diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 6b51cdab0a..f85c0d9a18 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -119,7 +119,7 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat **Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation **SKILL.md sections:** -- iOS source sets: iosMain, iosX64Main, iosArm64Main +- iOS source sets: iosMain, iosArm64Main - Swift interop: type mapping, nullability - expect/actual iOS: 10+ examples from quartz/iosMain - XCFramework setup: baseName = "quartz-kmpKit" diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh old mode 100644 new mode 100755 diff --git a/.claude/settings.json b/.claude/settings.json index af65d2dbca..4ab4d17262 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,18 +9,18 @@ } ] } - ] + ], "Stop": [ { "matcher": "", "hooks": [ { "type": "command", - "command": "./gradlew spotlessApply", + "command": "./gradlew spotlessApply 2>/dev/null || spotless-apply", "timeout": 120 } ] } ] } -} \ No newline at end of file +} diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md new file mode 100644 index 0000000000..36bf02c91c --- /dev/null +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -0,0 +1,77 @@ +--- +name: find-missing-translations +description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language +--- + +# Find Missing Translations + +## Overview + +Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation. + +## When to Use + +- Need to find untranslated strings for a specific locale +- Preparing a batch of strings for a translator +- Checking translation coverage after adding new features + +## Technique + +### 1. Identify files + +``` +Default: amethyst/src/main/res/values/strings.xml +Target: amethyst/src/main/res/values-/strings.xml +``` + +Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`). + +### 2. Extract and diff keys + +Use a single bash pipeline to extract translatable keys from both files and diff them: + +```bash +# Extract translatable keys from default (exclude translatable="false") +comm -23 \ + <(grep 'Valid + Valid from %1$s + Lists +``` + +Also check `` and `` tags using the same approach if the project uses them. + +## Common Mistakes + +- **Forgetting `translatable="false"`** — these should never appear in locale files +- **Not checking string-arrays/plurals** — only checking `` misses other resource types +- **Modifying files** — this is a read-only research task unless the user asks to add entries \ No newline at end of file diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 3f38203989..9417851c41 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -47,7 +47,7 @@ ### :quartz (KMP Nostr Library) **Type:** Kotlin Multiplatform Library -**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64) +**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64) **Dependencies:** - External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable - Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain @@ -127,7 +127,6 @@ commonMain (base) │ ├─ androidMain (Android platform) │ └─ jvmMain (Desktop platform) └─ iosMain (iOS platform) - ├─ iosX64Main ├─ iosArm64Main └─ iosSimulatorArm64Main ``` diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md index b0aa2f8d6d..6f8e43f42d 100644 --- a/.claude/skills/kotlin-multiplatform/SKILL.md +++ b/.claude/skills/kotlin-multiplatform/SKILL.md @@ -110,8 +110,8 @@ Think of source sets as a dependency graph, not folders. │ - Jackson │ │ │ │ - OkHttp │ └────┬─────────────┘ └───┬───────────┬───┘ │ - │ │ ├─→ iosX64Main - ▼ ▼ ├─→ iosArm64Main + │ │ │ + ▼ ▼ ├─→ iosArm64Main ┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main │android │ │jvmMain │ │Main │ │(Desktop) │ @@ -252,7 +252,7 @@ expect fun currentTimeSeconds(): Long **iOS (iosMain):** - Active development, framework configured -- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main +- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main - Platform APIs via platform.posix, Security framework ### Web, wasm - Future Targets diff --git a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md index ec09e00332..b950c57d5b 100644 --- a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md +++ b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md @@ -29,7 +29,7 @@ Visual guide to source set organization with concrete examples from the codebase │ - Jackson │ │ - Platform libs │ │ - OkHttp │ └───────┬───────────┘ └────┬─────────┬───┘ │ - │ │ ├─→ iosX64Main (simulator Intel) + │ │ │ │ │ ├─→ iosArm64Main (device ARM64) │ │ └─→ iosSimulatorArm64Main (Apple Silicon) ▼ ▼ @@ -238,7 +238,6 @@ iosMain { } } -val iosX64Main by getting { dependsOn(iosMain.get()) } val iosArm64Main by getting { dependsOn(iosMain.get()) } val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } ``` @@ -249,7 +248,6 @@ val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } - Different from Android/Desktop **Architecture targets:** -- iosX64Main: Intel simulator - iosArm64Main: Device (iPhone, iPad) - iosSimulatorArm64Main: Apple Silicon simulator @@ -326,7 +324,7 @@ commonMain | androidMain | jvmAndroid | Android framework | Activity, ViewModel | | jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar | | iosMain | commonMain | iOS platform | Security framework | -| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific | +| iosMain | Simulator (Intel) | Architecture-specific | | iosArm64Main | iosMain | Device (ARM64) | Architecture-specific | | jsMain | commonMain | JS/DOM | Web (future) | | wasmMain | commonMain | wasm APIs | WebAssembly (future) | diff --git a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md index 7ccf720fb8..c2a4d71299 100644 --- a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md +++ b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md @@ -76,7 +76,6 @@ fun main() = application { **Source sets:** - iosMain (common iOS code) -- iosX64Main (Intel simulator) - iosArm64Main (device - iPhone/iPad) - iosSimulatorArm64Main (Apple Silicon simulator) @@ -110,7 +109,7 @@ actual object Secp256k1Instance { ```kotlin // quartz/build.gradle.kts kotlin { - listOf(iosX64(), iosArm64(), iosSimulatorArm64()) + listOf(macosArm64(), iosArm64(), iosSimulatorArm64()) .forEach { target -> target.binaries.framework { baseName = "quartz-kmpKit" @@ -310,7 +309,7 @@ fun parseJson(json: String): Event { - Manual desktop app testing **iOS:** -- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.) +- Unit tests: iosTest (iosArm64Test, etc.) - Simulator/device testing **Web (future):** diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index a103377d8b..98164fa9ea 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -609,7 +609,7 @@ SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP- import com.vitorpamplona.quartz.nip01Core.store.EventStore import android.content.Context -val store = EventStore(context) +val store = EventStore() // Insert store.insert(event) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 903d42dc60..2c0dbdc27f 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -12,7 +12,7 @@ echo "$JAVA_HOME" echo "$(java -version)" echo "Running test... " -./gradlew test +./gradlew test --quiet status=$? diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61e76d85f9..0377b37359 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Test/Build Android +name: Test/Build on: pull_request: @@ -6,78 +6,190 @@ on: push: branches: [main] -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: Linter (gradle) run: ./gradlew spotlessCheck + test: + needs: lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + 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: Cache gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Test (gradle) run: ./gradlew test --no-daemon - name: Android Test Report uses: asadmansr/android-test-report-action@v1.2.0 - if: ${{ always() }} # IMPORTANT: run Android Test Report regardless + if: ${{ always() && matrix.os == 'ubuntu-latest' }} + + - name: Upload Test Results + uses: actions/upload-artifact@v6 + if: ${{ always() && matrix.os == 'ubuntu-latest' }} + with: + name: Test Reports + path: amethyst/build/reports + + build-android: + needs: 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: Cache gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- - name: Build APK (gradle) run: ./gradlew assembleDebug - name: Upload Play APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Play Debug APK path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk - name: Upload FDroid APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: FDroid Debug APK path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk - - name: Build APK (gradle) + - name: Build Benchmark APK (gradle) run: ./gradlew assembleBenchmark - name: Upload Play APK Benchmark - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Play Benchmark APK path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk - name: Upload FDroid APK Benchmark - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: FDroid Benchmark APK path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk - name: Upload Compose Reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Compose Reports path: amethyst/build/compose_compiler - - name: Upload Test Results - uses: actions/upload-artifact@v4 - with: - name: Test Reports - path: amethyst/build/reports + build-desktop: + needs: test + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + task: packageDeb + artifact-name: Desktop Linux DEB + artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb + - os: macos-latest + task: packageDmg + artifact-name: Desktop macOS DMG + artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg + - os: windows-latest + task: packageMsi + artifact-name: Desktop Windows MSI + artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + 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: Cache gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build Desktop Distribution + run: ./gradlew :desktopApp:${{ matrix.task }} + + - name: Upload Desktop Distribution + uses: actions/upload-artifact@v6 + with: + name: ${{ matrix.artifact-name }} + path: ${{ matrix.artifact-path }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 44f3d56fdc..beb08874b5 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -6,23 +6,42 @@ on: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 jobs: - deploy: + create-release: + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: true + + deploy-android: + needs: create-release runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -38,7 +57,6 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - name: Sign AAB (F-Droid) @@ -50,7 +68,6 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - name: Build APK @@ -65,7 +82,6 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - name: Sign APK (F-Droid) @@ -77,20 +93,8 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: false - prerelease: true - # Google Play APK - name: Upload Play APK Universal Asset id: upload-release-asset-play-universal-apk @@ -98,7 +102,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -109,7 +113,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -120,7 +124,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -131,7 +135,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -142,7 +146,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -154,7 +158,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -165,7 +169,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -176,7 +180,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -187,7 +191,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -198,13 +202,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk asset_content_type: application/zip - - # Google Play AAB - name: Upload Google Play AAB Asset id: upload-release-asset-play-aab @@ -212,7 +214,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab asset_name: amethyst-googleplay-${{ github.ref_name }}.aab asset_content_type: application/zip @@ -224,7 +226,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab asset_name: amethyst-fdroid-${{ github.ref_name }}.aab asset_content_type: application/zip @@ -236,3 +238,65 @@ jobs: ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} + + deploy-desktop: + needs: create-release + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + task: packageDeb + format: deb + platform: linux + - os: macos-latest + task: packageDmg + format: dmg + platform: macos + - os: windows-latest + task: packageMsi + format: msi + platform: windows + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + 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: Cache gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build Desktop Distribution + run: ./gradlew :desktopApp:${{ matrix.task }} + + - name: Find distribution file + id: find-dist + run: | + DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1) + echo "path=$DIST_FILE" >> $GITHUB_OUTPUT + echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT + + - name: Upload Desktop Distribution to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ steps.find-dist.outputs.path }} + asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }} + asset_content_type: application/octet-stream diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index d2707654d0..923e7fa492 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: crowdin action uses: crowdin/github-action@v2 diff --git a/.gitignore b/.gitignore index 43404b2597..de1c06cf0b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ /.idea/AndroidProjectSystem.xml /.idea/deviceManager.xml /.idea/inspectionProfiles/ +/.idea/migrations.xml /commons/.idea/gradle.xml /commons/.idea/misc.xml /commons/.idea/workspace.xml @@ -32,6 +33,10 @@ /captures .cxx +# superpowers skill +.superpowers +docs/brainstorms +docs/superpowers # Built application files *.apk @@ -149,3 +154,11 @@ lint/tmp/ # Local task tracking TASKS.md + +# Claude Code local settings +.claude/settings.local.json + +# Downloaded VLC binaries (vlc-setup plugin) +desktopApp/src/jvmMain/appResources/linux/ +desktopApp/src/jvmMain/appResources/macos/ +desktopApp/src/jvmMain/appResources/windows/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 193605cf98..26ef7ac12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ + +# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21 + Adds support for creating and rendering NIP-85 Polls +- Redesign of the poll and zap poll cards +- Adds Special notification card that stays while the poll is running Adds support for Relay Feeds - Adds support for NIP-51 favorite relay feeds @@ -15,7 +20,10 @@ Redesigns Media Player - Turn video controller creation into a flow to fix playback lifecycle issues - Adds support for uploading audio -Adds support for NIP events (kind 30817) +Adds support for NIP-47 Wallets and compete NWC spec +- Adds views for Balance and Transactions +- Add transaction filtering and pagination to wallet screen +- Added several test cases from other repos to guarantee interoperability Adds support for NIP-52 Calendar appointments @@ -23,34 +31,38 @@ Adds support for NIP-39 External Identities with kind 10011 Adds support for NIP-C0 Code Snippets +Adds support for NIPs on Nostr (event kind 30817) + Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents. -Adds support for NIP-40 Expirations in any new post. +Adds support for custom NIP-40 Expirations in any new post. +- Displays expirations on posts and DMs Adds support for NIP-66 Relay Monitor and discovery support to Quartz -Adds support for Namecoin .bit urls to NIP-05 -- Adds choice of ElectrumX server to resolve namecoins. +Adds support for Attestations (https://attestr.xyz/) Adds basic support for Chess with Jester protocol Adds NIP-46 Bunker support to Quartz and Amethyst Desktop -Adds a Broadcasting feedback pop-up in the Complete UI mode +Adds support for inline reply, mark as read from Push Notifications -Adds support for rendering Zap events when quoted inside of posts. +Removes NIP-04 DMs and blocks DM sending if the receiver doesn't have NIP-17 relay lists. -Removes support for NIP-96 and updates Blossom recommendations - -Adds support to upload Documents to all new post screens. +Uploads: +- Adds support to upload Documents to all new post screens. +- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Adds encrypted file upload fallback option for NIP-17 chats +- Removes support for NIP-96 and updates Blossom recommendations Content warning improvements: -- Adds optional description field for sensitive content warnings in new posts. +- Adds an optional description field for sensitive content warnings in new posts. - Displays additional information on warning composables -Redesigns and reorganizes Setting pages +Settings redesign - Consolidate drawer settings into a single Settings hub screen - Redesigns Zap Amount and NWC setup screens - Redesigns Custom zap amount screens @@ -65,7 +77,14 @@ URL/URI parser rewrite in Kotlin multiplatform (KMP) - Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd - Adds a parser for blossom: uris -Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx +UI Improvements: +- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx +- New UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- New UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Draft Screen requests confirmation before deleting drafts on swipe +- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Adds support for rendering Zap events when quoted inside of posts. +- Adds a Broadcasting feedback pop-up in the Complete UI mode Relay Management: - Adds relay search tooltip when adding relays @@ -73,7 +92,9 @@ Relay Management: - Adds active subscriptions and outbox event in the queue to relay information - Adds a complete list of event kind names to the subscription card to relay information - Tracks and displays connection success rate on relay settings -- Add relay settings export functionality +- Adds relay settings export functionality +- Adds NIP-45 count queries to show how many events each relay has. +- Adds Relay sync utility to help users move posts between relays. Search fixes - Breaks the search filter into two subscriptions to prioritize Metadata without punishing content. @@ -86,11 +107,14 @@ Search fixes Profiles: - Adds a profile picture upload button when the user has no picture +- Adds last seen to the user profile +- Adds nprofile and npub copy options to the profile - Groups received zap amounts by sending the user in the profile tab - Increases the limit of Zap downloads for profiles to 1000 - Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx - Migrates profile galleries to display a thumbnail for videos - Fixes profile galleries' aspect ratios +- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins. Bulk Follow onboarding - Adds screens to search for a user and to copy his/her follow list @@ -124,6 +148,7 @@ Fixes: - Fixes bug on Show More calculations for very long texts without spaces - Fixing IO Dispatchers and coroutine scopes of choice - Fixes anySync parallel operation that was returning the first result, not the first positive "any". +- Fixes Req onCannotConnect listeners to the relays that actually sent the req AI: - Add SKILL.md for AI agent customization @@ -135,6 +160,14 @@ Defaults: - Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays - Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers +Quartz: +- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support +- Adds support for dynamic policies to the relay implementation. +- Migrates Quartz EventStore from Android-only to KMP +- Adds a reqUntilEoseAsFlow extension to the Nostr Client +- Adds comprehensive NIP-46 Bunker support +- Adds comprehensive support for NIP-47 non-payment methods. + Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k - Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge. - Provide implementation for LargeCache, using a CacheMap @@ -147,7 +180,6 @@ Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53 - Provide implementation for AESGCM - Provide implementation for DigestInstance - Provide implementation for LibSodium -- Adds a reqUntilEoseAsFlow extension to the Nostr Client Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c - Adds NIP-46 Bunker Login @@ -157,6 +189,8 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7 - Adds encrypted DMs (NIP-04/NIP-17) - Adds proper empty states with EOSE tracking - Adds multi-column deck layout +- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox +- Adds advanced search with NIP-50, collapsible sections, and nav state preservation - Clear stored credentials on logout - Adds bunker heartbeat indicator - Adds QR-based signer pairing @@ -174,6 +208,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7 Code Quality - Migrates to AGP 9.0 +- Adds Amethyst Desktop to CI/CD and Release builds - Removes the in-app memory counter methods - Refactors the old NIP-05 code on Quartz - Migrates contact list management to addressable notes @@ -7006,4 +7041,4 @@ First public version with: [v0.4]: https://github.com/vitorpamplona/amethyst/compare/v0.3...v0.4 [v0.3]: https://github.com/vitorpamplona/amethyst/compare/v0.2...v0.3 [v0.2]: https://github.com/vitorpamplona/amethyst/compare/v0.1...v0.2 -[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1 \ No newline at end of file +[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1 diff --git a/README.md b/README.md index d1d9dbe7ba..a19f165504 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,13 @@ Build and run the Desktop app (requires Java 21+): ```bash ./gradlew :desktopApp:run ``` +Full build (including tests) +```bash +./gradlew build +``` +Requirements: +- Xcode and iOS simulator +- libsodium installed (e.g. via brew: `brew install libsodium` ## Testing ```bash diff --git a/amethyst/build.gradle b/amethyst/build.gradle index fe6c881a89..b5973ec7d0 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -36,6 +36,17 @@ def generateVersionName(String baseVersion) { } } +// 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() @@ -336,9 +347,7 @@ dependencies { fdroidImplementation libs.unifiedpush // Charts - implementation libs.vico.charts.core implementation libs.vico.charts.compose - implementation libs.vico.charts.views implementation libs.vico.charts.m3 // GeoHash @@ -352,6 +361,9 @@ dependencies { // Image compression lib implementation libs.zelory.image.compressor + // EXIF metadata stripping + implementation libs.androidx.exifinterface + // Voice anonymization DSP implementation libs.tarsosdsp diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt new file mode 100644 index 0000000000..f892f67403 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EventSyncTest { + companion object { + val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl() + val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val rootClient = + OkHttpClient + .Builder() + .followRedirects(true) + .followSslRedirects(true) + .addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05")) + .build() + val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } + } + + @Test + fun testSync() = + runBlocking { + val sync = + EventSync( + accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + relayDb = { + listOf(Constants.mom, Constants.nos) + }, + outboxTargets = { setOf(vitor) }, + inboxTargets = { setOf(vitor) }, + dmTargets = { setOf(vitor) }, + clientBuilder = { + NostrClient(socketBuilder, appScope) + }, + scope = appScope, + ) + + sync.runSync() + } + + @Test + fun testFiatjafSync() = + runBlocking { + val sync = + EventSync( + accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + relayDb = { listOf(fiatjaf) }, + outboxTargets = { setOf(vitor) }, + inboxTargets = { setOf(vitor) }, + dmTargets = { setOf(vitor) }, + clientBuilder = { + val newClient = NostrClient(socketBuilder, appScope) + val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false) + + val signer = NostrSignerInternal(KeyPair()) + + // Authenticates with relays. + val auth = + RelayAuthenticator( + newClient, + appScope, + signWithAllLoggedInUsers = { authTemplate -> + listOf(signer.sign(authTemplate)) + }, + ) + + newClient + }, + scope = appScope, + ) + + sync.runSync() + } +} diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 7339ba1bb1..4aae58459c 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -240,6 +240,11 @@ + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 713fe5978f..968a6e75c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -91,6 +91,7 @@ private object PrefKeys { const val NOSTR_PUBKEY = "nostr_pubkey" const val LOCAL_RELAY_SERVERS = "localRelayServers" const val DEFAULT_FILE_SERVER = "defaultFileServer" + const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -322,6 +323,8 @@ object LocalPreferences { JsonMapper.toJson(settings.defaultFileServer), ) + putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) + putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value)) @@ -461,6 +464,7 @@ object LocalPreferences { val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] + val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() @@ -503,6 +507,7 @@ object LocalPreferences { externalSignerPackageName = externalSignerPackageName, localRelayServers = MutableStateFlow(localRelayServers), defaultFileServer = defaultFileServer, + stripLocationOnUpload = stripLocationOnUpload, defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 84e3cc0f7a..70735fdd83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -171,7 +171,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -225,7 +226,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.math.BigDecimal -import java.util.Locale import kotlin.coroutines.cancellation.CancellationException @OptIn(DelicateCoroutinesApi::class) @@ -507,7 +507,7 @@ class Account( sendNewAppSpecificData() } - suspend fun updateTranslateTo(languageCode: Locale) { + suspend fun updateTranslateTo(languageCode: String) { if (settings.updateTranslateTo(languageCode)) { sendNewAppSpecificData() } @@ -591,6 +591,14 @@ class Account( suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) + suspend fun sendNwcRequest( + request: Request, + onResponse: (Response?) -> Unit, + ) { + val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse) + client.send(event, setOf(relay)) + } + suspend fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, @@ -1601,7 +1609,7 @@ class Account( client.send(newEvent, outboxRelays.flow.value + destinationRelays) } - suspend fun sendNip17EncryptedFile(template: EventTemplate) { + override suspend fun sendNip17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer) @@ -2008,6 +2016,7 @@ class Account( } scope.launch(Dispatchers.IO) { + @OptIn(kotlinx.coroutines.FlowPreview::class) settings.saveable.debounce(1000).collect { if (it.accountSettings != null) { LocalPreferences.saveToEncryptedStorage(it.accountSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 5e6ad853d2..6ed5a2cf1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -63,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable -import java.util.Locale val DefaultChannels = listOf( @@ -161,6 +160,7 @@ class AccountSettings( var externalSignerPackageName: String? = null, var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], + var stripLocationOnUpload: Boolean = true, val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -266,6 +266,13 @@ class AccountSettings( } } + fun changeStripLocationOnUpload(strip: Boolean) { + if (stripLocationOnUpload != strip) { + stripLocationOnUpload = strip + saveAccountSettings() + } + } + // --- // list names // --- @@ -332,11 +339,11 @@ class AccountSettings( saveAccountSettings() } - fun translateToContains(languageCode: Locale) = + fun translateToContains(languageCode: String) = syncedSettings.languages.translateTo.value - .contains(languageCode.language) + .contains(languageCode) - fun updateTranslateTo(languageCode: Locale): Boolean { + fun updateTranslateTo(languageCode: String): Boolean { if (syncedSettings.languages.updateTranslateTo(languageCode)) { saveAccountSettings() return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 3789e1c983..a1ab6665bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -27,7 +27,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import java.util.Locale @Stable class AccountSyncedSettings( @@ -165,11 +164,11 @@ class AccountLanguagePreferences( dontTranslateFrom.update { it - languageCode } } - fun translateToContains(languageCode: Locale) = translateTo.value.contains(languageCode.language) + fun translateToContains(languageCode: String) = translateTo.value.contains(languageCode) - fun updateTranslateTo(languageCode: Locale): Boolean { - if (translateTo.value != languageCode.language) { - translateTo.tryEmit(languageCode.language) + fun updateTranslateTo(languageCode: String): Boolean { + if (translateTo.value != languageCode) { + translateTo.tryEmit(languageCode) return true } return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 750d10ae9c..16d054ea6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -58,7 +62,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -135,8 +139,8 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpired -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -325,7 +329,7 @@ object LocalCache : ILocalCache, ICacheProvider { newFilter.init() - observables.put(newFilter, newFilter) + observables[newFilter] = newFilter awaitClose { observables.remove(newFilter) @@ -358,19 +362,19 @@ object LocalCache : ILocalCache, ICacheProvider { fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) - override fun getOrCreateUser(key: HexKey): User { - require(isValidHex(key = key)) { "$key is not a valid hex" } + override fun getOrCreateUser(pubkey: HexKey): User { + require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" } - return users.getOrCreate(key) { - val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key)) - val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key)) + return users.getOrCreate(pubkey) { + val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey)) + val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey)) User(it, nip65RelayListNote, dmRelayListNote) } } - override fun getUserIfExists(key: String): User? { - if (key.isEmpty()) return null - return users.get(key) + override fun getUserIfExists(pubkey: String): User? { + if (pubkey.isEmpty()) return null + return users.get(pubkey) } override fun countUsers(predicate: (String, User) -> Boolean): Int { @@ -394,7 +398,7 @@ object LocalCache : ILocalCache, ICacheProvider { fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) - override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) } + override fun getNoteIfExists(hexKey: String): Note? = if (hexKey.length == 64) notes.get(hexKey) else Address.parse(hexKey)?.let { addressables.get(it) } fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) @@ -619,6 +623,30 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( + event: AttestationEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: AttestationRequestEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: AttestorRecommendationEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: AttestorProficiencyEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consumeRegularEvent( event: Event, relay: NormalizedRelayUrl?, @@ -961,7 +989,7 @@ object LocalCache : ILocalCache, ICacheProvider { @Suppress("DEPRECATION") fun computeReplyTo(event: Event): List = when (event) { - is PollNoteEvent -> { + is ZapPollEvent -> { event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } } @@ -1059,7 +1087,7 @@ object LocalCache : ILocalCache, ICacheProvider { } fun consume( - event: PollNoteEvent, + event: ZapPollEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) @@ -2250,6 +2278,7 @@ object LocalCache : ILocalCache, ICacheProvider { requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { responseCallback(event) } @@ -2344,8 +2373,21 @@ object LocalCache : ILocalCache, ICacheProvider { if (key != null) { val note = getNoteIfExists(key) - if ((note != null) && !excludeNoteEventFromSearchResults(note)) { - return listOfNotNull(note) + val noteEvent = note?.event + val newNote = + if (noteEvent is AddressableEvent) { + val addressableNote = getAddressableNoteIfExists(noteEvent.address()) + if (addressableNote?.event?.id == note.idHex) { + addressableNote + } else { + note + } + } else { + note + } + + if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) { + return listOfNotNull(newNote) } } @@ -3050,6 +3092,10 @@ object LocalCache : ILocalCache, ICacheProvider { is AppDefinitionEvent -> consume(event, relay, wasVerified) is AppRecommendationEvent -> consume(event, relay, wasVerified) is AppSpecificDataEvent -> consume(event, relay, wasVerified) + is AttestationEvent -> consume(event, relay, wasVerified) + is AttestationRequestEvent -> consume(event, relay, wasVerified) + is AttestorRecommendationEvent -> consume(event, relay, wasVerified) + is AttestorProficiencyEvent -> consume(event, relay, wasVerified) is AudioHeaderEvent -> consume(event, relay, wasVerified) is AudioTrackEvent -> consume(event, relay, wasVerified) is BadgeAwardEvent -> consume(event, relay, wasVerified) @@ -3140,7 +3186,7 @@ object LocalCache : ILocalCache, ICacheProvider { is PublicMessageEvent -> consume(event, relay, wasVerified) is PeopleListEvent -> consume(event, relay, wasVerified) is CodeSnippetEvent -> consume(event, relay, wasVerified) - is PollNoteEvent -> consume(event, relay, wasVerified) + is ZapPollEvent -> consume(event, relay, wasVerified) is PollEvent -> consume(event, relay, wasVerified) is PollResponseEvent -> consume(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index d4f012cbbe..bb40be6cfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -31,13 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectRequestCache -import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectResponseCache -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectRequestCache +import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -114,7 +114,7 @@ class NwcSignerState( fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null - override fun isNIP47Author(pubkey: HexKey?): Boolean = nip47Signer.value.pubKey == pubkey + override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey /** * Decrypts a NIP-47 payment request using the current signer. @@ -138,6 +138,45 @@ class NwcSignerState( return zapPaymentResponseDecryptionCache.value.decryptResponse(event) } + /** + * Sends a generic NIP-47 request to the connected wallet. + * Subscribes to responses and waits up to 60s for a reply. + * + * @param request the NIP-47 request to send + * @param onResponse callback to handle the response from the wallet + * @return a pair containing the request event and target relay URL + * @throws IllegalArgumentException if no NIP-47 wallet is set up + */ + suspend fun sendNwcRequest( + request: Request, + onResponse: (Response?) -> Unit, + ): Pair { + val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup") + + val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value) + + val filter = + NWCPaymentQueryState( + fromServiceHex = walletService.pubKeyHex, + toUserHex = event.pubKey, + replyingToHex = event.id, + relay = walletService.relayUri, + ) + + nwcFilterAssembler.subscribe(filter) + + scope.launch(Dispatchers.IO) { + delay(60000) + nwcFilterAssembler.unsubscribe(filter) + } + + cache.consume(event, null, true, walletService.relayUri) { + onResponse(decryptResponse(it)) + } + + return Pair(event, walletService.relayUri) + } + /** * Sends a zap payment request to a connected Lightning wallet. * Subscribes to responses and waits up to 60s for a reply. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index 91f5fc1ac0..54c03aec62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.title import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -181,7 +181,7 @@ class LabeledBookmarkListsState( val template = listEvent.update { - if (listName != null) name(listName) + if (listName != null) title(listName) if (listDescription != null) description(listDescription) if (listImage != null) image(listImage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index 3787caac9f..d279ec20e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.description import com.vitorpamplona.quartz.nip51Lists.peopleList.image -import com.vitorpamplona.quartz.nip51Lists.peopleList.name +import com.vitorpamplona.quartz.nip51Lists.peopleList.title import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -226,7 +226,7 @@ class PeopleListsState( val template = listEvent.update { - if (listName != null) name(listName) + if (listName != null) title(listName) if (listDescription != null) description(listDescription) if (listImage != null) image(listImage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt index f3d7ae385c..dfb319822e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -79,6 +79,7 @@ class MergedFollowListsState( communities = community.mapTo(mutableSetOf()) { it.address.toValue() }, ) + @OptIn(kotlinx.coroutines.FlowPreview::class) val flow: StateFlow = combine( listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index ae28e98d8d..2607a9cebb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt index 5f2b176905..efd6e12fd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.cashu.v4 +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.cbor.ByteString @@ -34,6 +35,7 @@ class V4Token( val t: Array?, ) +@OptIn(ExperimentalSerializationApi::class) @Serializable class V4T( // identifier @@ -42,6 +44,7 @@ class V4T( val p: Array, ) +@OptIn(ExperimentalSerializationApi::class) @Serializable class V4Proof( // amount @@ -57,6 +60,7 @@ class V4Proof( val w: String? = null, ) +@OptIn(ExperimentalSerializationApi::class) @Serializable class V4DleqProof( @ByteString diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index c3160d01e1..2ccd1a0697 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -183,7 +183,7 @@ class EventNotificationConsumer( } } - private fun notify( + private suspend fun notify( event: ChatMessageEncryptedFileHeaderEvent, account: Account, ) { @@ -210,13 +210,13 @@ class EventNotificationConsumer( val content = chatNote.event?.content ?: "" val user = chatNote.author?.toBestDisplayName() ?: "" val userPicture = chatNote.author?.profilePicture() - val noteUri = - chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + - account.signer.pubKey - .hexToByteArray() - .toNpub() + val accountNpub = + account.signer.pubKey + .hexToByteArray() + .toNpub() + val chatroomMembers = chatRoom.users.joinToString(",") + val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - // TODO: Show Image on notification notificationManager() .sendDMNotification( event.id, @@ -226,12 +226,15 @@ class EventNotificationConsumer( userPicture, noteUri, applicationContext, + accountNpub = accountNpub, + accountPictureUrl = account.userProfile().profilePicture(), + chatroomMembers = chatroomMembers, ) } } } - private fun notify( + private suspend fun notify( event: ChatMessageEvent, account: Account, ) { @@ -255,20 +258,25 @@ class EventNotificationConsumer( val content = chatNote.event?.content ?: "" val user = chatNote.author?.toBestDisplayName() ?: "" val userPicture = chatNote.author?.profilePicture() - val noteUri = - chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + - account.signer.pubKey - .hexToByteArray() - .toNpub() + val accountNpub = + account.signer.pubKey + .hexToByteArray() + .toNpub() + val chatroomMembers = chatRoom.users.joinToString(",") + val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub + notificationManager() .sendDMNotification( - event.id, - content, - user, - event.createdAt, - userPicture, - noteUri, - applicationContext, + id = event.id, + messageBody = content, + senderName = user, + time = event.createdAt, + pictureUrl = userPicture, + uri = noteUri, + applicationContext = applicationContext, + accountNpub = accountNpub, + accountPictureUrl = account.userProfile().profilePicture(), + chatroomMembers = chatroomMembers, ) } } @@ -297,13 +305,25 @@ class EventNotificationConsumer( decryptContent(note, account.signer)?.let { content -> val user = note.author?.toBestDisplayName() ?: "" val userPicture = note.author?.profilePicture() - val noteUri = - note.toNEvent() + ACCOUNT_QUERY_PARAM + - account.signer.pubKey - .hexToByteArray() - .toNpub() + val accountNpub = + account.signer.pubKey + .hexToByteArray() + .toNpub() + val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub + notificationManager() - .sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext) + .sendDMNotification( + id = event.id, + messageBody = content, + senderName = user, + time = event.createdAt, + pictureUrl = userPicture, + uri = noteUri, + applicationContext = applicationContext, + accountNpub = accountNpub, + accountPictureUrl = account.userProfile().profilePicture(), + chatroomMembers = null, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt new file mode 100644 index 0000000000..f5009118e9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -0,0 +1,113 @@ +/* + * 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.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +class NotificationReplyReceiver : BroadcastReceiver() { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onReceive( + context: Context, + intent: Intent, + ) { + val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0) + val notificationManager = + ContextCompat.getSystemService(context, NotificationManager::class.java) + as NotificationManager + + when (intent.action) { + NotificationUtils.MARK_READ_ACTION -> { + notificationManager.cancel(notificationId) + } + + NotificationUtils.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 chatroomMembersStr = intent.getStringExtra(NotificationUtils.KEY_CHATROOM_MEMBERS) ?: return + val members = chatroomMembersStr.split(",").filter { it.isNotBlank() } + + if (members.isEmpty()) return + + val pendingResult = goAsync() + + scope.launch { + // activates the relay to send the message. + val collectionJob = + scope.launch { + Amethyst.instance.relayProxyClientConnector.relayServices + .collect() + } + + try { + sendReply(accountNpub, members, replyText) + notificationManager.cancel(notificationId) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NotificationReply", "Failed to send reply: ${e.message}") + } finally { + pendingResult.finish() + + // closes the relay connection. + collectionJob.cancel() + } + } + } + } + } + + private suspend fun sendReply( + accountNpub: String, + chatroomMembers: List, + replyText: String, + ) { + val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + + val recipients = chatroomMembers.map { PTag(it) } + val template = ChatMessageEvent.build(msg = replyText, to = recipients) + + account.sendNip17PrivateMessage(template) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 2472c097d0..9c16a2e64e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -25,23 +25,37 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import coil3.ImageLoader import coil3.asDrawable -import coil3.executeBlocking import coil3.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext object NotificationUtils { private var dmChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" + const val REPLY_ACTION = "com.vitorpamplona.amethyst.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" + + private const val DM_SUMMARY_ID = 0x10000 + private const val ZAP_SUMMARY_ID = 0x20000 fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { if (dmChannel != null) return dmChannel!! @@ -50,13 +64,12 @@ object NotificationUtils { NotificationChannel( stringRes(applicationContext, R.string.app_notification_dms_channel_id), stringRes(applicationContext, R.string.app_notification_dms_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, + NotificationManager.IMPORTANCE_HIGH, ).apply { description = stringRes(applicationContext, R.string.app_notification_dms_channel_description) } - // Register the channel with the system val notificationManager: NotificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -78,7 +91,6 @@ object NotificationUtils { stringRes(applicationContext, R.string.app_notification_zaps_channel_description) } - // Register the channel with the system val notificationManager: NotificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -87,7 +99,7 @@ object NotificationUtils { return zapChannel!! } - fun NotificationManager.sendZapNotification( + suspend fun NotificationManager.sendZapNotification( id: String, messageBody: String, messageTitle: String, @@ -96,109 +108,109 @@ object NotificationUtils { uri: String, applicationContext: Context, ) { - val zapChannel = getOrCreateZapChannel(applicationContext) + getOrCreateZapChannel(applicationContext) val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id) sendNotification( - id, - messageBody, - messageTitle, - time, - pictureUrl, - uri, - channelId, - ZAP_GROUP_KEY, - applicationContext, + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + time = time, + pictureUrl = pictureUrl, + uri = uri, + channelId = channelId, + notificationGroupKey = ZAP_GROUP_KEY, + category = NotificationCompat.CATEGORY_SOCIAL, + summaryId = ZAP_SUMMARY_ID, + summaryText = stringRes(applicationContext, R.string.app_notification_zaps_summary), + applicationContext = applicationContext, ) } - fun NotificationManager.sendDMNotification( + suspend fun NotificationManager.sendDMNotification( id: String, messageBody: String, - messageTitle: String, + senderName: String, time: Long, pictureUrl: String?, uri: String, applicationContext: Context, + accountNpub: String? = null, + accountPictureUrl: String? = null, + chatroomMembers: String? = null, ) { - val dmChannel = getOrCreateDMChannel(applicationContext) + getOrCreateDMChannel(applicationContext) val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id) - sendNotification( - id, - messageBody, - messageTitle, - time, - pictureUrl, - uri, - channelId, - DM_GROUP_KEY, - applicationContext, + sendDMNotificationStyled( + id = id, + messageBody = messageBody, + senderName = senderName, + time = time, + pictureUrl = pictureUrl, + uri = uri, + channelId = channelId, + applicationContext = applicationContext, + accountNpub = accountNpub, + accountPictureUrl = accountPictureUrl, + chatroomMembers = chatroomMembers, ) } - fun NotificationManager.sendNotification( + private suspend fun loadBitmap( + pictureUrl: String, + applicationContext: Context, + ): Bitmap? = + withContext(Dispatchers.IO) { + try { + val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build() + val imageLoader = ImageLoader(applicationContext) + val result = imageLoader.execute(request) + (result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap + } catch (_: Exception) { + null + } + } + + private suspend fun NotificationManager.sendDMNotificationStyled( id: String, messageBody: String, - messageTitle: String, + senderName: String, time: Long, pictureUrl: String?, uri: String, channelId: String, - notificationGroupKey: String, - applicationContext: Context, - ) { - if (pictureUrl != null) { - val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build() - - val imageLoader = ImageLoader(applicationContext) - val imageResult = imageLoader.executeBlocking(request) - sendNotificationInner( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - picture = imageResult.image?.asDrawable(applicationContext.resources) as? BitmapDrawable, - uri = uri, - channelId, - notificationGroupKey, - applicationContext = applicationContext, - ) - } else { - sendNotificationInner( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - picture = null, - uri = uri, - channelId, - notificationGroupKey, - applicationContext = applicationContext, - ) - } - } - - private fun NotificationManager.sendNotificationInner( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - picture: BitmapDrawable?, - uri: String, - channelId: String, - notificationGroupKey: String, applicationContext: Context, + accountNpub: String?, + accountPictureUrl: String?, + chatroomMembers: String?, ) { val notId = id.hashCode() - // dont notify twice - val notifications: Array = getActiveNotifications() - for (notification in notifications) { - if (notification.id == notId) { - return - } - } + if (isDuplicate(notId)) return + + val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) } + val accountBitmap = accountPictureUrl?.let { loadBitmap(it, applicationContext) } + + val senderIcon = bitmap?.let { IconCompat.createWithBitmap(it) } + val accountIcon = accountBitmap?.let { IconCompat.createWithBitmap(it) } + + val sender = + Person + .Builder() + .setName(senderName) + .apply { senderIcon?.let { setIcon(it) } } + .build() + + val messagingStyle = + NotificationCompat + .MessagingStyle( + Person + .Builder() + .setName("Me") + .setIcon(accountIcon) + .build(), + ).addMessage(messageBody, time * 1000, sender) val contentIntent = Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } @@ -208,41 +220,198 @@ object NotificationUtils { applicationContext, notId, contentIntent, - PendingIntent.FLAG_MUTABLE, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - // Build the notification val builderPublic = NotificationCompat - .Builder( - applicationContext, - channelId, - ).setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) + .Builder(applicationContext, channelId) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(senderName) .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) - .setLargeIcon(picture?.bitmap) + .setLargeIcon(bitmap) .setContentIntent(contentPendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .setWhen(time * 1000) - // Build the notification val builder = NotificationCompat - .Builder( - applicationContext, - channelId, - ).setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(messageBody) - .setLargeIcon(picture?.bitmap) + .Builder(applicationContext, channelId) + .setSmallIcon(R.drawable.amethyst) + .setLargeIcon(bitmap) + .setStyle(messagingStyle) .setContentIntent(contentPendingIntent) .setPublicVersion(builderPublic.build()) .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(DM_GROUP_KEY) + .setAutoCancel(true) + .setWhen(time * 1000) + + // Direct Reply action + if (accountNpub != null && chatroomMembers != null) { + 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 = REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, accountNpub) + putExtra(KEY_CHATROOM_MEMBERS, chatroomMembers) + } + + 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) + } + + // Mark as Read action + val markReadIntent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = MARK_READ_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + } + + val markReadPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + notId + 1, + markReadIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val markReadAction = + NotificationCompat.Action + .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .build() + + builder.addAction(markReadAction) + + notify(notId, builder.build()) + + // Group summary notification + sendGroupSummary(channelId, DM_GROUP_KEY, DM_SUMMARY_ID, stringRes(applicationContext, R.string.app_notification_dms_summary), applicationContext) + } + + private suspend fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + time: Long, + pictureUrl: String?, + uri: String, + channelId: String, + notificationGroupKey: String, + category: String, + summaryId: Int, + summaryText: String, + applicationContext: Context, + ) { + val notId = id.hashCode() + + if (isDuplicate(notId)) return + + val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) } + + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } + + val contentPendingIntent = + PendingIntent.getActivity( + applicationContext, + notId, + contentIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val builderPublic = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) + .setLargeIcon(bitmap) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setWhen(time * 1000) + + val builder = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setStyle(NotificationCompat.BigTextStyle().bigText(messageBody)) + .setLargeIcon(bitmap) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(category) + .setGroup(notificationGroupKey) .setAutoCancel(true) .setWhen(time * 1000) notify(notId, builder.build()) + + sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext) + } + + private fun NotificationManager.sendGroupSummary( + channelId: String, + groupKey: String, + summaryId: Int, + summaryText: String, + applicationContext: Context, + ) { + val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId } + + if (activeCount < 2) return + + val summaryBuilder = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(R.drawable.amethyst) + .setGroup(groupKey) + .setGroupSummary(true) + .setAutoCancel(true) + .setStyle( + NotificationCompat + .InboxStyle() + .setSummaryText(summaryText), + ) + + notify(summaryId, summaryBuilder.build()) + } + + private fun NotificationManager.isDuplicate(notId: Int): Boolean { + val notifications: Array = activeNotifications + for (notification in notifications) { + if (notification.id == notId) { + return true + } + } + return false } /** Cancels all notifications. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index ab54dcc705..194b8f499e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -32,12 +32,9 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -45,9 +42,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +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.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @@ -134,61 +133,41 @@ fun OverflowMenuButton( modifier = Size20Modifier, ) } + } - DropdownMenu( - expanded = menuExpanded.value, - onDismissRequest = { menuExpanded.value = false }, - containerColor = Color.Black.copy(alpha = 0.85f), + if (menuExpanded.value) { + M3ActionDialog( + title = stringRes(R.string.playback_actions_dialog_title), + onDismiss = { menuExpanded.value = false }, ) { - if (showShare) { - DropdownMenuItem( - text = { Text(stringRes(R.string.share_or_save), color = Color.White) }, - onClick = { + M3ActionSection { + if (showShare) { + M3ActionRow( + icon = Icons.Default.Share, + text = stringRes(R.string.share_or_save), + ) { menuExpanded.value = false onShareClick() - }, - leadingIcon = { - Icon( - Icons.Default.Share, - contentDescription = null, - tint = Color.White, - ) - }, - ) - } - - if (showSave) { - DropdownMenuItem( - text = { Text(stringRes(R.string.download_to_phone), color = Color.White) }, - onClick = { + } + } + if (showSave) { + M3ActionRow( + icon = Icons.Default.SaveAlt, + text = stringRes(R.string.download_to_phone), + ) { menuExpanded.value = false onSaveClick() - }, - leadingIcon = { - Icon( - Icons.Default.SaveAlt, - contentDescription = null, - tint = Color.White, - ) - }, - ) - } - - if (showPip) { - DropdownMenuItem( - text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) }, - onClick = { + } + } + if (showPip) { + M3ActionRow( + icon = Icons.Default.PictureInPicture, + text = stringRes(R.string.picture_in_picture), + ) { menuExpanded.value = false onPipClick() - }, - leadingIcon = { - Icon( - Icons.Default.PictureInPicture, - contentDescription = null, - tint = Color.White, - ) - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index bf7c6659f3..0d22198a79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -38,7 +40,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent @@ -65,7 +67,7 @@ val NotificationsPerKeyKinds = ChannelMessageEvent.KIND, EphemeralChatEvent.KIND, BadgeAwardEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, PollEvent.KIND, PollResponseEvent.KIND, PublicMessageEvent.KIND, @@ -85,6 +87,12 @@ val NotificationsPerKeyKinds2 = InteractiveStorySceneEvent.KIND, ) +val NotificationsPerKeyKinds3 = + listOf( + AttestationRequestEvent.KIND, + AttestorRecommendationEvent.KIND, + ) + fun filterSummaryNotificationsToPubkey( relay: NormalizedRelayUrl, pubkey: HexKey?, @@ -130,7 +138,17 @@ fun filterNotificationsToPubkey( Filter( kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), - limit = 500, + limit = 200, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds3, + tags = mapOf("p" to listOf(pubkey)), + limit = 10, since = since, ), ), @@ -171,7 +189,17 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( Filter( kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), - limit = 20, + limit = 10, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds3, + tags = mapOf("p" to listOf(pubkey)), + limit = 2, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index be3e7e2527..91358cdfd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -51,14 +51,14 @@ class EventWatcherSubAssembler( } override fun updateFilter( - key: List, + keys: List, since: SincePerRelayMap?, ): List? { - if (key.isEmpty()) { + if (keys.isEmpty()) { return null } - lastNotesOnFilter = key.map { it.note } + lastNotesOnFilter = keys.map { it.note } return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) .map { group -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index b9d6f7c8d4..dc2c08557b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -22,7 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -45,8 +46,9 @@ val RepliesAndReactionsToAddressesKinds1 = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, CommentEvent.KIND, + AttestationEvent.KIND, ) val PostsAndChatMessagesToAddresses = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 4047222233..55220b18fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent @@ -53,6 +54,7 @@ val RepliesAndReactionsKinds = OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, + AttestationEvent.KIND, ) val RepliesAndReactionsKinds2 = @@ -63,7 +65,7 @@ val RepliesAndReactionsKinds2 = TorrentCommentEvent.KIND, GitReplyEvent.KIND, PollResponseEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, ) fun filterRepliesAndReactionsToNotes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt index b8e160f74c..0bea1b6b89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent fun filterNWCPaymentsFromRequests( serviceKeys: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt index 000f4e4480..00ca42f5ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent @SuppressLint("StateFlowValueCalledInComposition") @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index ad37d97fee..74ad107617 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -64,7 +64,7 @@ val SearchPostsByTextKinds1 = AudioHeaderEvent.KIND, AudioTrackEvent.KIND, PinListEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, ChannelCreateEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt new file mode 100644 index 0000000000..9e603f514b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt @@ -0,0 +1,407 @@ +/* + * 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.uploads + +import android.content.Context +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMetadataRetriever +import android.media.MediaMuxer +import android.net.Uri +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import java.io.File +import java.nio.ByteBuffer + +data class StrippingResult( + val uri: Uri, + val stripped: Boolean, +) + +object MetadataStripper { + private const val DEFAULT_REMUX_BUFFER_SIZE = 8 * 1024 * 1024 + + private val SENSITIVE_EXIF_TAGS = + arrayOf( + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_VERSION_ID, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_SOFTWARE, + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_COPYRIGHT, + ExifInterface.TAG_CAMERA_OWNER_NAME, + ExifInterface.TAG_BODY_SERIAL_NUMBER, + ExifInterface.TAG_LENS_SERIAL_NUMBER, + ExifInterface.TAG_LENS_MAKE, + ExifInterface.TAG_LENS_MODEL, + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME, + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_IMAGE_UNIQUE_ID, + ExifInterface.TAG_USER_COMMENT, + ) + + private fun extractorToCodecFlags(sampleFlags: Int): Int { + var flags = 0 + if (sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) { + flags = flags or MediaCodec.BUFFER_FLAG_KEY_FRAME + } + if (sampleFlags and MediaExtractor.SAMPLE_FLAG_PARTIAL_FRAME != 0) { + flags = flags or MediaCodec.BUFFER_FLAG_PARTIAL_FRAME + } + return flags + } + + private fun remuxTracks( + uri: Uri, + context: Context, + outputFile: File, + preStart: (MediaMuxer, MediaExtractor, Context, Uri) -> Unit = { _, _, _, _ -> }, + ): Boolean { + val extractor = MediaExtractor() + var muxer: MediaMuxer? = null + var muxerStarted = false + var succeeded = false + try { + extractor.setDataSource(context, uri, null) + + if (extractor.trackCount == 0) return false + + // Note: MediaMuxer may still write a creation timestamp and encoder info into + // the new container. This is not controllable via the Android API and is a + // known residual privacy limitation of the remux approach. + muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + + val trackIndexMap = mutableMapOf() + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + trackIndexMap[i] = muxer.addTrack(format) + extractor.selectTrack(i) + } + + preStart(muxer, extractor, context, uri) + + muxer.start() + muxerStarted = true + + // Size buffer to the largest track's KEY_MAX_INPUT_SIZE (covers 4K keyframes), + // falling back to 8MB if the format doesn't report it. + var maxInputSize = DEFAULT_REMUX_BUFFER_SIZE + for (i in 0 until extractor.trackCount) { + val fmt = extractor.getTrackFormat(i) + if (fmt.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) { + maxInputSize = maxOf(maxInputSize, fmt.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)) + } + } + val buffer = ByteBuffer.allocateDirect(maxInputSize) + val bufferInfo = MediaCodec.BufferInfo() + + while (true) { + val sampleSize = extractor.readSampleData(buffer, 0) + if (sampleSize < 0) break + + val outputTrack = trackIndexMap[extractor.sampleTrackIndex] ?: break + + bufferInfo.offset = 0 + bufferInfo.size = sampleSize + bufferInfo.presentationTimeUs = extractor.sampleTime + bufferInfo.flags = extractorToCodecFlags(extractor.sampleFlags) + + muxer.writeSampleData(outputTrack, buffer, bufferInfo) + extractor.advance() + } + + muxer.stop() + muxerStarted = false + succeeded = true + } finally { + if (muxerStarted) runCatching { muxer?.stop() } + muxer?.release() + extractor.release() + if (!succeeded && !outputFile.delete()) { + Log.w("MetadataStripper", "Failed to delete temp file: ${outputFile.absolutePath}") + } + } + return succeeded + } + + fun stripImageMetadata( + uri: Uri, + context: Context, + ): StrippingResult { + var tempFile: File? = null + return try { + val mimeType = context.contentResolver.getType(uri) ?: "" + val extension = + when { + mimeType.endsWith("jpeg", ignoreCase = true) || + mimeType.endsWith("jpg", ignoreCase = true) -> ".jpg" + + mimeType.endsWith("png", ignoreCase = true) -> ".png" + + mimeType.endsWith("webp", ignoreCase = true) -> ".webp" + + else -> ".tmp" + } + tempFile = File.createTempFile("stripped_", extension, context.cacheDir) + + val inputStream = + context.contentResolver.openInputStream(uri) + ?: run { + if (!tempFile.delete()) { + Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}") + } + return StrippingResult(uri, false) + } + inputStream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + val exif = ExifInterface(tempFile.absolutePath) + for (tag in SENSITIVE_EXIF_TAGS) { + exif.setAttribute(tag, null) + } + exif.saveAttributes() + + Log.d("MetadataStripper", "Stripped EXIF metadata from image") + StrippingResult(tempFile.toUri(), true) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (tempFile?.delete() == false) { + Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}") + } + Log.d("MetadataStripper", "Failed to strip image metadata: ${e.message}") + StrippingResult(uri, false) + } + } + + fun stripVideoMetadata( + uri: Uri, + context: Context, + ): StrippingResult { + return try { + val tempOutputFile = File.createTempFile("stripped_video_", ".mp4", context.cacheDir) + + val succeeded = + remuxTracks(uri, context, tempOutputFile) { muxer, _, ctx, sourceUri -> + // Rotation is a container-level property not included in track formats; + // read it explicitly and reapply so the output plays back with the correct orientation. + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(ctx, sourceUri) + val rotation = + retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) + ?.toIntOrNull() ?: 0 + if (rotation != 0) muxer.setOrientationHint(rotation) + } finally { + retriever.release() + } + } + + if (!succeeded) return StrippingResult(uri, false) + + Log.d("MetadataStripper", "Stripped metadata from video") + StrippingResult(tempOutputFile.toUri(), true) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d("MetadataStripper", "Failed to strip video metadata: ${e.message}") + StrippingResult(uri, false) + } + } + + fun stripAudioMetadata( + uri: Uri, + context: Context, + ): StrippingResult { + return try { + // Verify the primary track is AAC/MP4A before remuxing + val extractor = MediaExtractor() + try { + extractor.setDataSource(context, uri, null) + if (extractor.trackCount == 0) return StrippingResult(uri, false) + val primaryMime = extractor.getTrackFormat(0).getString(MediaFormat.KEY_MIME) ?: "" + if (!primaryMime.contains("mp4a") && !primaryMime.contains("aac")) { + return StrippingResult(uri, false) + } + } finally { + extractor.release() + } + + val tempOutputFile = File.createTempFile("stripped_audio_", ".m4a", context.cacheDir) + + val succeeded = remuxTracks(uri, context, tempOutputFile) + + if (!succeeded) return StrippingResult(uri, false) + + Log.d("MetadataStripper", "Stripped metadata from audio") + StrippingResult(tempOutputFile.toUri(), true) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d("MetadataStripper", "Failed to strip audio metadata: ${e.message}") + StrippingResult(uri, false) + } + } + + fun stripMp3Metadata( + uri: Uri, + context: Context, + ): StrippingResult { + var tempInputFile: File? = null + return try { + tempInputFile = File.createTempFile("mp3_input_", ".mp3", context.cacheDir) + context.contentResolver.openInputStream(uri)?.use { input -> + tempInputFile.outputStream().use { output -> + input.copyTo(output) + } + } ?: run { + if (!tempInputFile.delete()) { + Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + } + return StrippingResult(uri, false) + } + + val fileSize = tempInputFile.length() + var startOffset = 0L + var endOffset = fileSize + + // Read first 10 bytes to check for ID3v2 header + val header = ByteArray(10) + tempInputFile.inputStream().use { it.read(header) } + + if (fileSize >= 10 && + header[0] == 'I'.code.toByte() && + header[1] == 'D'.code.toByte() && + header[2] == '3'.code.toByte() + ) { + val size = + (header[6].toInt() and 0x7F shl 21) or + (header[7].toInt() and 0x7F shl 14) or + (header[8].toInt() and 0x7F shl 7) or + (header[9].toInt() and 0x7F) + startOffset = 10L + size + } + + // Read last 128 bytes to check for ID3v1 tag + if (endOffset - startOffset >= 128) { + val tail = ByteArray(128) + java.io.RandomAccessFile(tempInputFile, "r").use { raf -> + raf.seek(endOffset - 128) + raf.readFully(tail) + } + if (tail[0] == 'T'.code.toByte() && + tail[1] == 'A'.code.toByte() && + tail[2] == 'G'.code.toByte() + ) { + endOffset -= 128 + } + } + + if (startOffset == 0L && endOffset == fileSize) { + if (!tempInputFile.delete()) { + Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + } + tempInputFile = null + return StrippingResult(uri, true) // no tags found, already clean + } + + val tempOutputFile = File.createTempFile("stripped_mp3_", ".mp3", context.cacheDir) + java.io.RandomAccessFile(tempInputFile, "r").use { raf -> + raf.seek(startOffset) + tempOutputFile.outputStream().use { output -> + val buffer = ByteArray(8192) + var remaining = endOffset - startOffset + while (remaining > 0) { + val toRead = minOf(buffer.size.toLong(), remaining).toInt() + val read = raf.read(buffer, 0, toRead) + if (read <= 0) break + output.write(buffer, 0, read) + remaining -= read + } + } + } + if (!tempInputFile.delete()) { + Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + } + tempInputFile = null + + Log.d("MetadataStripper", "Stripped ID3 tags from MP3") + StrippingResult(tempOutputFile.toUri(), true) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (tempInputFile?.delete() == false) { + Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + } + Log.d("MetadataStripper", "Failed to strip MP3 metadata: ${e.message}") + StrippingResult(uri, false) + } + } + + fun strip( + uri: Uri, + mimeType: String?, + context: Context, + ): StrippingResult = + when { + mimeType?.startsWith("image/", ignoreCase = true) == true -> stripImageMetadata(uri, context) + mimeType?.startsWith("video/", ignoreCase = true) == true -> stripVideoMetadata(uri, context) + mimeType?.equals("audio/mpeg", ignoreCase = true) == true -> stripMp3Metadata(uri, context) + mimeType?.startsWith("audio/", ignoreCase = true) == true -> stripAudioMetadata(uri, context) + else -> StrippingResult(uri, false) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 977b868527..18467a353c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -59,6 +59,8 @@ class MultiOrchestrator( account: Account, context: Context, useH265: Boolean = false, + stripMetadata: Boolean = true, + onStrippingFailed: suspend () -> Boolean = { true }, ): Result { coroutineScope { val jobs = @@ -74,6 +76,8 @@ class MultiOrchestrator( account, context, useH265, + stripMetadata, + onStrippingFailed, ) } } @@ -93,6 +97,8 @@ class MultiOrchestrator( account: Account, context: Context, useH265: Boolean = false, + stripMetadata: Boolean = true, + onStrippingFailed: suspend () -> Boolean = { true }, ): Result { coroutineScope { val jobs = @@ -109,6 +115,8 @@ class MultiOrchestrator( account, context, useH265, + stripMetadata, + onStrippingFailed, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/SuspendableConfirmation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/SuspendableConfirmation.kt new file mode 100644 index 0000000000..ca94a7728e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/SuspendableConfirmation.kt @@ -0,0 +1,60 @@ +/* + * 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.uploads + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume + +data class ConfirmationCallbacks( + val onConfirm: () -> Unit, + val onCancel: () -> Unit, +) + +@Stable +class SuspendableConfirmation { + var state by mutableStateOf(null) + private set + + private val mutex = Mutex() + + suspend fun awaitConfirmation(): Boolean = + mutex.withLock { + suspendCancellableCoroutine { continuation -> + state = + ConfirmationCallbacks( + onConfirm = { + state = null + continuation.resume(true) + }, + onCancel = { + state = null + continuation.resume(false) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index cd3c88e4a5..ee5ef4e728 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -292,6 +292,38 @@ class UploadOrchestrator { MediaCompressorResult(uri, mimeType, null) } + private suspend fun stripAfterCompression( + originalUri: Uri, + compressed: MediaCompressorResult, + mimeType: String?, + compressionQuality: CompressorQuality, + stripMetadata: Boolean, + onStrippingFailed: suspend () -> Boolean, + context: Context, + ): Uri? { + if (!stripMetadata) return compressed.uri + + val effectiveMimeType = compressed.contentType ?: mimeType + val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true + val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED + val compressionApplied = compressionRequested && compressed.uri != originalUri + + val strippingResult = + if (isVideo && compressionApplied) { + // Compression was requested and actually applied to a video; + // assume it stripped metadata successfully. + StrippingResult(compressed.uri, true) + } else { + MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext) + } + + if (!strippingResult.stripped) { + if (!onStrippingFailed()) return null + } + + return strippingResult.uri + } + suspend fun upload( uri: Uri, mimeType: String?, @@ -302,13 +334,19 @@ class UploadOrchestrator { account: Account, context: Context, useH265: Boolean = false, + stripMetadata: Boolean = true, + onStrippingFailed: suspend () -> Boolean = { true }, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265) + val finalUri = + stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) + ?: return error(R.string.upload_cancelled) + return when (server.type) { - ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context) - ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) - ServerType.Blossom -> uploadBlossom(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) + ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) } } @@ -323,9 +361,16 @@ class UploadOrchestrator { account: Account, context: Context, useH265: Boolean = false, + stripMetadata: Boolean = true, + onStrippingFailed: suspend () -> Boolean = { true }, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265) - val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt) + + val finalUri = + stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) + ?: return error(R.string.upload_cancelled) + + val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt) return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index d27935f49a..29ba7a579f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -125,6 +125,8 @@ fun EditPostView( postViewModel.load(edit, versionLookingAt) } + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + Dialog( onDismissRequest = { onClose() }, properties = @@ -264,8 +266,9 @@ fun EditPostView( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> - postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context) + isUploading = postViewModel.mediaUploadTracker.isUploading, + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, @@ -372,6 +375,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -379,7 +383,8 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 1c9d57d603..53cd5a131a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState @@ -79,7 +81,9 @@ open class EditPostViewModel : ViewModel() { var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) - var isUploadingImage by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -87,6 +91,9 @@ open class EditPostViewModel : ViewModel() { // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + // Codec selection: false = H264, true = H265 var useH265Codec by mutableStateOf(false) @@ -158,8 +165,9 @@ open class EditPostViewModel : ViewModel() { server: ServerName, onError: (String, String) -> Unit, context: Context, + stripMetadata: Boolean = true, ) = try { - uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context) + uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata) } catch (e: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -175,12 +183,13 @@ open class EditPostViewModel : ViewModel() { server: ServerName, onError: (String, String) -> Unit, context: Context, + stripMetadata: Boolean = true, ) { viewModelScope.launch(Dispatchers.IO) { val myAccount = account val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -191,6 +200,8 @@ open class EditPostViewModel : ViewModel() { myAccount, context, useH265Codec, + stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { @@ -243,7 +254,7 @@ open class EditPostViewModel : ViewModel() { onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -255,7 +266,7 @@ open class EditPostViewModel : ViewModel() { multiOrchestrator = null urlPreview = null - isUploadingImage = false + mediaUploadTracker.finishUpload() wantsInvoice = false @@ -296,7 +307,7 @@ open class EditPostViewModel : ViewModel() { } } - fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null + fun canPost() = message.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null fun selectImage(uris: ImmutableList) { multiOrchestrator = MultiOrchestrator(uris) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 97ac2a6a53..563cb0ce69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -59,12 +60,18 @@ open class NewMediaModel : ViewModel() { var multiOrchestrator by mutableStateOf(null) var onceUploaded: () -> Unit = {} + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED var mediaQualitySlider by mutableIntStateOf(1) // Codec selection: false = H264, true = H265 var useH265Codec by mutableStateOf(false) + // Strip location and sensitive metadata from files before upload + var stripMetadata by mutableStateOf(true) + open fun load( account: Account, uris: ImmutableList, @@ -73,6 +80,7 @@ open class NewMediaModel : ViewModel() { this.account = account this.multiOrchestrator = MultiOrchestrator(uris) this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload } fun isImage( @@ -115,6 +123,8 @@ open class NewMediaModel : ViewModel() { myAccount, context, useH265Codec, + stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 6bdb0a528e..63a21a0212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -89,6 +89,8 @@ fun NewMediaView( postViewModel.load(account, uris) } + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + Dialog( onDismissRequest = { onClose() }, properties = @@ -112,6 +114,7 @@ fun NewMediaView( postViewModel.selectedServer?.let { account.settings.changeDefaultFileServer(it) } + account.settings.changeStripLocationOnUpload(postViewModel.stripMetadata) }, ) }, @@ -269,4 +272,15 @@ fun ImageVideoPost( onCheckedChange = { postViewModel.useH265Codec = it }, ) } + + SettingSwitchItem( + title = R.string.strip_metadata_label, + description = R.string.strip_metadata_description, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + checked = postViewModel.stripMetadata, + onCheckedChange = { postViewModel.stripMetadata = it }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index ee6550f6c4..c5ea8056fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType @@ -210,7 +211,28 @@ class NewUserMetadataViewModel : ViewModel() { ): String? { isUploadingImageForPicture = true - val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + val strippingResult = + if (account.settings.stripLocationOnUpload) { + MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) + } else { + null + } + + val sourceUri = + if (account.settings.stripLocationOnUpload && + strippingResult != null && + !strippingResult.stripped + ) { + onError( + stringRes(context, R.string.metadata_strip_failed_title), + stringRes(context, R.string.metadata_strip_failed_upload_cancelled), + ) + return null + } else { + strippingResult?.uri ?: galleryUri.uri + } + + val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) return try { val result = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/StrippingFailureDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/StrippingFailureDialog.kt new file mode 100644 index 0000000000..7500201cdd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/StrippingFailureDialog.kt @@ -0,0 +1,55 @@ +/* + * 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 androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.ConfirmationCallbacks +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun StrippingFailureDialog(confirmation: SuspendableConfirmation) { + val dialogState = confirmation.state ?: return + StrippingFailureDialog(dialogState) +} + +@Composable +fun StrippingFailureDialog(dialogState: ConfirmationCallbacks) { + AlertDialog( + onDismissRequest = { dialogState.onCancel() }, + title = { Text(stringRes(R.string.metadata_strip_failed_title)) }, + text = { Text(stringRes(R.string.metadata_strip_failed_body)) }, + confirmButton = { + Button(onClick = { dialogState.onConfirm() }) { + Text(stringRes(R.string.metadata_strip_failed_upload)) + } + }, + dismissButton = { + Button(onClick = { dialogState.onCancel() }) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 84142984be..23a5e561dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -53,7 +53,7 @@ class BlossomServersViewModel : ViewModel() { fun refresh() { isModified = false _fileServers.update { - val obtainedFileServers = obtainFileServers() ?: emptyList() + val obtainedFileServers = obtainFileServers() obtainedFileServers.mapNotNull { serverUrl -> try { ServerName( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/MediaUploadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/MediaUploadTracker.kt new file mode 100644 index 0000000000..6ff5e72ec8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/MediaUploadTracker.kt @@ -0,0 +1,47 @@ +/* + * 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.uploads + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class MediaUploadTracker { + var isUploadingImage by mutableStateOf(false) + private set + var isUploadingFile by mutableStateOf(false) + private set + + val isUploading: Boolean get() = isUploadingImage || isUploadingFile + + fun startUpload(hasNonMedia: Boolean) { + if (hasNonMedia) { + isUploadingFile = true + } else { + isUploadingImage = true + } + } + + fun finishUpload() { + isUploadingImage = false + isUploadingFile = false + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt index 354853720c..7ed03deb21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt @@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean @Composable fun SelectFromFiles( isUploading: Boolean, + enabled: Boolean = true, tint: Color, modifier: Modifier, onFilesChosen: (ImmutableList) -> Unit, @@ -64,19 +65,20 @@ fun SelectFromFiles( ) } - FileSelectButton(isUploading, tint, modifier) { showFileSelect = true } + FileSelectButton(isUploading, enabled, tint, modifier) { showFileSelect = true } } @Composable private fun FileSelectButton( isUploading: Boolean, + enabled: Boolean, tint: Color, modifier: Modifier, onClick: () -> Unit, ) { IconButton( modifier = modifier, - enabled = !isUploading, + enabled = enabled && !isUploading, onClick = { onClick() }, ) { if (!isUploading) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index 0e38451c57..fcae06fc33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -69,6 +69,7 @@ class SelectedMedia( @Composable fun SelectFromGallery( isUploading: Boolean, + enabled: Boolean = true, tint: Color, modifier: Modifier, onImageChosen: (ImmutableList) -> Unit, @@ -85,7 +86,7 @@ fun SelectFromGallery( ) } - GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } + GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true } } @Composable @@ -107,19 +108,20 @@ fun SelectSingleFromGallery( ) } - GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } + GallerySelectButton(isUploading, true, tint, modifier) { showGallerySelect = true } } @Composable private fun GallerySelectButton( isUploading: Boolean, + enabled: Boolean, tint: Color, modifier: Modifier, onClick: () -> Unit, ) { IconButton( modifier = modifier, - enabled = !isUploading, + enabled = enabled && !isUploading, onClick = { onClick() }, ) { if (!isUploading) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/M3ActionDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/M3ActionDialog.kt new file mode 100644 index 0000000000..38d58b2da8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/M3ActionDialog.kt @@ -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.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier + +@Composable +fun M3ActionDialog( + title: String, + onDismiss: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = + Modifier + .padding(vertical = 20.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) + content() + } + } + } +} + +@Composable +fun M3ActionSection(content: @Composable ColumnScope.() -> Unit) { + Surface( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column { + content() + } + } +} + +@Composable +fun M3ActionRow( + icon: ImageVector, + text: String, + isDestructive: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val tint = + if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val textColor = + if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + val alpha = if (enabled) 1f else 0.38f + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .alpha(alpha) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Size20Modifier, + tint = tint, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + fontSize = Font14SP, + color = textColor, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 32aeaa6792..90bad3c7f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -119,11 +119,12 @@ object ShareHelper { bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header) // MP4/MOV alternative: moov, mdat, or free at offset 4 - bytesRead >= 8 && ( - matchesMagicNumbers(header, 4, MOV_MOOV) || - matchesMagicNumbers(header, 4, MOV_MDAT) || - matchesMagicNumbers(header, 4, MOV_FREE) - ) -> "mp4" + bytesRead >= 8 && + ( + matchesMagicNumbers(header, 4, MOV_MOOV) || + matchesMagicNumbers(header, 4, MOV_MDAT) || + matchesMagicNumbers(header, 4, MOV_FREE) + ) -> "mp4" else -> defaultExtension } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index f8522fe3fc..8d704df193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.components -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -61,7 +59,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import kotlinx.collections.immutable.ImmutableList @@ -159,13 +156,11 @@ private fun BaseTextSpinner( ) } - if (optionsShowing) { - options.isNotEmpty().also { - SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { - currentText = options[it].title - optionsShowing = false - onSelect(it) - } + if (optionsShowing && options.isNotEmpty()) { + SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { + currentText = options[it].title + optionsShowing = false + onSelect(it) } } } @@ -211,14 +206,14 @@ fun SpinnerSelectionDialog( ) { Dialog(onDismissRequest = onDismiss) { Surface( - border = BorderStroke(0.25.dp, Color.LightGray), - shape = RoundedCornerShape(5.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { LazyColumn { title?.let { item { Row( - modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp), + modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalArrangement = Arrangement.Center, ) { Text( @@ -227,7 +222,6 @@ fun SpinnerSelectionDialog( fontWeight = FontWeight.Bold, ) } - HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) } } itemsIndexed(options) { index, item -> @@ -237,7 +231,7 @@ fun SpinnerSelectionDialog( Modifier .fillMaxWidth() .clickable { onSelect(index) } - .padding(16.dp, 16.dp) + .padding(horizontal = 16.dp, vertical = 12.dp) .semantics { role = Role.Button contentDescription = optionsOfLabel @@ -245,9 +239,6 @@ fun SpinnerSelectionDialog( ) { Column { onRenderItem(item) } } - if (index < options.lastIndex) { - HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) - } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 79b3ce8ede..bbd56206c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -24,12 +24,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color @@ -47,27 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier -@Composable -private fun CopyToClipboard( - popupExpanded: MutableState, - content: String, - onDismiss: () -> Unit, -) { - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = onDismiss, - ) { - val clipboardManager = LocalClipboardManager.current - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_url_to_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(content)) - onDismiss() - }, - ) - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable fun UrlPreviewCard( @@ -81,11 +59,20 @@ fun UrlPreviewCard( } if (popupExpanded.value) { - CopyToClipboard( - popupExpanded = popupExpanded, - content = url, + val clipboardManager = LocalClipboardManager.current + M3ActionDialog( + title = stringRes(R.string.link_actions_dialog_title), + onDismiss = { popupExpanded.value = false }, ) { - popupExpanded.value = false + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.ContentCopy, + text = stringRes(R.string.copy_url_to_clipboard), + ) { + clipboardManager.setText(AnnotatedString(url)) + popupExpanded.value = false + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt new file mode 100644 index 0000000000..b72c343154 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt @@ -0,0 +1,108 @@ +/* + * 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.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged + +private const val PAGER_ZONE_FRACTION = 0.5f + +fun Modifier.zonedDrawerSwipe( + pagerState: PagerState, + openDrawer: () -> Unit, +): Modifier = + composed { + var widthPx by remember { mutableFloatStateOf(1f) } + var gestureStartX by remember { mutableFloatStateOf(0f) } + var gestureStartPage by remember { mutableIntStateOf(0) } + var drawerOpened by remember { mutableStateOf(false) } + + val connection = + remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + if (drawerOpened) return Offset(available.x, 0f) + + // Non-first pages in the drawer zone: intercept before the + // pager consumes the delta to page backwards. + if (available.x > 0f) { + val wasOnFirstPage = gestureStartPage == 0 + val isInPagerZone = gestureStartX < widthPx * PAGER_ZONE_FRACTION + + if (!wasOnFirstPage && !isInPagerZone) { + drawerOpened = true + openDrawer() + return Offset(available.x, 0f) + } + } + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + if (drawerOpened) return Offset(available.x, 0f) + + // First page: open drawer only with unconsumed right-swipe + // so child LazyRows can scroll first. + if (available.x > 0f && gestureStartPage == 0) { + drawerOpened = true + openDrawer() + return Offset(available.x, 0f) + } + return Offset.Zero + } + } + } + + this + .onSizeChanged { widthPx = it.width.toFloat() } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + gestureStartX = down.position.x + gestureStartPage = pagerState.currentPage + drawerOpened = false + } + }.nestedScroll(connection) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e9f22e2abc..357f640ef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -32,15 +32,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Report -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -771,119 +772,101 @@ fun ShareMediaAction( // Track if video is downloading - hoisted here to block menu dismiss during download val isDownloadingVideo = remember { mutableStateOf(false) } - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() }, - ) { - val clipboardManager = LocalClipboardManager.current + if (popupExpanded.value) { + M3ActionDialog( + title = stringRes(R.string.media_actions_dialog_title), + onDismiss = { if (!isDownloadingVideo.value) onDismiss() }, + ) { + val clipboardManager = LocalClipboardManager.current - if (videoUri != null && !videoUri.startsWith("file")) { - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_url_to_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(videoUri)) - onDismiss() - }, - ) - } - - postNostrUri?.let { - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_the_note_id_to_the_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(it)) - onDismiss() - }, - ) - } - - postNostrUri?.let { - DropdownMenuItem( - text = { Text(stringRes(R.string.add_media_to_gallery)) }, - onClick = { - if (videoUri != null) { - val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent - if (n19 != null) { - accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first? - accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery) + // Copy & Gallery section + if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) { + M3ActionSection { + if (videoUri != null && !videoUri.startsWith("file")) { + M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) { + clipboardManager.setText(AnnotatedString(videoUri)) + onDismiss() } } - - onDismiss() - }, - ) - } - - content?.let { - val context = LocalContext.current - - when (content) { - is MediaUrlImage -> { - videoUri?.let { - if (videoUri.isNotEmpty()) { - DropdownMenuItem( - text = { Text(stringRes(R.string.share_image)) }, - onClick = { - scope.launch { shareImageFile(context, videoUri, mimeType) } - onDismiss() - }, - ) + postNostrUri?.let { + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) { + clipboardManager.setText(AnnotatedString(it)) + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.Collections, text = stringRes(R.string.add_media_to_gallery)) { + if (videoUri != null) { + val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent + if (n19 != null) { + accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) + accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery) + } + } + onDismiss() } } } + } - is MediaUrlVideo -> { - videoUri?.let { - if (videoUri.isNotEmpty()) { - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringRes(R.string.share_video)) - if (isDownloadingVideo.value) { - Spacer(modifier = Modifier.width(8.dp)) - LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp) + // Share section + content?.let { + val context = LocalContext.current + + M3ActionSection { + when (content) { + is MediaUrlImage -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_image)) { + scope.launch { shareImageFile(context, videoUri, mimeType) } + onDismiss() + } + } + } + } + + is MediaUrlVideo -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + M3ActionRow( + icon = Icons.Outlined.Share, + text = stringRes(R.string.share_video), + enabled = !isDownloadingVideo.value, + ) { + isDownloadingVideo.value = true + scope.launch { + shareVideoFile( + context = context, + videoUrl = videoUri, + mimeType = mimeType, + okHttpClient = { url -> + accountViewModel.httpClientBuilder.okHttpClientForVideo(url) + }, + onComplete = { + isDownloadingVideo.value = false + onDismiss() + }, + onError = { + isDownloadingVideo.value = false + }, + ) } } - }, - enabled = !isDownloadingVideo.value, - onClick = { - isDownloadingVideo.value = true - scope.launch { - shareVideoFile( - context = context, - videoUrl = videoUri, - mimeType = mimeType, - okHttpClient = { url -> - accountViewModel.httpClientBuilder.okHttpClientForVideo(url) - }, - onComplete = { - isDownloadingVideo.value = false - onDismiss() - }, - onError = { - isDownloadingVideo.value = false - }, - ) - } - }, - ) + } + } } + + is MediaLocalVideo -> { + content.localFile?.let { localFile -> + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_video)) { + scope.launch { shareLocalVideoFile(context, localFile, mimeType) } + onDismiss() + } + } + } + + else -> { /* No share option for other types */ } } } - - is MediaLocalVideo -> { - content.localFile?.let { localFile -> - DropdownMenuItem( - text = { Text(stringRes(R.string.share_video)) }, - onClick = { - scope.launch { shareLocalVideoFile(context, localFile, mimeType) } - onDismiss() - }, - ) - } - } - - else -> { /* No share option for other types */ } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fb210a2ab4..057a8c6157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -37,8 +37,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri import androidx.core.util.Consumer +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages @@ -109,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen @@ -121,6 +124,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -139,7 +146,17 @@ fun AppNavigation( ) { val nav = rememberNav() - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val isTabPagerRoute = + navBackStackEntry?.destination?.let { dest -> + dest.hasRoute() || dest.hasRoute() + } ?: false + val drawerGesturesEnabled = + !isTabPagerRoute || + nav.drawerState.isOpen || + nav.drawerState.targetValue != nav.drawerState.currentValue + + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) { NavHost( navController = nav.controller, startDestination = Route.Home, @@ -153,6 +170,11 @@ fun AppNavigation( composable { NotificationScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } + composableFromEnd { WalletScreen(accountViewModel, nav) } + composableFromEnd { WalletSendScreen(accountViewModel, nav) } + composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } + composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } @@ -197,6 +219,7 @@ fun AppNavigation( composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + composableFromEnd { EventSyncScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } @@ -415,7 +438,9 @@ private fun NavigateIfIntentRequested( actionableNextPage?.let { nextRoute -> val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() if (npub != null && accountSessionManager.currentAccountNPub() != npub) { - accountSessionManager.checkAndSwitchUserSync(npub, nextRoute) + accountSessionManager.checkAndSwitchUserSync(npub) { account -> + uriToRoute(intentNextPage, account) + } } else { val currentRoute = getRouteWithArguments(nextRoute::class, nav.controller) if (!isSameRoute(currentRoute, nextRoute)) { @@ -471,7 +496,9 @@ private fun NavigateIfIntentRequested( scope.launch { val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() if (npub != null && accountSessionManager.currentAccountNPub() != npub) { - accountSessionManager.checkAndSwitchUserSync(npub, newPage) + accountSessionManager.checkAndSwitchUserSync(npub) { newAccount -> + uriToRoute(uri, newAccount) + } } else { val currentRoute = getRouteWithArguments(newPage::class, nav.controller) if (!isSameRoute(currentRoute, newPage)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 8ca73fb972..17d3d12e63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -46,10 +46,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Sync import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -87,6 +89,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote @@ -464,12 +467,30 @@ fun ListContent( ) NavigationRow( - title = R.string.route_chess, - icon = R.drawable.ic_chess, - iconReference = 1, + title = R.string.wallet, + icon = Icons.Outlined.AccountBalanceWallet, tint = MaterialTheme.colorScheme.onBackground, nav = nav, - route = Route.Chess, + route = Route.Wallet, + ) + + if (isDebug) { + NavigationRow( + title = R.string.route_chess, + icon = R.drawable.ic_chess, + iconReference = 1, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Chess, + ) + } + + NavigationRow( + title = R.string.event_sync_title, + icon = Icons.Outlined.Sync, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.EventSync, ) NavigationRow( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 16d2c3c3d2..765748f7b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -43,6 +43,14 @@ sealed class Route { @Serializable object Chess : Route() + @Serializable object Wallet : Route() + + @Serializable object WalletSend : Route() + + @Serializable object WalletReceive : Route() + + @Serializable object WalletTransactions : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() @@ -126,6 +134,8 @@ sealed class Route { @Serializable object EditRelays : Route() + @Serializable object EventSync : Route() + @Serializable object EditMediaServers : Route() @Serializable object UpdateReactionType : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 62f7217670..be00e48457 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -21,20 +21,36 @@ package com.vitorpamplona.amethyst.ui.navigation.topbars import android.Manifest +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.automirrored.outlined.VolumeOff import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.SensorDoor import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -43,14 +59,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted @@ -62,7 +81,6 @@ import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation -import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition @@ -74,6 +92,8 @@ import com.vitorpamplona.amethyst.ui.screen.RelayName import com.vitorpamplona.amethyst.ui.screen.ResourceName import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -120,6 +140,8 @@ fun FeedFilterSpinner( stringRes(R.string.feed_filter_select_an_option, selectAnOption) } + val openDropdownLabel = stringRes(R.string.open_dropdown_menu) + Box( modifier = modifier, contentAlignment = Alignment.Center, @@ -137,7 +159,7 @@ fun FeedFilterSpinner( Text( text = stringRes(R.string.lack_location_permissions), - fontSize = 12.sp, + fontSize = Font12SP, lineHeight = 12.sp, ) } else { @@ -152,7 +174,7 @@ fun FeedFilterSpinner( Row { Text( text = "(${myLocation.geoHash})", - fontSize = 12.sp, + fontSize = Font12SP, lineHeight = 12.sp, ) Spacer(modifier = StdHorzSpacer) @@ -162,7 +184,7 @@ fun FeedFilterSpinner( ) { cityName -> Text( text = "($cityName)", - fontSize = 12.sp, + fontSize = Font12SP, lineHeight = 12.sp, ) } @@ -171,7 +193,7 @@ fun FeedFilterSpinner( LocationState.LocationResult.LackPermission -> { Text( text = stringRes(R.string.lack_location_permissions), - fontSize = 12.sp, + fontSize = Font12SP, lineHeight = 12.sp, ) } @@ -179,7 +201,7 @@ fun FeedFilterSpinner( LocationState.LocationResult.Loading -> { Text( text = stringRes(R.string.loading_location), - fontSize = 12.sp, + fontSize = Font12SP, lineHeight = 12.sp, ) } @@ -207,7 +229,7 @@ fun FeedFilterSpinner( }.semantics { role = Role.DropdownList stateDescription = accessibilityDescription - onClick(label = "Open feed filter menu") { + onClick(label = openDropdownLabel) { optionsShowing = true return@onClick true } @@ -215,20 +237,18 @@ fun FeedFilterSpinner( ) } - if (optionsShowing) { - options.isNotEmpty().also { - SpinnerSelectionDialog( - title = explainer, - options = options, - onDismiss = { optionsShowing = false }, - onSelect = { - selected = options[it] - optionsShowing = false - onSelect(it) - }, - ) { - RenderOption(it.name, accountViewModel) - } + if (optionsShowing && options.isNotEmpty()) { + GroupedFeedFilterDialog( + title = explainer, + options = options, + onDismiss = { optionsShowing = false }, + onSelect = { + selected = options[it] + optionsShowing = false + onSelect(it) + }, + ) { + RenderOption(it.name, accountViewModel) } } } @@ -241,84 +261,258 @@ fun RenderOption( when (option) { is GeoHashName -> { LoadCityName(option.geoHashTag) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface) - } + Text(text = "/g/$it", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } } is HashtagName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) - } + Text(text = option.name(), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is ResourceName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringRes(id = option.resourceId), - color = MaterialTheme.colorScheme.onSurface, - ) - } + Text( + text = stringRes(id = option.resourceId), + fontSize = Font14SP, + color = MaterialTheme.colorScheme.onSurface, + ) } is PeopleListName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - val noteState by observeNote(option.note, accountViewModel) + val noteState by observeNote(option.note, accountViewModel) - val noteEvent = noteState.note.event - val name = - when (noteEvent) { - is PeopleListEvent -> { - noteEvent.titleOrName() ?: option.note.dTag() - } - - is FollowListEvent -> { - noteEvent.title() ?: option.note.dTag() - } - - else -> { - option.note.dTag() - } + val noteEvent = noteState.note.event + val name = + when (noteEvent) { + is PeopleListEvent -> { + noteEvent.titleOrName() ?: option.note.dTag() } - Text(text = name, color = MaterialTheme.colorScheme.onSurface) - } + is FollowListEvent -> { + noteEvent.title() ?: option.note.dTag() + } + + else -> { + option.note.dTag() + } + } + + Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is CommunityName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - val it by observeNote(option.note, accountViewModel) + val it by observeNote(option.note, accountViewModel) - Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface) - } + Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is RelayName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), + Text( + text = option.name(), + fontSize = Font14SP, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Immutable +private data class IndexedFeedDefinition( + val originalIndex: Int, + val item: FeedDefinition, +) + +private enum class FeedGroup( + @param:androidx.annotation.StringRes val labelRes: Int, +) { + FEEDS(R.string.feed_group_feeds), + HASHTAGS(R.string.feed_group_hashtags), + COMMUNITIES(R.string.feed_group_communities), + LISTS(R.string.feed_group_lists), +} + +private fun groupFeedDefinitions(options: ImmutableList): Map> { + val indexed = options.mapIndexed { index, item -> IndexedFeedDefinition(index, item) } + return indexed.groupBy { entry -> + when (entry.item.name) { + is HashtagName -> FeedGroup.HASHTAGS + is CommunityName -> FeedGroup.COMMUNITIES + is PeopleListName -> FeedGroup.LISTS + else -> FeedGroup.FEEDS + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GroupedFeedFilterDialog( + title: String, + options: ImmutableList, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, + onRenderItem: @Composable (FeedDefinition) -> Unit, +) { + val grouped = remember(options) { groupFeedDefinitions(options) } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + LazyColumn( + modifier = Modifier.padding(vertical = 20.dp), ) { - Text( - text = option.name(), - color = MaterialTheme.colorScheme.onSurface, - ) + item { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp), + ) + } + + FeedGroup.entries.forEach { group -> + val items = grouped[group] + if (!items.isNullOrEmpty()) { + item { + GroupSection( + label = stringRes(group.labelRes), + items = items, + isChipLayout = group == FeedGroup.HASHTAGS, + onSelect = onSelect, + onRenderItem = onRenderItem, + ) + } + } + } } } } } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GroupSection( + label: String, + items: List, + isChipLayout: Boolean, + onSelect: (Int) -> Unit, + onRenderItem: @Composable (FeedDefinition) -> Unit, +) { + Surface( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column { + Text( + text = label.uppercase(), + fontSize = Font12SP, + letterSpacing = 0.8.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 6.dp), + ) + + if (isChipLayout) { + FlowRow( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items.forEach { entry -> + Surface( + modifier = Modifier.clickable { onSelect(entry.originalIndex) }, + shape = RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + color = Color.Transparent, + ) { + Text( + text = entry.item.name.name(), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + } else { + items.forEach { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onSelect(entry.originalIndex) } + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + FeedIcon( + item = entry.item, + modifier = Size20Modifier, + ) + Spacer(modifier = Modifier.padding(start = 12.dp)) + Column(modifier = Modifier.weight(1f)) { onRenderItem(entry.item) } + } + } + } + } + } +} + +@Composable +private fun FeedIcon( + item: FeedDefinition, + modifier: Modifier = Modifier, +) { + val icon = + when (item.code) { + is TopFilter.Global -> { + Icons.Outlined.Public + } + + is TopFilter.AroundMe -> { + Icons.Outlined.LocationOn + } + + is TopFilter.AllFollows -> { + Icons.Outlined.Groups + } + + is TopFilter.AllUserFollows -> { + Icons.Outlined.Person + } + + is TopFilter.DefaultFollows -> { + Icons.Outlined.Groups + } + + is TopFilter.MuteList -> { + Icons.AutoMirrored.Outlined.VolumeOff + } + + is TopFilter.Chess -> { + Icons.Outlined.Groups + } + + is TopFilter.PeopleList -> { + Icons.AutoMirrored.Outlined.ViewList + } + + else -> { + when (item.name) { + is GeoHashName -> Icons.Outlined.LocationOn + is RelayName -> Icons.Outlined.SensorDoor + is CommunityName -> Icons.Outlined.Groups + is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList + else -> Icons.Outlined.Person + } + } + } + Icon( + imageVector = icon, + contentDescription = null, + modifier = modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index ac0e28646c..321187de84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -62,7 +62,7 @@ fun BadgeCompose( nav: INav, ) { val noteState by observeNote(likeSetCard.note, accountViewModel) - val note = noteState?.note + val note = noteState.note val context = LocalContext.current.applicationContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 5a2163075d..92763735bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -102,6 +102,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency +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 @@ -174,6 +178,10 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -184,7 +192,7 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -767,6 +775,22 @@ private fun RenderNoteRow( RenderAppDefinition(baseNote, accountViewModel, nav) } + is AttestationEvent -> { + RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + + is AttestationRequestEvent -> { + RenderAttestationRequest(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + + is AttestorRecommendationEvent -> { + RenderAttestorRecommendation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + + is AttestorProficiencyEvent -> { + RenderAttestorProficiency(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + is AudioTrackEvent -> { RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) } @@ -1039,7 +1063,7 @@ private fun RenderNoteRow( ) } - is PollNoteEvent -> { + is ZapPollEvent -> { RenderZapPoll( baseNote, makeItShort, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 107bd36aa5..d6452fbbcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -32,10 +32,16 @@ import kotlin.math.round private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" private const val MONTH_DATE_FORMAT = "MMM dd" +private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy" +private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd" + var locale: Locale = Locale.getDefault() var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) +var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) +var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) + fun timeAgo( time: Long?, context: Context, @@ -116,6 +122,46 @@ fun timeAgoNoDot( } } +fun timeAgoNoDotNoDay( + time: Long?, + context: Context, +): String { + if (time == null) return " " + if (time == 0L) return " ${stringRes(context, R.string.never)}" + + val timeDifference = TimeUtils.now() - time + + return if (timeDifference > TimeUtils.ONE_YEAR) { + // Dec 12, 2022 + + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) + monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) + } + + yearNoDayFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_MONTH) { + // Dec 12 + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) + monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) + } + + monthNoDayFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_DAY) { + // 2 days + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d) + } else if (timeDifference > TimeUtils.ONE_HOUR) { + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h) + } else if (timeDifference > TimeUtils.ONE_MINUTE) { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m) + } else { + stringRes(context, R.string.now) + } +} + fun timeAheadNoDot( time: Long?, context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 2e1de3e286..14c5ed8990 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -30,10 +30,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt index 2856360eec..ab8aa20159 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt @@ -49,10 +49,10 @@ fun showAmountInteger(amount: BigDecimal?): String { if (amount.abs() < BigDecimal(0.01)) return "" return when { - amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.get().format(amount) + amount >= OneGiga -> dfG.get()?.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) ?: "" + amount >= OneMega -> dfM.get()?.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) ?: "" + amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: "" + else -> dfN.get()?.format(amount) ?: "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt index a9c5f7a818..4874e5619e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -32,7 +34,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -58,8 +59,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight @@ -93,13 +97,15 @@ import com.vitorpamplona.amethyst.ui.theme.BigPadding 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.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size14Modifier +import com.vitorpamplona.amethyst.ui.theme.SmallishBorder import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.ripple24dp -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList @@ -115,7 +121,7 @@ import kotlin.uuid.Uuid @Composable fun ZapZapPollNotePreview() { val event = - PollNoteEvent( + ZapPollEvent( id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72", pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", createdAt = 1729186078, @@ -188,7 +194,7 @@ fun ZapZapPollNotePreview() { @Composable fun ZapZapPollNotePreview2() { val event = - PollNoteEvent( + ZapPollEvent( id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab", pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", createdAt = 1729191389, @@ -311,13 +317,6 @@ private fun OptionNote( modifier = Modifier.padding(vertical = 3.dp), ) { if (!pollViewModel.canZap.value) { - val color = - if (poolOption.consensusThreadhold.value) { - Color.Green.copy(alpha = 0.32f) - } else { - MaterialTheme.colorScheme.mediumImportanceLink - } - ZapVote( baseNote, poolOption, @@ -326,7 +325,7 @@ private fun OptionNote( RenderOptionAfterVote( baseNote, poolOption, - color, + poolOption.consensusThreadhold.value, canPreview, tags, backgroundColor, @@ -366,7 +365,7 @@ private fun OptionNote( private fun RenderOptionAfterVote( baseNote: Note, poolOption: PollOption, - color: Color, + isWinning: Boolean, canPreview: Boolean, tags: ImmutableListOfLists, backgroundColor: MutableState, @@ -376,14 +375,25 @@ private fun RenderOptionAfterVote( Box( Modifier .fillMaxWidth(0.75f) - .clip(shape = QuoteBorder) + .clip(SmallishBorder) .border( - 2.dp, - color, - QuoteBorder, + width = 1.dp, + color = + if (isWinning) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.grayText + }, + shape = SmallishBorder, + ).background( + if (isWinning) { + MaterialTheme.colorScheme.allGoodColor.copy(0.2f) + } else { + MaterialTheme.colorScheme.subtleBorder + }, ), ) { - DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize()) + DisplayProgress(poolOption, isWinning, modifier = Modifier.matchParentSize()) Row( verticalAlignment = Alignment.CenterVertically, @@ -428,22 +438,29 @@ private fun RenderOptionAfterVote( @Composable private fun DisplayProgress( poolOption: PollOption, - color: Color, + isWinning: Boolean, modifier: Modifier, ) { - val progress by poolOption.tally + // Animate the progress bar when a vote is cast + val animatedProgress by animateFloatAsState( + targetValue = poolOption.tally.value, + animationSpec = tween(durationMillis = 800), + ) - // The LinearProgressIndicator has some weird update issues and renders inaccurate percentages. - Box(modifier = modifier) { - Box( - modifier = - Modifier - .fillMaxWidth(progress) - .fillMaxHeight() - .background(color = color), - ) { - } - } + val progressBarColor = if (isWinning) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.primary + + Box( + modifier = + modifier + .alpha(0.32f) + .drawWithContent { + // Clip the drawing area to show only the progress amount + clipRect(right = size.width * animatedProgress) { + drawRect(progressBarColor) + } + drawContent() + }, + ) } @Composable @@ -475,11 +492,11 @@ private fun RenderOptionBeforeVote( Box( Modifier .fillMaxWidth(0.75f) - .clip(shape = QuoteBorder) + .clip(SmallishBorder) .border( - 2.dp, - MaterialTheme.colorScheme.primary, - QuoteBorder, + width = 1.dp, + color = MaterialTheme.colorScheme.placeholderText, + shape = SmallishBorder, ), ) { Column(BigPadding) { @@ -568,7 +585,19 @@ fun ZapVote( showErrorMessageDialog = StringToastMsg(title, message) }, onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, - onPayViaIntent = {}, + onPayViaIntent = { + if (it.size == 1) { + val payable = it.first() + payViaIntent(payable.invoice, context, { }) { error -> + zappingProgress = 0f + showErrorMessageDialog = StringToastMsg(stringRes(context, R.string.error_dialog_zap_error), error) + } + } else { + val uid = Uuid.random().toString() + accountViewModel.tempManualPaymentCache.put(uid, it) + nav.nav(Route.ManualZapSplitPayment(uid)) + } + }, ) } else { wantsToZap = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 9d4b31d4a3..58e3047797 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -53,7 +53,7 @@ class PollNoteViewModel : ViewModel() { private lateinit var account: Account private var pollNote: Note? = null - private var pollEvent: PollNoteEvent? = null + private var pollEvent: ZapPollEvent? = null private var pollOptions: Map? = null private var valueMaximum: Long? = null private var valueMinimum: Long? = null @@ -76,7 +76,7 @@ class PollNoteViewModel : ViewModel() { fun load(note: Note?) { if (pollNote != note) { pollNote = note - pollEvent = pollNote?.event as PollNoteEvent + pollEvent = pollNote?.event as ZapPollEvent pollOptions = pollEvent?.pollOptions() valueMaximum = pollEvent?.maxAmount() valueMinimum = pollEvent?.minAmount() @@ -118,13 +118,13 @@ class PollNoteViewModel : ViewModel() { it.zappedValue.value = zappedValue it.tally.value = tallyValue.toFloat() it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!! - it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false + it.zappedByLoggedIn.value = account.userProfile().let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } } } } fun checkIfCanZap(): Boolean { - val account = account ?: return false + val account = account val note = pollNote ?: return false return account.userProfile() != note.author && !wasZappedByLoggedInAccount } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 9e40f6e1d7..725af41bb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -78,7 +78,8 @@ import kotlinx.collections.immutable.toImmutableList fun ImageVideoDescription( uris: MultiOrchestrator, defaultServer: ServerName, - onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit, + isUploading: Boolean, + onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, accountViewModel: AccountViewModel, @@ -107,6 +108,8 @@ fun ImageVideoDescription( // Codec selection: false = H264, true = H265 var useH265Codec by remember { mutableStateOf(false) } + var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) } + Column( modifier = Modifier @@ -247,36 +250,52 @@ fun ImageVideoDescription( ) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - .padding(vertical = 8.dp), - ) { - Column( - modifier = Modifier.weight(1.0f), - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - Text( - text = stringRes(R.string.media_compression_quality_label), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringRes(R.string.media_compression_quality_explainer), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 5, - overflow = TextOverflow.Ellipsis, - ) - } + // Hide privacy toggle when video compression is selected (compression already strips metadata) + val isVideoWithCompression = + uris.first().media.isVideo() == true && mediaQualitySlider != 3 + + if (!isVideoWithCompression) { + SettingSwitchItem( + title = R.string.strip_metadata_label, + description = R.string.strip_metadata_description, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + checked = stripMetadata, + onCheckedChange = { stripMetadata = it }, + ) } val firstMedia = uris.first().media - if (firstMedia.isVideo() == true || firstMedia.isImage() == true || firstMedia.isAudio() == true) { + if (firstMedia.isVideo() == true || firstMedia.isImage() == true) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .padding(vertical = 8.dp), + ) { + Column( + modifier = Modifier.weight(1.0f), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Text( + text = stringRes(R.string.media_compression_quality_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.media_compression_quality_explainer), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } Column(horizontalAlignment = Alignment.CenterHorizontally) { Box(modifier = Modifier.fillMaxWidth()) { Text( @@ -301,7 +320,7 @@ fun ImageVideoDescription( } } - if (uris.first().media.isVideo() == true) { + if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) { SettingSwitchItem( title = R.string.video_codec_h265_label, description = R.string.video_codec_h265_description, @@ -319,7 +338,11 @@ fun ImageVideoDescription( Modifier .fillMaxWidth() .padding(vertical = 10.dp), - onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) }, + enabled = !isUploading, + onClick = { + val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata + onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata) + }, shape = QuoteBorder, colors = ButtonDefaults.buttonColors( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollConsensusThreshold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollConsensusThreshold.kt new file mode 100644 index 0000000000..8c3f474727 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollConsensusThreshold.kt @@ -0,0 +1,110 @@ +/* + * 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.creators.zappolls + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.CancellationException + +@Composable +fun ZapPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) { + var text by rememberSaveable { mutableStateOf("") } + + pollViewModel.isValidConsensusThreshold.value = true + if (text.isNotEmpty()) { + try { + val int = text.toInt() + if (int !in 0..100) { + pollViewModel.isValidConsensusThreshold.value = false + } else { + pollViewModel.zapPollConsensusThreshold = int + } + } catch (e: Exception) { + if (e is CancellationException) throw e + pollViewModel.isValidConsensusThreshold.value = false + } + } + + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, + ) + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid, + label = { + Text( + text = stringRes(R.string.poll_consensus_threshold), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringRes(R.string.poll_consensus_threshold_percent), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun ZapPollConsensusThresholdPreview() { + ZapPollConsensusThreshold(ShortNotePostViewModel()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt new file mode 100644 index 0000000000..295c2d8d2a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt @@ -0,0 +1,170 @@ +/* + * 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.creators.zappolls + +import android.annotation.SuppressLint +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.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.TimeUtils +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) { + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + // Get current time details + val currentTime = Instant.ofEpochMilli(model.zapPollClosedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = model.zapPollClosedAt * 1000, + yearRange = currentTime.year..2050, + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + // Only allow today and future dates + return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer + } + }, + ) + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = false, // Set to true if you prefer military time + ) + + val context = LocalContext.current + + OutlinedCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.DateRange, contentDescription = stringResource(R.string.accessibility_select_date)) + Spacer(Modifier.width(12.dp)) + + if (model.zapPollClosedAt < TimeUtils.oneMinuteFromNow()) { + Text(stringRes(R.string.poll_closing_date_time) + " " + model.zapPollClosedAt, style = MaterialTheme.typography.bodyLarge) + } else { + Text( + text = stringRes(R.string.poll_closing_in, timeAheadNoDot(model.zapPollClosedAt, context)), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + + // --- Date Picker Dialog --- + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { Text(stringResource(R.string.next)) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + // --- Time Picker Dialog --- + if (showTimePicker) { + TimePickerDialog( + title = { + Text(stringResource(R.string.closing_time)) + }, + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + val datetimeLocalTimeZone = + datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis -> + (localDayAtZeroHourMillis / 1000) + + (timePickerState.hour * TimeUtils.ONE_HOUR) + + (timePickerState.minute * TimeUtils.ONE_MINUTE) + } ?: TimeUtils.oneDayAhead() + + // Get the offset from UTC for the current instant in the local time zone + val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) + + model.zapPollClosedAt = datetimeLocalTimeZone - offset.totalSeconds + + showTimePicker = false + }, + ) { Text(stringResource(R.string.confirm)) } + }, + ) { + TimePicker(state = timePickerState) + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun ZapPollDeadlinePickerPreview() { + ZapPollDeadlinePicker( + ShortNotePostViewModel(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt new file mode 100644 index 0000000000..78e2c02af6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt @@ -0,0 +1,75 @@ +/* + * 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.creators.zappolls + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun ZapPollField(postViewModel: ShortNotePostViewModel) { + val optionsList = postViewModel.zapPollOptions + Column( + modifier = Modifier.fillMaxWidth(), + ) { + optionsList.forEach { value -> + ZapPollOption(postViewModel, value.key) + } + + Spacer(Modifier.height(8.dp)) + + ZapPollDeadlinePicker(postViewModel) + + ZapPollVoteValueRange(postViewModel) + + Button( + onClick = { + // postViewModel.pollOptions[postViewModel.pollOptions.size] = "" + optionsList[optionsList.size] = "" + }, + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + ), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Icon(Icons.Default.Add, contentDescription = stringRes(R.string.add_poll_option_button)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt new file mode 100644 index 0000000000..4bdee9e075 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt @@ -0,0 +1,95 @@ +/* + * 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.creators.zappolls + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun ZapPollOption( + pollViewModel: ShortNotePostViewModel, + optionIndex: Int, +) { + Row { + val deleteIcon: @Composable (() -> Unit) = { + IconButton( + onClick = { + pollViewModel.removeZapPollOption(optionIndex) + }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringRes(R.string.clear), + ) + } + } + + OutlinedTextField( + modifier = Modifier.weight(1F), + value = pollViewModel.zapPollOptions[optionIndex] ?: "", + onValueChange = { + pollViewModel.updateZapPollOption(optionIndex, it) + }, + label = { + Text( + text = stringRes(R.string.poll_option_index).format(optionIndex + 1), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringRes(R.string.poll_option_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else + // colorInValid, + trailingIcon = if (optionIndex > 1) deleteIcon else null, + ) + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun ZapPollOptionPreview() { + ZapPollOption(ShortNotePostViewModel(), 0) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollVoteValueRange.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollVoteValueRange.kt new file mode 100644 index 0000000000..ea72c02ed3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollVoteValueRange.kt @@ -0,0 +1,128 @@ +/* + * 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.creators.zappolls + +import android.annotation.SuppressLint +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.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun ZapPollVoteValueRange(pollViewModel: ShortNotePostViewModel) { + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, + ) + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = pollViewModel.zapPollValueMinimum?.toString() ?: "", + onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = if (pollViewModel.isValidValueMinimum.value) colorValid else colorInValid, + label = { + Text( + text = stringRes(R.string.poll_zap_value_min), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringRes(R.string.sats), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = DoubleHorzSpacer) + + OutlinedTextField( + value = pollViewModel.zapPollValueMaximum?.toString() ?: "", + onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = if (pollViewModel.isValidValueMaximum.value) colorValid else colorInValid, + label = { + Text( + text = stringRes(R.string.poll_zap_value_max), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringRes(R.string.sats), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringRes(R.string.poll_zap_value_min_max_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun ZapPollVoteValueRangePreview() { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + ZapPollVoteValueRange(ShortNotePostViewModel()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index aec0c4a003..242bf2f46e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -21,10 +21,22 @@ package com.vitorpamplona.amethyst.ui.note.elements import android.content.Intent -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bookmark +import androidx.compose.material.icons.outlined.BookmarkAdd +import androidx.compose.material.icons.outlined.BookmarkRemove +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material.icons.outlined.PersonAdd +import androidx.compose.material.icons.outlined.PersonRemove +import androidx.compose.material.icons.outlined.PlaylistAdd +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.State @@ -44,6 +56,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable +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.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo @@ -53,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -78,16 +92,16 @@ fun MoreOptionsButton( onClick = { popupExpanded.value = true }, ) { VerticalDotsIcon() + } - if (popupExpanded.value) { - NoteDropDownMenu( - note = baseNote, - onDismiss = { popupExpanded.value = false }, - editState = editState, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (popupExpanded.value) { + NoteDropDownMenu( + note = baseNote, + onDismiss = { popupExpanded.value = false }, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -146,213 +160,150 @@ fun NoteDropDownMenu( ) } - DropdownMenu( - expanded = true, - onDismissRequest = onDismiss, + M3ActionDialog( + title = stringRes(R.string.note_actions_dialog_title), + onDismiss = onDismiss, ) { val clipboardManager = LocalClipboardManager.current val actContext = LocalContext.current - val scope = rememberCoroutineScope() - if (!state.isFollowingAuthor) { - DropdownMenuItem( - text = { Text(stringRes(R.string.follow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + // Follow section + M3ActionSection { + if (!state.isFollowingAuthor) { + M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.follow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.unfollow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + } + } else { + M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.unfollow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } - DropdownMenuItem( - text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) }, - onClick = { - val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem + } + } + M3ActionRow(icon = Icons.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) { + val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow nav.nav(Route.PeopleListManagement(authorHexKey)) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_text)) }, - onClick = { + } + } + + // Copy & Share section + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - clipboardManager.setText(AnnotatedString(it)) - } + accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_user_pubkey)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) onDismiss() } } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_note_id)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString(note.toNostrUri())) onDismiss() } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), - ) - putExtra( - Intent.EXTRA_TITLE, - stringRes(actContext, R.string.quick_action_share_browser_link), - ) + putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) + putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) } - - val shareIntent = - Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) ContextCompat.startActivity(actContext, shareIntent, null) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser && note.isDraft()) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_draft)) }, - onClick = { - nav.nav { - routeEditDraftTo(note, accountViewModel.account) - } - }, - ) - } - if (note.event is TextNoteEvent && !note.isDraft()) { - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_post)) }, - onClick = { - wantsToEditPost.value = true - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.propose_an_edit)) }, - onClick = { - wantsToEditPost.value = true - }, - ) } } - DropdownMenuItem( - text = { Text(stringRes(R.string.broadcast)) }, - onClick = { + + // Edit & Broadcast section + M3ActionSection { + if (state.isLoggedUser && note.isDraft()) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) { + nav.nav { routeEditDraftTo(note, accountViewModel.account) } + } + } + if (note.event is TextNoteEvent && !note.isDraft()) { + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) { + wantsToEditPost.value = true + } + } else { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) { + wantsToEditPost.value = true + } + } + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) { accountViewModel.broadcast(note) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.otsState.hasPendingAttestations(note)) { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_pending)) }, - onClick = { - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_it)) }, - onClick = { + } + } + + // Timestamp & Bookmarks section + M3ActionSection { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } + } else { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) { accountViewModel.timestamp(note) onDismiss() - }, - ) - } - HorizontalDivider(thickness = DividerThickness) - note.let { + } + } val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) - DropdownMenuItem( - text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) }, - onClick = { - if (note.event is LongTextNoteEvent) { - val noteAddress = (note as AddressableNote).address - nav.nav(Route.ArticleBookmarkManagement(noteAddress)) - } else { - nav.nav(Route.PostBookmarkManagement(note.idHex)) - } - onDismiss() - }, - ) - } - if (state.isPrivateBookmarkNote) { - DropdownMenuItem( - text = { Text(stringRes(R.string.remove_from_private_bookmarks)) }, - onClick = { + M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { + if (note.event is LongTextNoteEvent) { + nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) + } else { + nav.nav(Route.PostBookmarkManagement(note.idHex)) + } + onDismiss() + } + if (state.isPrivateBookmarkNote) { + M3ActionRow(icon = Icons.Outlined.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) { accountViewModel.removePrivateBookmark(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.add_to_private_bookmarks)) }, - onClick = { + } + } else { + M3ActionRow(icon = Icons.Outlined.Lock, text = stringRes(R.string.add_to_private_bookmarks)) { accountViewModel.addPrivateBookmark(note) onDismiss() - }, - ) - } - if (state.isPublicBookmarkNote) { - DropdownMenuItem( - text = { Text(stringRes(R.string.remove_from_public_bookmarks)) }, - onClick = { + } + } + if (state.isPublicBookmarkNote) { + M3ActionRow(icon = Icons.Outlined.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { accountViewModel.removePublicBookmark(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.add_to_public_bookmarks)) }, - onClick = { + } + } else { + M3ActionRow(icon = Icons.Outlined.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { accountViewModel.addPublicBookmark(note) onDismiss() - }, - ) + } + } } - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.request_deletion)) }, - onClick = { + + // Moderation section + M3ActionSection { + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.block_report)) }, - onClick = { reportDialogShowing = true }, - ) + } + } else { + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) { + reportDialogShowing = true + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index c60b0a42e0..10051c2945 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -42,9 +42,11 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -149,7 +151,9 @@ open class CommentPostViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -159,6 +163,9 @@ open class CommentPostViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + // Invoices var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) @@ -467,8 +474,9 @@ open class CommentPostViewModel : server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, + stripMetadata: Boolean = true, ) = try { - uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata) } catch (_: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -483,11 +491,12 @@ open class CommentPostViewModel : server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, + stripMetadata: Boolean = true, ) { viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -497,6 +506,8 @@ open class CommentPostViewModel : server, account, context, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { @@ -551,7 +562,7 @@ open class CommentPostViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -564,7 +575,7 @@ open class CommentPostViewModel : externalIdentity = null multiOrchestrator = null - isUploadingImage = false + mediaUploadTracker.finishUpload() notifying = null @@ -676,7 +687,7 @@ open class CommentPostViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - !isUploadingImage && + !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && multiOrchestrator == null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 67e690b834..aba7a01200 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -139,6 +140,8 @@ fun GenericCommentPostScreen( ) { WatchAndLoadMyEmojiList(accountViewModel) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() @@ -299,8 +302,9 @@ private fun GenericCommentPostBody( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + isUploading = postViewModel.mediaUploadTracker.isUploading, + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, @@ -395,6 +399,7 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -402,7 +407,8 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt new file mode 100644 index 0000000000..6eb4dae2e7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -0,0 +1,638 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.Recommend +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Preview +@Composable +fun RenderAttestationPreview() { + val event = + AttestationEvent( + id = "f8e05e4fa964d9dfbeb339ae0450d0b3424aaf8f6083d95f38c780335c3dbd56", + pubKey = "c4f5e7a75a8ce3683d529cff06368439c529e5243c6b125ba68789198856cac7", + createdAt = 1773941524, + content = "This is Frank's new npub.", + sig = "61dde5dc5738bfa637aa04451eec63d45f649902a0772abbaf55711aad5b7ce376bc7712c937f9ffc504655c4fd7e39f78139dc3f1a90953d150b43f3e2428fb", + tags = + arrayOf( + arrayOf("d", "af5aa898:fe108febb997:1773941524"), + arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"), + arrayOf("s", "verified"), + arrayOf("v", "valid"), + arrayOf("client", "attestr.xyz"), + ), + ) + + LocalCache.justConsume(event, null, true) + val note = LocalCache.getOrCreateNote(event.id) + + ThemeComparisonColumn( + toPreview = { + RenderAttestation( + baseNote = note, + quotesLeft = 3, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + }, + ) +} + +@Composable +fun RenderAttestation( + baseNote: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by baseNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestationEvent ?: return + + RenderAttestation( + baseNote, + noteEvent, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun RenderAttestation( + note: Note, + noteEvent: AttestationEvent, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val validity = remember(noteEvent) { noteEvent.validity() } + val status = remember(noteEvent) { noteEvent.status() } + val validFrom = remember(noteEvent) { noteEvent.validFrom() } + val validTo = remember(noteEvent) { noteEvent.validTo() } + val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } + + val statusColor = remember(status, validity) { attestationColor(status, validity) } + val statusIcon = remember(status, validity) { attestationIcon(status, validity) } + val statusLabel = attestationStatusLabel(status, validity) + + val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } + val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } + val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, statusColor.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) + .background(statusColor.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = statusIcon, + contentDescription = stringRes(R.string.attestation), + tint = statusColor, + modifier = Modifier.size(24.dp), + ) + Text( + text = statusLabel, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = statusColor, + ) + } + + if (validFrom != null || validTo != null) { + Spacer(modifier = DoubleVertSpacer) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + validFrom?.let { + Text( + text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + validTo?.let { + Text( + text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (quotesLeft > 0) { + if (aboutAddress != null) { + LoadAddressableNote(aboutAddress, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutEvent != null) { + LoadNote(aboutEvent, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutPubkey != null) { + LoadUser(aboutPubkey, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + UserCompose(it, accountViewModel = accountViewModel, nav = nav) + } + } + } + } + + content?.let { + Spacer(modifier = DoubleVertSpacer) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun RenderAttestationRequest( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestationRequestEvent ?: return + + val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } + + val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } + val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } + val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + RoundedCornerShape(12.dp), + ).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = stringRes(R.string.attestation_request), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.attestation_request), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + + content?.let { + Spacer(modifier = DoubleVertSpacer) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (quotesLeft > 0) { + if (aboutAddress != null) { + LoadAddressableNote(aboutAddress, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = stringRes(R.string.attestation_requests_attestation_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutEvent != null) { + LoadNote(aboutEvent, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = stringRes(R.string.attestation_requests_attestation_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutPubkey != null) { + LoadUser(aboutPubkey, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = stringRes(R.string.attestation_requests_attestation_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + UserCompose(it, accountViewModel = accountViewModel, nav = nav) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderAttestorRecommendation( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestorRecommendationEvent ?: return + + val kinds = remember(noteEvent) { noteEvent.kinds() } + val description = remember(noteEvent) { noteEvent.description() } + val aboutPubKey = remember(noteEvent) { noteEvent.dTag() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f), + RoundedCornerShape(12.dp), + ).background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Recommend, + contentDescription = stringRes(R.string.attestor_recommendation), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.attestor_recommendation), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + LoadUser(aboutPubKey, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + UserCompose(it, accountViewModel = accountViewModel, nav = nav) + } + } + + if (kinds.isNotEmpty()) { + Spacer(modifier = DoubleVertSpacer) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringRes(R.string.attestor_recommendation_for_kinds), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Kinds + kinds.forEach { kind -> + KindChip(kind) + } + } + } + + description?.let { + Spacer(modifier = DoubleVertSpacer) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderAttestorProficiency( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestorProficiencyEvent ?: return + + val kinds = remember(noteEvent) { noteEvent.kinds() } + val description = remember(noteEvent) { noteEvent.description() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f), + RoundedCornerShape(12.dp), + ).background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = stringRes(R.string.attestor_proficiency), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.attestor_proficiency), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.secondary, + ) + } + + if (kinds.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + kinds.forEach { kind -> + Text( + text = "Kind $kind", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + + description?.let { + Spacer(modifier = Modifier.height(8.dp)) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +private fun attestationColor( + status: AttestationStatus?, + validity: Validity?, +): Color = + when { + status == AttestationStatus.REVOKED -> Color(0xFFB71C1C) + status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) + validity == Validity.INVALID -> Color(0xFFB71C1C) + status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) + validity == Validity.VALID -> Color(0xFF2E7D32) + status == AttestationStatus.VERIFYING -> Color(0xFFF57F17) + status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0) + else -> Color(0xFF757575) + } + +private fun attestationIcon( + status: AttestationStatus?, + validity: Validity?, +): ImageVector = + when { + status == AttestationStatus.REVOKED -> Icons.Default.Close + status == AttestationStatus.REJECTED -> Icons.Default.Close + validity == Validity.INVALID -> Icons.Default.Close + status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser + validity == Validity.VALID -> Icons.Default.CheckCircle + status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop + status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle + else -> Icons.Default.VerifiedUser + } + +@Composable +private fun attestationStatusLabel( + status: AttestationStatus?, + validity: Validity?, +): String = + when { + status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked) + status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected) + validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) + status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified) + validity == Validity.VALID -> stringRes(R.string.attestation_valid) + status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) + status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted) + else -> stringRes(R.string.attestation) + } + +private fun formatTimestamp(timestamp: Long): String { + val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + return sdf.format(Date(timestamp * 1000)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 4840d06a1e..066de85c41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -96,7 +96,7 @@ fun RenderLiveChessChallenge( nav: INav, ) { val event = (note.event as? LiveChessGameChallengeEvent) ?: return - val gameId = event.gameId() ?: return + val gameId = event.gameId() val chessViewModel: ChessViewModelNew = viewModel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index 6cdcec848d..29cb0937af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -72,9 +72,9 @@ private fun ObserverAndRenderNIP95( val content by remember(noteState) { // Creates a new object when the event arrives to force an update of the image. - val note = noteState?.note + val note = noteState.note val uri = header.toNostrUri() - val localDir = note?.idHex?.let { File(Amethyst.instance.nip95cache, it) } + val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) } val blurHash = eventHeader.blurhash() val dimensions = eventHeader.dimensions() val description = eventHeader.alt() ?: eventHeader.content diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt index d69e4a5a07..e7772e9c02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -163,7 +163,7 @@ fun RenderTextModificationEvent( } LaunchedEffect(key1 = noteState) { - val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author) + val newAuthor = accountViewModel.isLoggedUser(noteState.note.author) if (isAuthorTheLoggedUser.value != newAuthor) { isAuthorTheLoggedUser.value = newAuthor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt index aa40605d62..48847aa61b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt @@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -61,7 +61,7 @@ fun RenderZapPoll( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PollNoteEvent ?: return + val noteEvent = note.event as? ZapPollEvent ?: return val eventContent = noteEvent.content val showReply by diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 3549f612ce..9e9fcf4b32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -112,11 +112,11 @@ class AccountSessionManager( } } - private suspend fun loginWithDefaultAccount(route: Route? = null) { + private suspend fun loginWithDefaultAccount(routeBuilder: ((account: Account) -> Route?)? = null) { val accountSettings = localPreferences.loadAccountConfigFromEncryptedStorage() if (accountSettings != null) { - startUI(accountSettings, route) + startUI(accountSettings, routeBuilder) } else { requestLoginUI() } @@ -184,11 +184,11 @@ class AccountSessionManager( fun startUI( accountSettings: AccountSettings, - route: Route? = null, + routeBuilder: ((account: Account) -> Route?)? = null, ) { val account = accountsCache.loadAccount(accountSettings) _accountContent.update { - AccountState.LoggedIn(account, route) + AccountState.LoggedIn(account, routeBuilder?.invoke(account)) } } @@ -280,7 +280,7 @@ class AccountSessionManager( localPreferences.setDefaultAccount(accountSettings) - startUI(accountSettings, route = Route.ImportFollowsSelectUser) + startUI(accountSettings, routeBuilder = { Route.ImportFollowsSelectUser }) scope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. @@ -329,12 +329,12 @@ class AccountSessionManager( suspend fun checkAndSwitchUserSync( npub: String, - route: Route, + routeBuilder: ((account: Account) -> Route?)? = null, ): Boolean { if (npub != localPreferences.currentAccount()) { val account = localPreferences.allSavedAccounts().firstOrNull { it.npub == npub } if (account != null) { - switchUserSync(account, route) + switchUserSync(account, routeBuilder) return true } } @@ -343,10 +343,10 @@ class AccountSessionManager( private suspend fun switchUserSync( accountInfo: AccountInfo, - route: Route? = null, + routeBuilder: ((account: Account) -> Route?)? = null, ) { localPreferences.switchToAccount(accountInfo) - loginWithDefaultAccount(route) + loginWithDefaultAccount(routeBuilder) } fun currentAccountNPub() = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 327a7e3f5f..c2a2b12d7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import kotlinx.coroutines.CoroutineScope @@ -68,6 +69,7 @@ class AccountFeedContentStates( val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) + val notificationsOpenPolls = OpenPollsState(account, scope) val notificationSummary = NotificationSummaryState(account) val feedListOptions = TopNavFilterState(account, scope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 84ec07af70..8dc7d01f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -49,6 +49,7 @@ fun AccountSwitcherAndLeftDrawerLayout( accountViewModel: AccountViewModel, accountSessionManager: AccountSessionManager, nav: INav, + gesturesEnabled: Boolean = true, content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() @@ -83,6 +84,7 @@ fun AccountSwitcherAndLeftDrawerLayout( ModalNavigationDrawer( drawerState = nav.drawerState, + gesturesEnabled = gesturesEnabled, drawerContent = { DrawerContent(nav, openSheetFunction, accountViewModel) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 09a901b7d8..b3bd798932 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -35,6 +35,7 @@ import coil3.asDrawable import coil3.imageLoader import coil3.request.ImageRequest import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache @@ -79,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType @@ -93,8 +95,10 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -123,7 +127,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -144,8 +148,10 @@ import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -158,7 +164,6 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.Locale @Stable class AccountViewModel( @@ -176,6 +181,66 @@ class AccountViewModel( val broadcastTracker = BroadcastTracker() val feedStates = AccountFeedContentStates(account, viewModelScope) + val eventSync = + EventSync( + accountPubKey = account.signer.pubKey, + relayDb = { + val stats = Amethyst.instance.relayStats.snapshot() + + val relays = + account.cache.relayHints.relayDB + .keys() + .filter { url -> + val relayStat = stats[url] + // has connected at least once OR never tried. + if (relayStat != null) { + relayStat.connectionCompleted > 0 || relayStat.connectionTentatives == 0 + } else { + true + } + } + + val sortMap = relays.associateWith { stats.get(it)?.receivedBytes } + + relays.sortedByDescending { sortMap[it] } + }, + outboxTargets = { account.nip65RelayList.outboxFlow.value }, + inboxTargets = { account.nip65RelayList.inboxFlow.value }, + dmTargets = { account.dmRelayList.flow.value }, + clientBuilder = { + // creates a new client to make sure these events don't end up polluting the local cache. + + // Create a new scope that inherits the ViewModel's lifecycle + // but uses a SupervisorJob so child failures are independent. + val customScope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob()) + + // Provides a relay pool + val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope) + + // Authenticates with relays. + val auth = + RelayAuthenticator( + newClient, + customScope, + signWithAllLoggedInUsers = { authTemplate -> + if (account.signer.isWriteable()) { + try { + listOf(account.signer.sign(authTemplate)) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + emptyList() + } + } else { + emptyList() + } + }, + ) + + newClient + }, + scope = viewModelScope, + ) + val tempManualPaymentCache = LruCache>(5) @OptIn(ExperimentalCoroutinesApi::class) @@ -1009,7 +1074,7 @@ class AccountViewModel( fun removeDontTranslateFrom(languageCode: String) = launchSigner { account.removeDontTranslateFrom(languageCode) } - fun updateTranslateTo(languageCode: Locale) = launchSigner { account.updateTranslateTo(languageCode) } + fun updateTranslateTo(languageCode: String) = launchSigner { account.updateTranslateTo(languageCode) } fun prefer( source: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index c2411a8c93..717499410c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -21,10 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display import android.content.Intent -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BookmarkRemove +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material.icons.outlined.PersonAdd +import androidx.compose.material.icons.outlined.PersonRemove +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -42,6 +51,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable +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.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon @@ -52,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.Dispatchers @@ -76,20 +87,20 @@ fun BookmarkGroupItemOptions( onClick = { popupExpanded.value = true }, ) { VerticalDotsIcon() + } - if (popupExpanded.value) { - BookmarkGroupItemOptionsMenu( - note = baseNote, - isBookmarkItemPrivate = isBookmarkItemPrivate, - onDismiss = { popupExpanded.value = false }, - onMoveBookmarkToPublic = onMoveBookmarkToPublic, - onMoveBookmarkToPrivate = onMoveBookmarkToPrivate, - onDeleteBookmarkItem = onDeleteBookmarkItem, - editState = editState, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (popupExpanded.value) { + BookmarkGroupItemOptionsMenu( + note = baseNote, + isBookmarkItemPrivate = isBookmarkItemPrivate, + onDismiss = { popupExpanded.value = false }, + onMoveBookmarkToPublic = onMoveBookmarkToPublic, + onMoveBookmarkToPrivate = onMoveBookmarkToPrivate, + onDeleteBookmarkItem = onDeleteBookmarkItem, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -142,89 +153,71 @@ fun BookmarkGroupItemOptionsMenu( ) } - DropdownMenu( - expanded = true, - onDismissRequest = onDismiss, + M3ActionDialog( + title = stringRes(R.string.bookmark_item_actions_dialog_title), + onDismiss = onDismiss, ) { val clipboardManager = LocalClipboardManager.current val actContext = LocalContext.current - val scope = rememberCoroutineScope() - DropdownMenuItem( - text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) }, - onClick = if (isBookmarkItemPrivate) onMoveBookmarkToPublic else onMoveBookmarkToPrivate, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.bookmark_remove_action_label)) }, - onClick = { + + // Bookmark Management section + M3ActionSection { + M3ActionRow( + icon = if (isBookmarkItemPrivate) Icons.Outlined.LockOpen else Icons.Outlined.Lock, + text = stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label), + ) { + if (isBookmarkItemPrivate) onMoveBookmarkToPublic() else onMoveBookmarkToPrivate() + } + M3ActionRow( + icon = Icons.Outlined.BookmarkRemove, + text = stringRes(R.string.bookmark_remove_action_label), + isDestructive = true, + ) { onDeleteBookmarkItem() onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) + } + } - if (!state.isFollowingAuthor) { - DropdownMenuItem( - text = { Text(stringRes(R.string.follow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + // Follow section + M3ActionSection { + if (!state.isFollowingAuthor) { + M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.follow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.unfollow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + } + } else { + M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.unfollow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } -// DropdownMenuItem( -// text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) }, -// onClick = { -// val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem -// nav.nav(Route.PeopleListManagement(authorHexKey)) -// onDismiss() -// }, -// ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_text)) }, - onClick = { - val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - clipboardManager.setText(AnnotatedString(it)) } + } + } + + // Copy & Share section + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) { + val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note + accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_user_pubkey)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) onDismiss() } } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_note_id)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString(note.toNostrUri())) onDismiss() } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -243,74 +236,57 @@ fun BookmarkGroupItemOptionsMenu( Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) ContextCompat.startActivity(actContext, shareIntent, null) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser && note.isDraft()) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_draft)) }, - onClick = { + } + } + + // Edit & Broadcast section + M3ActionSection { + if (state.isLoggedUser && note.isDraft()) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) { nav.nav { routeEditDraftTo(note, accountViewModel.account) } - }, - ) - } - if (note.event is TextNoteEvent && !note.isDraft()) { - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_post)) }, - onClick = { - wantsToEditPost.value = true - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.propose_an_edit)) }, - onClick = { - wantsToEditPost.value = true - }, - ) + } } - } - DropdownMenuItem( - text = { Text(stringRes(R.string.broadcast)) }, - onClick = { + if (note.event is TextNoteEvent && !note.isDraft()) { + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) { + wantsToEditPost.value = true + } + } else { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) { + wantsToEditPost.value = true + } + } + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) { accountViewModel.broadcast(note) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.otsState.hasPendingAttestations(note)) { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_pending)) }, - onClick = { + } + } + + // Timestamp & Moderation section + M3ActionSection { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_it)) }, - onClick = { + } + } else { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) { accountViewModel.timestamp(note) onDismiss() - }, - ) - } - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.request_deletion)) }, - onClick = { + } + } + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.block_report)) }, - onClick = { reportDialogShowing = true }, - ) + } + } else { + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) { + reportDialogShowing = true + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 1a7605f9bd..943a262abb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -30,11 +30,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -57,6 +57,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickableBox +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.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -65,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -304,6 +306,31 @@ fun BookmarkGroupActionsMenuButton( ) { val isActionListOpen = remember { mutableStateOf(false) } + if (isActionListOpen.value) { + M3ActionDialog( + title = stringRes(R.string.list_actions_dialog_title), + onDismiss = { isActionListOpen.value = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.CellTower, + text = stringRes(R.string.bookmark_list_broadcast_btn_label), + ) { + onBroadcastList() + isActionListOpen.value = false + } + M3ActionRow( + icon = Icons.Outlined.Delete, + text = stringRes(R.string.bookmark_list_delete_btn_label), + isDestructive = true, + ) { + onDeleteList() + isActionListOpen.value = false + } + } + } + } + ClickableBox( modifier = StdPadding @@ -319,44 +346,5 @@ fun BookmarkGroupActionsMenuButton( onClick = { isActionListOpen.value = true }, ) { VerticalDotsIcon() - BookmarkGroupActionsMenu( - onCloseMenu = { isActionListOpen.value = false }, - isOpen = isActionListOpen.value, - onBroadcastList = onBroadcastList, - onDeleteList = onDeleteList, - ) - } -} - -@Composable -fun BookmarkGroupActionsMenu( - onCloseMenu: () -> Unit, - isOpen: Boolean, - onBroadcastList: () -> Unit, - onDeleteList: () -> Unit, -) { - DropdownMenu( - expanded = isOpen, - onDismissRequest = onCloseMenu, - ) { - DropdownMenuItem( - text = { - Text(stringRes(R.string.bookmark_list_broadcast_btn_label)) - }, - onClick = { - onBroadcastList() - onCloseMenu() - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.bookmark_list_delete_btn_label)) - }, - onClick = { - onDeleteList() - onCloseMenu() - }, - ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt index 2393d385bf..eeb80529f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -32,10 +32,12 @@ import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -57,6 +59,9 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.components.ClickableBox +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.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes @@ -258,18 +263,18 @@ private fun BookmarkGroupOptionsButton( onClick = { isMenuOpen.value = true }, ) { VerticalDotsIcon() - - GroupOptionsMenu( - groupName = bookmarkGroupName, - groupDescription = bookmarkGroupDescription, - isExpanded = isMenuOpen.value, - onDismiss = { isMenuOpen.value = false }, - onGroupRename = onGroupRename, - onGroupDescriptionChange = onGroupDescriptionChange, - onGroupClone = onGroupCloneCreate, - onDelete = onGroupDelete, - ) } + + GroupOptionsMenu( + groupName = bookmarkGroupName, + groupDescription = bookmarkGroupDescription, + isExpanded = isMenuOpen.value, + onDismiss = { isMenuOpen.value = false }, + onGroupRename = onGroupRename, + onGroupDescriptionChange = onGroupDescriptionChange, + onGroupClone = onGroupCloneCreate, + onDelete = onGroupDelete, + ) } @Composable @@ -288,45 +293,32 @@ private fun GroupOptionsMenu( val optionalCloneName = remember { mutableStateOf(null) } val optionalCloneDescription = remember { mutableStateOf(null) } - DropdownMenu( - expanded = isExpanded, - onDismissRequest = onDismiss, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_rename_btn_label)) - }, - onClick = { - onGroupRename() - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_desc_modify_label)) - }, - onClick = { - onGroupDescriptionChange() - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_copy_action_btn_label)) - }, - onClick = { - isCopyDialogOpen.value = true - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.quick_action_delete)) - }, - onClick = { - onDelete() - }, - ) + if (isExpanded) { + M3ActionDialog( + title = stringRes(R.string.group_actions_dialog_title), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_rename_btn_label)) { + onGroupRename() + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.Description, text = stringRes(R.string.follow_set_desc_modify_label)) { + onGroupDescriptionChange() + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) { + isCopyDialogOpen.value = true + onDismiss() + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + onDismiss() + } + } + } } if (isCopyDialogOpen.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt index b7715da6c0..c82c179954 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType @@ -138,7 +139,22 @@ class BookmarkGroupMetadataViewModel : ViewModel() { ) { onUploading(true) - val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + val sourceUri = + if (account.settings.stripLocationOnUpload) { + val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) + if (!result.stripped) { + onError( + stringRes(context, R.string.metadata_strip_failed_title), + stringRes(context, R.string.metadata_strip_failed_upload_cancelled), + ) + onUploading(false) + return + } + result.uri + } else { + galleryUri.uri + } + val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) try { val result = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt index 756b8bc8e2..a66a30be78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt @@ -30,12 +30,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BookmarkAdd import androidx.compose.material.icons.filled.BookmarkRemove +import androidx.compose.material.icons.outlined.BookmarkAdd import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.RemoveCircleOutline -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -48,6 +47,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.R +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.screen.loggedIn.bookmarkgroups.list.BookmarkMembershipStatusAndNumberDisplay import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding @@ -158,6 +160,30 @@ fun BookmarkManagementOptions( ) { val isBookmarkAddTapped = remember { mutableStateOf(false) } + if (isBookmarkAddTapped.value) { + M3ActionDialog( + title = stringRes(R.string.add_bookmark_dialog_title), + onDismiss = { isBookmarkAddTapped.value = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.BookmarkAdd, + text = stringRes(R.string.public_bookmark_add_action_label), + ) { + onAddBookmark(false) + isBookmarkAddTapped.value = false + } + M3ActionRow( + icon = Icons.Outlined.Lock, + text = stringRes(R.string.private_bookmark_add_action_label), + ) { + onAddBookmark(true) + isBookmarkAddTapped.value = false + } + } + } + } + Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -196,29 +222,5 @@ fun BookmarkManagementOptions( ) } } - - DropdownMenu( - expanded = isBookmarkAddTapped.value, - onDismissRequest = { isBookmarkAddTapped.value = false }, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.public_bookmark_add_action_label)) - }, - onClick = { - onAddBookmark(false) - isBookmarkAddTapped.value = false - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.private_bookmark_add_action_label)) - }, - onClick = { - onAddBookmark(true) - isBookmarkAddTapped.value = false - }, - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index b996354f9d..358247a945 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -26,14 +26,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView @@ -44,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.Privat import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.launch @Composable @@ -92,10 +95,15 @@ fun ChatroomView( } } - if (room.users.size == 1) { - // Activates NIP-17 if the user has DM relays - ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { - newPostModel.nip17 = !it?.relays().isNullOrEmpty() + // Reactively check if recipients have DM relays for NIP-17 delivery + for (userHex in room.users) { + LoadAddressableNote( + ChatMessageRelayListEvent.createAddress(userHex), + accountViewModel, + ) { note -> + if (note != null) { + EventFinderFilterAssemblerSubscription(note, accountViewModel) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 747920b86f..19e917bd73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -73,6 +74,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji @@ -88,16 +90,29 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch @Stable @@ -126,13 +141,65 @@ class ChatNewMessageViewModel : } } - var room: ChatroomKey? by mutableStateOf(null) + val room = MutableStateFlow(null) - var requiresNIP17: Boolean = false + val roomUsers: StateFlow> = + room + .mapNotNull { + it?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList() + }.flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + room.value?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val recipientsMissingDmRelays: StateFlow> = + roomUsers + .transformLatest { + val dmRelayListNoteFlows = + it.map { user -> + user.dmRelayListNote + .flow() + .metadata.stateFlow + } + + if (dmRelayListNoteFlows.isEmpty()) { + emitAll(MutableStateFlow(persistentListOf())) + } else { + val flow = + combine(dmRelayListNoteFlows) { dmRelayListNotes -> + dmRelayListNotes + .mapNotNull { noteState -> + val noteEvent = noteState.note.event as? ChatMessageRelayListEvent + if (noteEvent == null || noteEvent.relays().isEmpty()) { + noteState.note.author + } else { + null + } + }.toImmutableList() + } + emitAll(flow) + } + }.onStart { + }.onCompletion { + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + roomUsers.value + .filter { user -> + user.dmInboxRelays().isNullOrEmpty() + }.toImmutableList(), + ) val replyTo = mutableStateOf(null) var uploadState by mutableStateOf(null) + + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + val iMetaAttachments = IMetaAttachments() var uploadsWaitingToBeSent by mutableStateOf>(emptyList()) @@ -141,7 +208,8 @@ class ChatNewMessageViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) + val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false + val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -179,9 +247,6 @@ class ChatNewMessageViewModel : var wantsZapraiser by mutableStateOf(false) override var zapRaiserAmount = mutableStateOf(null) - // NIP17 Wrapped DMs / Group messages - var nip17 by mutableStateOf(false) - fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -203,30 +268,16 @@ class ChatNewMessageViewModel : this.uploadState = ChatFileUploadState( account.settings.defaultFileServer, + account.settings.stripLocationOnUpload, ) } fun load(room: ChatroomKey) { - this.room = room + this.room.tryEmit(room) this.toUsers = TextFieldValue( room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, ) - - updateNIP17StatusFromRoom() - } - - fun updateNIP17StatusFromRoom() { - val room = this.room - if (room != null) { - this.requiresNIP17 = room.users.size > 1 - if (this.requiresNIP17) { - this.nip17 = true - } - } else { - this.requiresNIP17 = false - this.nip17 = false - } } fun reply(replyNote: Note) { @@ -359,9 +410,6 @@ class ChatNewMessageViewModel : urlPreviews.update(message) iMetaAttachments.addAll(draftEvent.imetas()) - - requiresNIP17 = draftEvent is NIP17Group - nip17 = draftEvent is NIP17Group } suspend fun sendPostSync() { @@ -401,18 +449,20 @@ class ChatNewMessageViewModel : val uploadState = uploadState ?: return accountViewModel.launchSigner { - if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.HOLD + }, + context, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded() } } } @@ -422,28 +472,97 @@ class ChatNewMessageViewModel : context: Context, onceUploaded: () -> Unit, ) { - val room = room ?: return + val room = room.value ?: return val uploadState = uploadState ?: return accountViewModel.launchSigner { - if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { - ChatFileSender(room, account).sendNIP17(it) - draftTag.newVersion() - onceUploaded() + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.SEND + pendingRetryOnError = onError + pendingRetryContext = context + pendingRetryOnceUploaded = onceUploaded + }, + context, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded() + } + } + } + + // Encrypted upload error state for retry dialog + var encryptedUploadErrorTitle by mutableStateOf(null) + var encryptedUploadErrorMessage by mutableStateOf(null) + var pendingRetryMode by mutableStateOf(null) + var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null) + var pendingRetryContext by mutableStateOf(null) + var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null) + + enum class RetryMode { HOLD, SEND } + + fun dismissEncryptedUploadError() { + encryptedUploadErrorTitle = null + encryptedUploadErrorMessage = null + pendingRetryMode = null + pendingRetryOnError = null + pendingRetryContext = null + pendingRetryOnceUploaded = null + } + + fun retryWithoutEncryption() { + val mode = pendingRetryMode ?: return + val onError = pendingRetryOnError + val context = pendingRetryContext + val onceUploaded = pendingRetryOnceUploaded + val room = room.value + val uploadState = uploadState + + dismissEncryptedUploadError() + + if (room == null || uploadState == null || context == null) return + + uploadState.encryptFiles = false + + accountViewModel.launchSigner { + when (mode) { + RetryMode.HOLD -> { + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded?.invoke() + } } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { - ChatFileSender(room, account).sendNIP04(it) - draftTag.newVersion() - onceUploaded() + + RetryMode.SEND -> { + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded?.invoke() + } } } } } private suspend fun innerSendPost(draftTag: String?) { - val room = room ?: return + val room = room.value ?: return val urls = findURLs(message.text) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -456,70 +575,49 @@ class ChatNewMessageViewModel : val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) { - val replyHint = replyTo.value?.toEventHint() + val replyHint = replyTo.value?.toEventHint() - val template = - if (replyHint == null) { - ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { - hashtags(findHashtags(message)) - references(findURLs(message)) - quotes(findNostrEventUris(message)) + val template = + if (replyHint == null) { + ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrEventUris(message)) - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - localExpirationDate?.let { expiration(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } else { - ChatMessageEvent.reply(message, replyHint) { - hashtags(findHashtags(message)) - references(findURLs(message)) - quotes(findNostrEventUris(message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - localExpirationDate?.let { expiration(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } - - if (draftTag != null) { - accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) - } else { - accountViewModel.account.sendNip17PrivateMessage(template) - } - } else { - val toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() } - - val template = - PrivateDmEvent.build( - toUser = toUser, - message = message, - imetas = usedAttachments, - replyingTo = replyTo.value?.toEventHint(), - signer = accountViewModel.account.signer, - ) { + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } localExpirationDate?.let { expiration(it) } - } - if (draftTag != null) { - accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) + emojis(emojis) + imetas(usedAttachments) + } } else { - accountViewModel.account.sendNip04PrivateMessage(template) + ChatMessageEvent.reply(message, replyHint) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrEventUris(message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + + emojis(emojis) + imetas(usedAttachments) + } } + + if (draftTag != null) { + accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) + } else { + accountViewModel.account.sendNip17PrivateMessage(template) } if (draftTag == null) { - ChatFileSender(room, accountViewModel.account).sendAll(uploadsWaitingToBeSent) + ChatFileSender(room, accountViewModel.account).sendNIP17(uploadsWaitingToBeSent) } } @@ -560,6 +658,9 @@ class ChatNewMessageViewModel : userSuggestionsMainMessage = null uploadsWaitingToBeSent = emptyList() + uploadState?.reset() + + dismissEncryptedUploadError() iMetaAttachments.reset() @@ -611,12 +712,10 @@ class ChatNewMessageViewModel : val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } if (users.isNullOrEmpty()) { - room = null - updateNIP17StatusFromRoom() + room.emit(null) } else { - if (users != room?.users) { - room = ChatroomKey(users) - updateNIP17StatusFromRoom() + if (users != room.value?.users) { + room.emit(ChatroomKey(users)) } } } @@ -649,9 +748,6 @@ class ChatNewMessageViewModel : val lastWord = toUsers.currentWord() toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) updateRoomFromUsersInput() - - val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm() - nip17 = relayList != null } userSuggestionsMainMessage = null @@ -691,11 +787,12 @@ class ChatNewMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - uploadState?.isUploadingImage != true && + uploadState?.mediaUploadTracker?.isUploading != true && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && - uploadState?.multiOrchestrator == null + uploadState?.multiOrchestrator == null && + recipientsMissingDmRelays.value.isEmpty() fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) @@ -707,15 +804,7 @@ class ChatNewMessageViewModel : Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") } - fun toggleNIP04And24() { - if (requiresNIP17) { - nip17 = true - } else { - nip17 = !nip17 - } - - draftTag.newVersion() - } + // NIP-04 sending is deprecated. NIP-17 is always used. override fun updateZapPercentage( index: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 3a2f1f8599..5d3a7ba1d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -197,7 +197,7 @@ fun NewGroupDMScreen( // function when the postViewModel is released accountViewModel.launchSigner { postViewModel.sendPostSync() - postViewModel.room?.let { + postViewModel.room.value?.let { nav.nav(routeToMessage(it, null, null, null, null, accountViewModel)) } } @@ -287,7 +287,8 @@ fun GroupDMScreenContent( ImageVideoDescription( selectedFiles, accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> + isUploading = uploading.mediaUploadTracker.isUploading, + onAdd = { alt, server, sensitiveContent, mediaQuality, _, _ -> postViewModel.uploadAndHold( accountViewModel.toastManager::toast, context, @@ -301,6 +302,15 @@ fun GroupDMScreenContent( ) } } + + postViewModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = postViewModel.encryptedUploadErrorMessage ?: "", + onDismiss = postViewModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = postViewModel::retryWithoutEncryption, + ) + } } } @@ -322,6 +332,11 @@ fun GroupDMScreenContent( ) } + val missingRelays by postViewModel.recipientsMissingDmRelays.collectAsStateWithLifecycle() + if (missingRelays.isNotEmpty()) { + RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav) + } + BottomRowActions(postViewModel, accountViewModel) } } @@ -383,9 +398,12 @@ private fun BottomRowActions( .height(50.dp), verticalAlignment = CenterVertically, ) { - if (postViewModel.room != null) { + val room by postViewModel.room.collectAsStateWithLifecycle() + + if (room != null) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -393,7 +411,8 @@ private fun BottomRowActions( } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -412,7 +431,7 @@ private fun BottomRowActions( } } - if (postViewModel.room != null) { + if (room != null) { TakePictureButton( onPictureTaken = { postViewModel.pickedMedia(it) }, ) @@ -431,7 +450,7 @@ private fun BottomRowActions( } } - if (postViewModel.room != null) { + if (room != null) { TakeVideoButton( onVideoTaken = { postViewModel.pickedMedia(it) }, ) @@ -538,8 +557,6 @@ fun SendDirectMessageTo( focusedBorderColor = Color.Transparent, ), ) - - ToggleNip17Button(postViewModel, accountViewModel) } HorizontalDivider(thickness = DividerThickness) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 2615ac2ec6..9ef4170ffc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -20,33 +20,51 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send -import android.R.attr.maxLines import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog @@ -59,9 +77,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.PostKeyboard +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Preview @Composable @@ -88,10 +110,12 @@ fun PrivateMessageEditFieldRow( nav: INav, ) { BackHandler { - accountViewModel.launchSigner { - channelScreenModel.sendDraftSync() - channelScreenModel.cancel() + if (channelScreenModel.message.text.isNotBlank()) { + accountViewModel.launchSigner { + channelScreenModel.sendDraftSync() + } } + channelScreenModel.cancel() nav.popBack() } @@ -114,6 +138,17 @@ fun PrivateMessageEditFieldRow( } } + StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation) + + channelScreenModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = channelScreenModel.encryptedUploadErrorMessage ?: "", + onDismiss = channelScreenModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption, + ) + } + Column( modifier = EditFieldModifier, ) { @@ -151,7 +186,12 @@ fun PrivateMessageEditFieldRow( } } - EditField(channelScreenModel, onSendNewMessage, accountViewModel) + val missingRelays by channelScreenModel.recipientsMissingDmRelays.collectAsStateWithLifecycle() + if (missingRelays.isNotEmpty()) { + RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav) + } else { + EditField(channelScreenModel, onSendNewMessage, accountViewModel) + } } } @@ -196,6 +236,90 @@ fun EditField( ) } +@Preview +@Composable +fun RecipientMissingRelaysWarningPreview() { + val user1 = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val user2 = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + + ThemeComparisonColumn { + RecipientMissingRelaysWarning( + persistentListOf(user1, user2), + mockAccountViewModel(), + EmptyNav(), + ) + } +} + +@Composable +fun RecipientMissingRelaysWarning( + users: ImmutableList, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy10dp, + ) { + UserGallery(users) { user -> + ClickableUserPicture( + user, + Size25dp, + accountViewModel, + onClick = { + nav.nav { routeFor(user) } + }, + ) + } + + Text( + text = stringRes(R.string.recipient_missing_dm_relays), + color = MaterialTheme.colorScheme.error, + fontSize = Font12SP, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +fun UserGallery( + users: ImmutableList, + galleryUser: @Composable RowScope.(user: User) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-10).dp), + ) { + users.take(6).forEach { + key(it.pubkeyHex) { + galleryUser(it) + } + } + + if (users.size > 6) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(Size25dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(users.size - 6), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + @Composable fun KeyboardLeadingIcon( channelScreenModel: ChatNewMessageViewModel, @@ -203,7 +327,7 @@ fun KeyboardLeadingIcon( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 4.dp, end = 10.dp), + modifier = Modifier.padding(start = 4.dp, end = 4.dp), ) { SelectFromGallery( isUploading = channelScreenModel.isUploadingImage, @@ -211,7 +335,38 @@ fun KeyboardLeadingIcon( modifier = Modifier, onImageChosen = channelScreenModel::pickedMedia, ) - - ToggleNip17Button(channelScreenModel, accountViewModel) } } + +@Composable +fun EncryptedUploadErrorDialog( + title: String, + message: String, + onDismiss: () -> Unit, + onRetryWithoutEncryption: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(message) + Text( + stringRes(R.string.upload_without_encryption_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + }, + confirmButton = { + TextButton(onClick = onRetryWithoutEncryption) { + Text(stringRes(R.string.retry_without_encryption)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt index 15c6aec34d..586d71f716 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt @@ -25,94 +25,25 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.launch @Composable -fun ToggleNip17Button( - channelScreenModel: ChatNewMessageViewModel, - accountViewModel: AccountViewModel, -) { - var wantsToActivateNIP17 by remember { mutableStateOf(false) } - - if (wantsToActivateNIP17) { - NewFeatureNIP17AlertDialog( - accountViewModel = accountViewModel, - onConfirm = { channelScreenModel.toggleNIP04And24() }, - onDismiss = { wantsToActivateNIP17 = false }, - ) - } - +fun Nip17Indicator(channelScreenModel: ChatNewMessageViewModel) { IconButton( modifier = Modifier.width(30.dp), - onClick = { - if ( - !accountViewModel.account.settings.hideNIP17WarningDialog && - !channelScreenModel.nip17 && - !channelScreenModel.requiresNIP17 - ) { - wantsToActivateNIP17 = true - } else { - channelScreenModel.toggleNIP04And24() - } - }, + onClick = { }, + enabled = false, ) { - if (channelScreenModel.nip17) { - Icon( - painter = painterRes(R.drawable.incognito, 2), - contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), - modifier = IncognitoIconButtonModifier, - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Icon( - painter = painterRes(R.drawable.incognito_off, 2), - contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message), - modifier = IncognitoIconButtonModifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } + Icon( + painter = painterRes(R.drawable.incognito, 2), + contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), + modifier = IncognitoIconButtonModifier, + tint = MaterialTheme.colorScheme.primary, + ) } } - -@Composable -fun NewFeatureNIP17AlertDialog( - accountViewModel: AccountViewModel, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - val scope = rememberCoroutineScope() - - QuickActionAlertDialog( - title = stringRes(R.string.new_feature_nip17_might_not_be_available_title), - textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description), - buttonIconResource = R.drawable.incognito, - buttonIconReference = 3, - buttonText = stringRes(R.string.new_feature_nip17_activate), - onClickDoOnce = { - scope.launch { onConfirm() } - onDismiss() - }, - onClickDontShowAgain = { - scope.launch { - onConfirm() - accountViewModel.account.settings.setHideNIP17WarningDialog() - } - onDismiss() - }, - onDismiss = onDismiss, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 7ec7bd2453..4337bffa82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -24,11 +24,13 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.ciphers.AESGCM class ChatFileSender( @@ -39,6 +41,8 @@ class ChatFileSender( uploads.forEach { if (it.cipher != null) { sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) + } else { + sendNIP17AsHiddenLink(it.result, it.caption, it.contentWarningReason) } } } @@ -70,19 +74,7 @@ class ChatFileSender( ) } - // ------ - // NIP 04 - // ------ - - suspend fun sendNIP04(uploads: List) { - uploads.forEach { - if (it.cipher == null) { - sendNIP04(it.result, it.caption, it.contentWarningReason) - } - } - } - - suspend fun sendNIP04( + suspend fun sendNIP17AsHiddenLink( result: UploadOrchestrator.OrchestratorResult.ServerResult, caption: String?, contentWarningReason: String?, @@ -90,27 +82,24 @@ class ChatFileSender( val iMetaAttachments = IMetaAttachments() iMetaAttachments.add(result, caption, contentWarningReason) - val toUser = chatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() } + val toUsers = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() } val template = - PrivateDmEvent.build( - toUser = toUser, - message = result.url, - imetas = iMetaAttachments.iMetaAttachments, - replyingTo = null, - signer = account.signer, - ) + ChatMessageEvent.build(result.url, toUsers) { + references(listOf(result.url)) - account.sendNip04PrivateMessage(template) + if (!caption.isNullOrEmpty()) { + alt(caption) + } + contentWarningReason?.let { contentWarning(it) } + + imetas(iMetaAttachments.filterIsIn(setOf(result.url))) + } + + account.sendNip17PrivateMessage(template) } suspend fun sendAll(uploads: List) { - uploads.forEach { - if (it.cipher != null) { - sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) - } else { - sendNIP04(it.result, it.caption, it.contentWarningReason) - } - } + sendNIP17(uploads) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 1dca30b9fd..da5dbbd4bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -46,7 +47,8 @@ fun RoomChatFileUploadDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val room = channelScreenModel.room ?: return + val roomState = channelScreenModel.room.collectAsStateWithLifecycle() + val room = roomState.value ?: return val context = LocalContext.current ChatFileUploadDialog( @@ -70,9 +72,11 @@ fun RoomChatFileUploadDialog( ) accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) }, onCancel, accountViewModel, nav, + isNip17 = true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index ddc0018055..bee033b79b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -39,58 +39,66 @@ class ChatFileUploader( suspend fun justUploadNIP17( viewState: ChatFileUploadState, onError: (title: String, message: String) -> Unit, + onEncryptedUploadError: (title: String, message: String) -> Unit, context: Context, + onStrippingFailed: suspend () -> Boolean = { true }, onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return - viewState.isUploadingImage = true + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) - val cipher = AESGCM() + if (viewState.encryptFiles) { + val cipher = AESGCM() - val results = - orchestrator.uploadEncrypted( - viewState.caption, - viewState.contentWarningReason, - MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), - cipher, - viewState.selectedServer, - account, - context, - ) + val results = + orchestrator.uploadEncrypted( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + cipher, + viewState.selectedServer, + account, + context, + stripMetadata = viewState.stripMetadata, + onStrippingFailed = onStrippingFailed, + ) - if (results.allGood) { - val list = - results.successful.mapNotNull { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) - } else { - null + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) + } else { + null + } } - } - onceUploaded(list) - viewState.reset() + onceUploaded(list) + viewState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onEncryptedUploadError( + stringRes(context, R.string.failed_to_upload_encrypted_media_title), + stringRes(context, R.string.failed_to_upload_encrypted_media_message) + "\n\n" + errorMessages.joinToString(".\n"), + ) + } } 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")) + justUploadNIP17Unencrypted(viewState, onError, context, onStrippingFailed, onceUploaded) } - viewState.isUploadingImage = false + viewState.mediaUploadTracker.finishUpload() } - // ------ - // NIP 04 - // ------ - - suspend fun justUploadNIP04( + suspend fun justUploadNIP17Unencrypted( viewState: ChatFileUploadState, onError: (title: String, message: String) -> Unit, context: Context, + onStrippingFailed: suspend () -> Boolean = { true }, onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return - viewState.isUploadingImage = true + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) val results = orchestrator.upload( @@ -100,6 +108,8 @@ class ChatFileUploader( viewState.selectedServer, account, context, + stripMetadata = viewState.stripMetadata, + onStrippingFailed = onStrippingFailed, ) if (results.allGood) { @@ -120,6 +130,53 @@ class ChatFileUploader( onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - viewState.isUploadingImage = false + viewState.mediaUploadTracker.finishUpload() + } + + // ------ + // NIP 04 + // ------ + + suspend fun justUploadNIP04( + viewState: ChatFileUploadState, + onError: (title: String, message: String) -> Unit, + context: Context, + onStrippingFailed: suspend () -> Boolean = { true }, + onceUploaded: suspend (List) -> Unit, + ) { + val orchestrator = viewState.multiOrchestrator ?: return + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) + + val results = + orchestrator.upload( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + viewState.selectedServer, + account, + context, + stripMetadata = viewState.stripMetadata, + onStrippingFailed = onStrippingFailed, + ) + + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null) + } else { + null + } + } + + onceUploaded(list) + viewState.reset() + } 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")) + } + + viewState.mediaUploadTracker.finishUpload() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 310d5044a6..30d9b0e967 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType @@ -104,7 +105,7 @@ class ChannelMetadataViewModel : ViewModel() { fun createOrUpdate(onDone: (PublicChatChannel) -> Unit) { viewModelScope.launch(Dispatchers.IO) { - account?.let { account -> + account.let { account -> val channel = originalChannel if (channel == null) { val template = @@ -204,10 +205,25 @@ class ChannelMetadataViewModel : ViewModel() { onUploaded: (String) -> Unit, onError: (String, String) -> Unit, ) { - val account = account ?: return + val account = account onUploading(true) - val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + val sourceUri = + if (account.settings.stripLocationOnUpload) { + val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) + if (!result.stripped) { + onError( + stringRes(context, R.string.metadata_strip_failed_title), + stringRes(context, R.string.metadata_strip_failed_upload_cancelled), + ) + onUploading(false) + return + } + result.uri + } else { + galleryUri.uri + } + val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) try { val result = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 2ef6776d83..8c8b383432 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -131,6 +131,7 @@ fun ChannelFileUploadDialog( ) accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) }, onCancel, accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 98b36ef0eb..489a744f88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -129,12 +130,17 @@ open class ChannelNewMessageViewModel : val replyTo = mutableStateOf(null) var uploadState by mutableStateOf(null) + + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) - var isUploadingImage by mutableStateOf(false) + val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false + val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -185,7 +191,7 @@ open class ChannelNewMessageViewModel : this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) - this.uploadState = ChatFileUploadState(account.settings.defaultFileServer) + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) } open fun load(channel: Channel) { @@ -337,7 +343,7 @@ open class ChannelNewMessageViewModel : val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch - isUploadingImage = true + uploadState.mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -347,6 +353,8 @@ open class ChannelNewMessageViewModel : uploadState.selectedServer, account, context, + stripMetadata = uploadState.stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { @@ -378,7 +386,7 @@ open class ChannelNewMessageViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + uploadState.mediaUploadTracker.finishUpload() } } @@ -546,6 +554,8 @@ open class ChannelNewMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null + uploadState?.reset() + iMetaAttachments.reset() emojiSuggestions?.reset() @@ -632,7 +642,7 @@ open class ChannelNewMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - uploadState?.isUploadingImage != true && + uploadState?.mediaUploadTracker?.isUploading != true && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && uploadState?.multiOrchestrator == null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 1e66b8f753..ddf1ad2d4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -70,6 +71,8 @@ fun EditFieldRow( nav.popBack() } + StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation) + channelScreenModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { channelScreenModel.clearReply() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 9f48375a48..8b5961be5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -28,8 +28,9 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.MoveToInbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -48,6 +49,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +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.zonedDrawerSwipe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -102,12 +107,13 @@ fun MessagesTabHeader( contentDescription = stringRes(id = R.string.more_options), tint = MaterialTheme.colorScheme.placeholderText, ) + } - MessagesTabMenu( - moreActionsExpanded, - { moreActionsExpanded = false }, - onMarkKnownAsRead, - onMarkNewAsRead, + if (moreActionsExpanded) { + MessagesMarkAsReadDialog( + onDismiss = { moreActionsExpanded = false }, + onMarkKnownAsRead = onMarkKnownAsRead, + onMarkNewAsRead = onMarkNewAsRead, ) } } @@ -124,7 +130,12 @@ fun MessagesPager( HorizontalPager( contentPadding = paddingValues, state = pagerState, - userScrollEnabled = false, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, @@ -136,34 +147,38 @@ fun MessagesPager( } @Composable -fun MessagesTabMenu( - expanded: Boolean, +fun MessagesMarkAsReadDialog( onDismiss: () -> Unit, onMarkKnownAsRead: () -> Unit, onMarkNewAsRead: () -> Unit, ) { - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_known_as_read)) }, - onClick = { + M3ActionDialog( + title = stringRes(R.string.mark_as_read_dialog_title), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.Groups, + text = stringRes(R.string.mark_all_known_as_read), + ) { onMarkKnownAsRead() onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_new_as_read)) }, - onClick = { + } + M3ActionRow( + icon = Icons.Outlined.MoveToInbox, + text = stringRes(R.string.mark_all_new_as_read), + ) { onMarkNewAsRead() onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_as_read)) }, - onClick = { + } + M3ActionRow( + icon = Icons.Outlined.DoneAll, + text = stringRes(R.string.mark_all_as_read), + ) { onMarkKnownAsRead() onMarkNewAsRead() onDismiss() - }, - ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index f1ce975257..d1d7e98b8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -81,6 +81,7 @@ fun ChatFileUploadDialog( onCancel: () -> Unit, accountViewModel: AccountViewModel, nav: INav, + isNip17: Boolean = false, ) { val scrollState = rememberScrollState() @@ -132,7 +133,7 @@ fun ChatFileUploadDialog( ) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { - ImageVideoPostChat(state, accountViewModel) + ImageVideoPostChat(state, accountViewModel, isNip17) } } } @@ -144,6 +145,7 @@ fun ChatFileUploadDialog( private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, + isNip17: Boolean = false, ) { val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() @@ -190,6 +192,16 @@ private fun ImageVideoPostChat( onCheckedChange = fileUploadState::updateContentWarning, ) + if (isNip17) { + SettingSwitchItem( + title = R.string.encrypt_files_label, + description = R.string.encrypt_files_description, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + checked = fileUploadState.encryptFiles, + onCheckedChange = { fileUploadState.encryptFiles = it }, + ) + } + SettingsRow(R.string.file_server, R.string.file_server_description) { TextSpinner( label = "", @@ -222,28 +234,40 @@ private fun ImageVideoPostChat( ) } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box(modifier = Modifier.fillMaxWidth()) { - Text( - text = - when (fileUploadState.mediaQualitySlider) { - 0 -> stringRes(R.string.media_compression_quality_low) - 1 -> stringRes(R.string.media_compression_quality_medium) - 2 -> stringRes(R.string.media_compression_quality_high) - 3 -> stringRes(R.string.media_compression_quality_uncompressed) - else -> stringRes(R.string.media_compression_quality_medium) - }, - modifier = Modifier.align(Alignment.Center), + val firstMedia = fileUploadState.multiOrchestrator?.first()?.media + + if (firstMedia?.isVideo() == true || firstMedia?.isImage() == true) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = + when (fileUploadState.mediaQualitySlider) { + 0 -> stringRes(R.string.media_compression_quality_low) + 1 -> stringRes(R.string.media_compression_quality_medium) + 2 -> stringRes(R.string.media_compression_quality_high) + 3 -> stringRes(R.string.media_compression_quality_uncompressed) + else -> stringRes(R.string.media_compression_quality_medium) + }, + modifier = Modifier.align(Alignment.Center), + ) + } + + Slider( + value = fileUploadState.mediaQualitySlider.toFloat(), + onValueChange = { fileUploadState.mediaQualitySlider = it.toInt() }, + valueRange = 0f..3f, + steps = 2, ) } - - Slider( - value = fileUploadState.mediaQualitySlider.toFloat(), - onValueChange = { fileUploadState.mediaQualitySlider = it.toInt() }, - valueRange = 0f..3f, - steps = 2, - ) } + + SettingSwitchItem( + title = R.string.strip_metadata_label, + description = R.string.strip_metadata_description, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + checked = fileUploadState.stripMetadata, + onCheckedChange = { fileUploadState.stripMetadata = it }, + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 9475afa739..07482aac74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import kotlinx.collections.immutable.ImmutableList @@ -35,8 +36,11 @@ import kotlinx.collections.immutable.ImmutableList @Stable class ChatFileUploadState( val defaultServer: ServerName, + defaultStripMetadata: Boolean = true, ) { - var isUploadingImage by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var selectedServer by mutableStateOf(defaultServer) var caption by mutableStateOf("") @@ -52,6 +56,9 @@ class ChatFileUploadState( // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED var mediaQualitySlider by mutableIntStateOf(1) + var stripMetadata by mutableStateOf(defaultStripMetadata) + var encryptFiles by mutableStateOf(true) + fun load(uris: ImmutableList) { reset() this.multiOrchestrator = MultiOrchestrator(uris) @@ -64,16 +71,17 @@ class ChatFileUploadState( fun reset() { multiOrchestrator = null - isUploadingImage = false + mediaUploadTracker.finishUpload() caption = "" selectedServer = defaultServer + encryptFiles = true } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { multiOrchestrator?.remove(selected) } - fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null + fun canPost(): Boolean = !mediaUploadTracker.isUploading && multiOrchestrator != null fun hasPickedMedia() = multiOrchestrator != null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index ad9ad0e22e..96a2502ebf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -297,8 +297,10 @@ fun ChessLobbyContent( val userPubkey = accountViewModel.account.userProfile().pubkeyHex val hasContent = - activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || - publicGames.isNotEmpty() || challenges.isNotEmpty() + activeGames.isNotEmpty() || + spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || + challenges.isNotEmpty() if (!hasContent) { // Empty state - use LazyColumn so pull-to-refresh works diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt index dafcd75308..e0561e6eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -42,6 +43,7 @@ val CommunityPostKinds = ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, CommunityPostApprovalEvent.KIND, + AttestationEvent.KIND, ) fun filterCommunityPosts( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 47fae49aaf..b148a0f174 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -137,6 +138,8 @@ fun NewProductScreen( ) { WatchAndLoadMyEmojiList(accountViewModel) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() @@ -269,8 +272,9 @@ private fun NewProductBody( ImageVideoDescription( uris = it, defaultServer = accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + isUploading = postViewModel.mediaUploadTracker.isUploading, + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, @@ -364,6 +368,7 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -371,7 +376,8 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index be86ac1511..2908f5a793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -42,9 +42,11 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -133,7 +135,9 @@ open class NewProductViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -143,6 +147,9 @@ open class NewProductViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + // Classifieds var title by mutableStateOf(TextFieldValue("")) var price by mutableStateOf(TextFieldValue("")) @@ -217,7 +224,7 @@ open class NewProductViewModel : } open fun quote(quote: Note) { - val accountViewModel = accountViewModel ?: return + val accountViewModel = accountViewModel message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") @@ -304,7 +311,6 @@ open class NewProductViewModel : } suspend fun sendPostSync() { - val accountViewModel = accountViewModel ?: return val template = createTemplate() ?: return val version = draftTag.current @@ -317,8 +323,6 @@ open class NewProductViewModel : } suspend fun sendDraftSync() { - val accountViewModel = accountViewModel ?: return - if (message.text.isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { @@ -328,7 +332,7 @@ open class NewProductViewModel : } private suspend fun createTemplate(): EventTemplate? { - val accountViewModel = accountViewModel ?: return null + val accountViewModel = accountViewModel val tagger = NewMessageTagger( @@ -337,7 +341,7 @@ open class NewProductViewModel : ) tagger.run() - val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value) + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) val urls = findURLs(tagger.message) val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } @@ -394,12 +398,13 @@ open class NewProductViewModel : server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, + stripMetadata: Boolean = true, ) { viewModelScope.launch(Dispatchers.IO) { - val myAccount = account ?: return@launch + val myAccount = account val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -409,6 +414,8 @@ open class NewProductViewModel : server, myAccount, context, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { @@ -444,7 +451,7 @@ open class NewProductViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -454,7 +461,7 @@ open class NewProductViewModel : message = TextFieldValue("") multiOrchestrator = null - isUploadingImage = false + mediaUploadTracker.finishUpload() wantsInvoice = false wantsZapraiser = false @@ -498,8 +505,8 @@ open class NewProductViewModel : this.multiOrchestrator?.remove(selected) } - override fun updateMessage(it: TextFieldValue) { - message = it + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage urlPreviews.update(message) if (message.selection.collapsed) { @@ -575,7 +582,7 @@ open class NewProductViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - !isUploadingImage && + !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && title.text.isNotBlank() && @@ -613,7 +620,7 @@ open class NewProductViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 716b4545a3..8d6df52e0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -31,9 +29,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -55,7 +57,8 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -72,6 +75,7 @@ fun DraftListScreen( RenderDraftListScreen(accountViewModel.feedStates.drafts, accountViewModel, nav) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RenderDraftListScreen( feedState: FeedContentState, @@ -80,10 +84,67 @@ private fun RenderDraftListScreen( ) { WatchLifecycleAndUpdateModel(feedState) + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { + showDeleteDialog = false + }, + title = { + Text(text = stringResource(R.string.drafts)) + }, + text = { + Text(text = stringResource(R.string.delete_all_drafts_confirmation)) + }, + confirmButton = { + TextButton( + onClick = { + val currentState = feedState.feedContent.value + if (currentState is FeedState.Loaded) { + accountViewModel.delete(currentState.feed.value.list) + } + showDeleteDialog = false + }, + ) { + Text(text = stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton( + onClick = { + showDeleteDialog = false + }, + ) { + Text(text = stringResource(R.string.no)) + } + }, + ) + } + DisappearingScaffold( isInvertedLayout = false, topBar = { - TopBarWithBackButton(stringRes(id = R.string.drafts), nav::popBack) + ShorterTopAppBar( + title = { + Text( + text = stringRes(id = R.string.drafts), + ) + }, + navigationIcon = { + IconButton(onClick = nav::popBack) { + ArrowBackIcon() + } + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete_all), + ) + } + }, + ) }, accountViewModel = accountViewModel, ) { @@ -104,7 +165,6 @@ private fun RenderDraftListScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun DraftFeedLoaded( loaded: FeedState.Loaded, @@ -114,60 +174,12 @@ private fun DraftFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() - var showDeleteDialog by remember { mutableStateOf(false) } - - if (showDeleteDialog) { - AlertDialog( - onDismissRequest = { - showDeleteDialog = false - }, - title = { - Text(text = stringResource(R.string.drafts)) - }, - text = { - Text(text = stringResource(R.string.delete_all_drafts_confirmation)) - }, - confirmButton = { - TextButton( - onClick = { - accountViewModel.delete(items.list) - showDeleteDialog = false - }, - ) { - Text(text = stringResource(R.string.yes)) - } - }, - dismissButton = { - TextButton( - onClick = { - showDeleteDialog = false - }, - ) { - Text(text = stringResource(R.string.no)) - } - }, - ) - } - LazyColumn( contentPadding = FeedPadding, state = listState, ) { - stickyHeader { - Row( - Modifier - .fillMaxWidth(), - Arrangement.Center, - ) { - ElevatedButton( - onClick = { showDeleteDialog = true }, - ) { - Text(stringResource(R.string.delete_all)) - } - } - } itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - Row(Modifier.fillMaxWidth().animateItem()) { + Row(Modifier.fillMaxWidth()) { SwipeToDeleteWithConfirmation( modifier = Modifier.fillMaxWidth().animateContentSize(), onDelete = { accountViewModel.delete(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 06cd8d9272..b7268d4736 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt index 6571ea5145..5aa7ccc441 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -102,7 +102,7 @@ class FollowPackFeedConversationsFeedFilter( ): Boolean = ( event is TextNoteEvent || - event is PollNoteEvent || + event is ZapPollEvent || event is ChannelMessageEvent || event is CommentEvent || event is VoiceReplyEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index 15e043a7a3..3b97682e7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -130,7 +130,7 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is GenericRepostEvent || (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || - noteEvent is PollNoteEvent || + noteEvent is ZapPollEvent || noteEvent is HighlightEvent || noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index b00fbc9a11..723b1a4373 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash @@ -77,7 +77,7 @@ class GeoHashFeedFilter( event is WikiNoteEvent || event is ChannelMessageEvent || event is PrivateDmEvent || - event is PollNoteEvent || + event is ZapPollEvent || event is AudioHeaderEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 913be738da..4c02ec0a5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -43,7 +43,7 @@ val PostsByGeohashKinds = ChannelMessageEvent.KIND, LongTextNoteEvent.KIND, PollEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioTrackEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 9a95a0e46b..f5fc6b4974 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash @@ -81,9 +81,10 @@ class HashtagFeedFilter( event is WikiNoteEvent || event is ChannelMessageEvent || event is PrivateDmEvent || - event is PollNoteEvent || + event is ZapPollEvent || event is AudioHeaderEvent - ) && event.isTaggedHash(hashTag) + ) && + event.isTaggedHash(hashTag) fun acceptableViaScope( event: Event?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 69bbeed3d9..3c4528c715 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -61,7 +61,7 @@ val PostsByHashtagKinds2 = AudioTrackEvent.KIND, AudioHeaderEvent.KIND, NipTextEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, ) fun filterPostsByHashtags( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 86891c7fe9..feaeb15436 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys @@ -211,7 +212,12 @@ private fun HomePages( HorizontalPager( contentPadding = it, state = pagerState, - userScrollEnabled = false, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), ) { page -> HomeFeeds( feedState = tabs[page].feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 73a722af69..74d9f3434f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -60,6 +60,7 @@ import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton @@ -94,15 +95,18 @@ import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiBut import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zappolls.ZapPollField import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size19Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage @@ -178,6 +182,8 @@ private fun NewPostScreenInner( ) { WatchAndLoadMyEmojiList(accountViewModel) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() @@ -298,6 +304,15 @@ private fun NewPostScreenBody( } } + if (postViewModel.wantsZapPoll) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapPollField(postViewModel) + } + } + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { @@ -355,8 +370,9 @@ private fun NewPostScreenBody( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) + isUploading = postViewModel.mediaUploadTracker.isUploading, + onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, @@ -498,6 +514,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -505,7 +522,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -532,11 +550,24 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { ) if (postViewModel.canUsePoll) { - // These should be hashtag recommendations the user selects in the future. - // val hashtag = stringRes(R.string.poll_hashtag) - // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) AddPollButton(postViewModel.wantsPoll) { postViewModel.wantsPoll = !postViewModel.wantsPoll + if (postViewModel.wantsPoll) { + if (postViewModel.wantsZapPoll) { + postViewModel.wantsZapPoll = false + } + } + } + } + + if (postViewModel.canUseZapPoll) { + AddZapPollButton(postViewModel.wantsZapPoll) { + postViewModel.wantsZapPoll = !postViewModel.wantsZapPoll + if (postViewModel.wantsZapPoll) { + if (postViewModel.wantsPoll) { + postViewModel.wantsPoll = false + } + } } } @@ -585,6 +616,32 @@ private fun BottomRowActionsPreview() { } } +@Composable +private fun AddZapPollButton( + isPollActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isPollActive) { + Icon( + painter = painterRes(R.drawable.ic_poll, 1), + contentDescription = stringRes(id = R.string.poll), + modifier = Size19Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + painter = painterRes(R.drawable.ic_poll, 1), + contentDescription = stringRes(id = R.string.disable_poll), + modifier = Size19Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 422394b5bc..2bbded69bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -45,10 +45,12 @@ import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing @@ -70,6 +72,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.experimental.zapPolls.closedAt +import com.vitorpamplona.quartz.experimental.zapPolls.consensusThreshold +import com.vitorpamplona.quartz.experimental.zapPolls.maxAmount +import com.vitorpamplona.quartz.experimental.zapPolls.minAmount +import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -178,7 +186,9 @@ open class ShortNotePostViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -188,6 +198,9 @@ open class ShortNotePostViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + // Voice Messages var voiceRecording by mutableStateOf(null) var voiceLocalFile by mutableStateOf(null) @@ -227,6 +240,20 @@ open class ShortNotePostViewModel : var pollOptions: SnapshotStateMap = newStateMapPollOptions() var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead()) + // ZapPolls + var canUseZapPoll by mutableStateOf(false) + var wantsZapPoll by mutableStateOf(false) + var zapPollOptions: SnapshotStateMap = newStateMapZapPollOptions() + var zapPollValueMaximum by mutableStateOf(null) + var zapPollValueMinimum by mutableStateOf(null) + var zapPollConsensusThreshold: Int? = null + var zapPollClosedAt by mutableLongStateOf(TimeUtils.oneDayAhead()) + + var isValidValueMaximum = mutableStateOf(true) + var isValidValueMinimum = mutableStateOf(true) + var isValidConsensusThreshold = mutableStateOf(true) + var isValidClosedAt = mutableStateOf(true) + // Invoices var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) @@ -330,6 +357,7 @@ open class ShortNotePostViewModel : canAddInvoice = user.lnAddress() != null canAddZapRaiser = user.lnAddress() != null canUsePoll = originalNote == null + canUseZapPoll = originalNote == null multiOrchestrator = null quote?.let { quotedNote -> @@ -414,6 +442,10 @@ open class ShortNotePostViewModel : if (draftEvent is PollEvent) { loadFromDraft(draftEvent) } + + if (draftEvent is ZapPollEvent) { + loadFromDraft(draftEvent) + } } private fun loadFromDraft(draftEvent: TextNoteEvent) { @@ -486,12 +518,14 @@ open class ShortNotePostViewModel : } canUsePoll = originalNote == null + canUseZapPoll = originalNote == null if (forwardZapTo.value.items.isNotEmpty()) { wantsForwardZapTo = true } wantsPoll = false + wantsZapPoll = false message = TextFieldValue(draftEvent.content) @@ -547,11 +581,14 @@ open class ShortNotePostViewModel : } canUsePoll = originalNote == null + canUseZapPoll = originalNote == null if (forwardZapTo.value.items.isNotEmpty()) { wantsForwardZapTo = true } + wantsZapPoll = false + val polls = draftEvent.options() wantsPoll = polls.isNotEmpty() @@ -568,6 +605,80 @@ open class ShortNotePostViewModel : urlPreviews.update(message) } + private fun loadFromDraft(draftEvent: ZapPollEvent) { + canAddInvoice = accountViewModel.userProfile().lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null + multiOrchestrator = null + + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" + + val draftExpiration = draftEvent.tags.expiration() + wantsExpirationDate = draftExpiration != null + expirationDate = draftExpiration ?: TimeUtils.oneDayAhead() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + if (geohash != null) { + wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND + } + + val zapRaiser = draftEvent.zapraiserAmount() + wantsZapRaiser = zapRaiser != null + zapRaiserAmount.value = null + if (zapRaiser != null) { + zapRaiserAmount.value = zapRaiser + } + + eTags = + draftEvent.tags.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull { + val note = LocalCache.checkGetOrCreateNote(it[1]) + note + } + + pTags = + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { + LocalCache.getOrCreateUser(it[1]) + } + + canUsePoll = originalNote == null + canUseZapPoll = originalNote == null + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + wantsPoll = false + + val polls = draftEvent.pollOptionsArray() + wantsZapPoll = polls.isNotEmpty() + + polls.forEach { tag -> + zapPollOptions[tag.index] = tag.descriptor + } + + zapPollValueMinimum = draftEvent.minAmount() + zapPollValueMaximum = draftEvent.maxAmount() + zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() } + zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead() + + message = TextFieldValue(draftEvent.content) + + iMetaAttachments.addAll(draftEvent.imetas()) + + urlPreviews.update(message) + } + suspend fun sendPostSync() { // Upload voice message first if it hasn't been uploaded yet if (voiceRecording != null && voiceMetadata == null) { @@ -728,6 +839,35 @@ open class ShortNotePostViewModel : contentWarningReason?.let { contentWarning(it) } localExpirationDate?.let { expiration(it) } + emojis(emojis) + imetas(usedAttachments) + } + } else if (wantsZapPoll) { + val options = zapPollOptions.map { PollOptionTag(it.key, it.value) } + + if (options.isEmpty()) return null + + val quotes = findNostrUris(tagger.message) + val relays = + accountViewModel.account.nip65RelayList.outboxFlow.value + .toList() + + ZapPollEvent.build(tagger.message, options) { + zapPollValueMinimum?.let { minAmount(it) } + zapPollValueMaximum?.let { maxAmount(it) } + zapPollClosedAt?.let { closedAt(it) } + zapPollConsensusThreshold?.let { consensusThreshold(it / 100.0) } + + pTags(tagger.directMentionsUsers.map { it.toPTag() }) + quotes(quotes) + hashtags(findHashtags(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + emojis(emojis) imetas(usedAttachments) } @@ -808,8 +948,9 @@ open class ShortNotePostViewModel : onError: (title: String, message: String) -> Unit, context: Context, useH265: Boolean, + stripMetadata: Boolean = true, ) = try { - uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265) + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata) } catch (_: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -825,11 +966,12 @@ open class ShortNotePostViewModel : onError: (title: String, message: String) -> Unit, context: Context, useH265: Boolean, + stripMetadata: Boolean = true, ) { viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -840,6 +982,8 @@ open class ShortNotePostViewModel : account, context, useH265, + stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { @@ -885,7 +1029,7 @@ open class ShortNotePostViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -897,7 +1041,7 @@ open class ShortNotePostViewModel : forkedFromNote = null multiOrchestrator = null - isUploadingImage = false + mediaUploadTracker.finishUpload() voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = null @@ -912,6 +1056,13 @@ open class ShortNotePostViewModel : pollOptions = newStateMapPollOptions() closedAt = TimeUtils.oneDayAhead() + wantsZapPoll = false + zapPollOptions = newStateMapZapPollOptions() + zapPollValueMaximum = null + zapPollValueMinimum = null + zapPollConsensusThreshold = null + zapPollClosedAt = TimeUtils.oneDayAhead() + wantsInvoice = false wantsZapRaiser = false zapRaiserAmount.value = null @@ -1029,25 +1180,40 @@ open class ShortNotePostViewModel : 1 to OptionTag(RandomInstance.randomChars(6), ""), ) + private fun newStateMapZapPollOptions(): SnapshotStateMap = + mutableStateMapOf( + 0 to "", + 1 to "", + ) + fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) if (voiceMetadata != null || voiceRecording != null) { - return !isUploadingVoice && !isUploadingImage && processingPreset == null + return !isUploadingVoice && !mediaUploadTracker.isUploading && processingPreset == null } // Regular text/media posts require text return message.text.isNotBlank() && - !isUploadingImage && + !mediaUploadTracker.isUploading && !isUploadingVoice && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && ( !wantsPoll || ( - pollOptions.isNotEmpty() && pollOptions.all { it.value.label.isNotEmpty() } && + pollOptions.isNotEmpty() && + pollOptions.all { it.value.label.isNotEmpty() } && closedAt > TimeUtils.oneMinuteFromNow() ) ) && + ( + !wantsZapPoll || + ( + zapPollOptions.values.all { it.isNotEmpty() } && + isValidValueMinimum.value && + isValidValueMaximum.value + ) + ) && multiOrchestrator == null } @@ -1250,6 +1416,61 @@ open class ShortNotePostViewModel : this.putAll(newEntries) } + // --- + // Zap Polls + // --- + + fun updateMinZapAmountForPoll(textMin: String) { + zapPollValueMinimum = textMin.toLongOrNull()?.takeIf { it > 0 } + checkMinMax() + draftTag.newVersion() + } + + fun updateMaxZapAmountForPoll(textMax: String) { + zapPollValueMaximum = textMax.toLongOrNull()?.takeIf { it > 0 } + checkMinMax() + draftTag.newVersion() + } + + fun checkMinMax() { + if ((zapPollValueMinimum ?: 0) > (zapPollValueMaximum ?: Long.MAX_VALUE)) { + isValidValueMinimum.value = false + isValidValueMaximum.value = false + } else { + isValidValueMinimum.value = true + isValidValueMaximum.value = true + } + } + + fun removeZapPollOption(optionIndex: Int) { + zapPollOptions.removeOrderedZapPoll(optionIndex) + draftTag.newVersion() + } + + private fun MutableMap.removeOrderedZapPoll(index: Int) { + val keyList = keys + val elementList = values.toMutableList() + run stop@{ + for (i in index until elementList.size) { + val nextIndex = i + 1 + if (nextIndex == elementList.size) return@stop + elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } + } + } + elementList.removeAt(elementList.size - 1) + val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) } + this.clear() + this.putAll(newEntries) + } + + fun updateZapPollOption( + optionIndex: Int, + text: String, + ) { + zapPollOptions[optionIndex] = text + draftTag.newVersion() + } + fun toggleMarkAsSensitive() { wantsToMarkAsSensitive = !wantsToMarkAsSensitive draftTag.newVersion() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index 53384e1dd7..82a368184b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -81,7 +81,7 @@ class HomeConversationsFeedFilter( ): Boolean = ( event is TextNoteEvent || - event is PollNoteEvent || + event is ZapPollEvent || event is PollResponseEvent || event is ChannelMessageEvent || event is CommentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index fc4193fe83..d5c134ba46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -29,10 +29,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -60,6 +64,7 @@ class HomeNewThreadFeedFilter( LongTextNoteEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND, + AttestationEvent.KIND, ) } @@ -116,7 +121,7 @@ class HomeNewThreadFeedFilter( noteEvent is GenericRepostEvent || (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || - noteEvent is PollNoteEvent || + noteEvent is ZapPollEvent || noteEvent is PollEvent || noteEvent is HighlightEvent || noteEvent is InteractiveStoryPrologueEvent || @@ -126,7 +131,11 @@ class HomeNewThreadFeedFilter( noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || noteEvent is LiveChessGameChallengeEvent || - noteEvent is LiveChessGameEndEvent + noteEvent is LiveChessGameEndEvent || + noteEvent is AttestationEvent || + noteEvent is AttestationRequestEvent || + noteEvent is AttestorRecommendationEvent || + noteEvent is AttestorProficiencyEvent ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt index 4f52a00006..4c19d4939c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -36,7 +37,6 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent val HomePostsByGeohashKinds = listOf( @@ -46,11 +46,11 @@ val HomePostsByGeohashKinds = LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, CommentEvent.KIND, VoiceEvent.KIND, - VoiceReplyEvent.KIND, + AttestationEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) fun filterHomePostsByGeohashes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt index 68c788650d..c47f7c94b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt @@ -23,7 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1 +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds2 import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -43,11 +44,20 @@ fun filterHomePostsByGlobal( relay = relayUrl, filter = Filter( - kinds = HomePostsNewThreadKinds, + kinds = HomePostsNewThreadKinds1, limit = 50, since = since ?: newThreadSince, ), ), + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsNewThreadKinds2, + limit = 5, + since = since ?: newThreadSince, + ), + ), RelayBasedFilter( relay = relayUrl, filter = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt index b5f02d5f67..d3542f77c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent @@ -39,7 +40,6 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent val HomePostsBuHashtagsKinds = listOf( @@ -50,12 +50,12 @@ val HomePostsBuHashtagsKinds = ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioHeaderEvent.KIND, - InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, VoiceEvent.KIND, - VoiceReplyEvent.KIND, + AttestationEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) fun filterHomePostsByHashtags( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index e6c9c2cfb5..cd65087039 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -23,10 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follo import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -50,7 +51,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import kotlin.math.min -val HomePostsNewThreadKinds = +val HomePostsNewThreadKinds1 = listOf( TextNoteEvent.KIND, RepostEvent.KIND, @@ -58,10 +59,15 @@ val HomePostsNewThreadKinds = ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, HighlightEvent.KIND, - WikiNoteEvent.KIND, - NipTextEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, PollEvent.KIND, + WikiNoteEvent.KIND, + AttestationEvent.KIND, + NipTextEvent.KIND, + ) + +val HomePostsNewThreadKinds2 = + listOf( InteractiveStoryPrologueEvent.KIND, ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, @@ -91,12 +97,22 @@ fun filterNewHomePostsByAuthors( relay = relay, filter = Filter( - kinds = HomePostsNewThreadKinds, + kinds = HomePostsNewThreadKinds1, authors = authorList, limit = min(authorList.size * 10, 500), since = since, ), ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsNewThreadKinds2, + authors = authorList, + limit = min(authorList.size * 10, 5), + since = since, + ), + ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index 5c0c0602ae..95b23a2ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -38,11 +38,14 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults.cardElevation -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton @@ -75,6 +78,9 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ClickableBox +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.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -416,15 +422,16 @@ private fun ListActionsMenuButton( onClick = { isActionListOpen.value = true }, ) { VerticalDotsIcon() + } - DropdownMenu( - expanded = isActionListOpen.value, - onDismissRequest = { isActionListOpen.value = false }, + if (isActionListOpen.value) { + val context = LocalContext.current + M3ActionDialog( + title = stringRes(R.string.list_actions_dialog_title), + onDismiss = { isActionListOpen.value = false }, ) { - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -443,38 +450,22 @@ private fun ListActionsMenuButton( Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) ContextCompat.startActivity(context, shareIntent, null) isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_set_edit_list_metadata)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) { onEditList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_set_broadcast)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_set_broadcast)) { onBroadcastList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_set_delete)) - }, - onClick = { + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_set_delete), isDestructive = true) { onDeleteList() isActionListOpen.value = false - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt index eefa908ad5..c10fbc2370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -35,11 +35,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults.cardElevation -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton @@ -68,6 +71,9 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ClickableBox +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.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -271,15 +277,16 @@ private fun ListActionsMenuButton( onClick = { isActionListOpen.value = true }, ) { VerticalDotsIcon() + } - DropdownMenu( - expanded = isActionListOpen.value, - onDismissRequest = { isActionListOpen.value = false }, + if (isActionListOpen.value) { + val context = LocalContext.current + M3ActionDialog( + title = stringRes(R.string.pack_actions_dialog_title), + onDismiss = { isActionListOpen.value = false }, ) { - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -298,38 +305,22 @@ private fun ListActionsMenuButton( Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) ContextCompat.startActivity(context, shareIntent, null) isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_pack_edit_list_metadata)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_pack_edit_list_metadata)) { onEditList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_pack_broadcast)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_pack_broadcast)) { onBroadcastList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_pack_delete)) - }, - onClick = { + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_pack_delete), isDestructive = true) { onDeleteList() isActionListOpen.value = false - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index 369852526f..2268ff2e02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -29,13 +29,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Public import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -59,6 +60,9 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.ui.components.ClickableBox +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.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -286,15 +290,15 @@ private fun PeopleListOptionsButton( onClick = { isMenuOpen.value = true }, ) { VerticalDotsIcon() - - ListOptionsMenu( - isExpanded = isMenuOpen.value, - onListEditMetadata = onListEditMetadata, - onListClone = onListCloneCreate, - onDelete = onListDelete, - onDismiss = { isMenuOpen.value = false }, - ) } + + ListOptionsMenu( + isExpanded = isMenuOpen.value, + onListEditMetadata = onListEditMetadata, + onListClone = onListCloneCreate, + onDelete = onListDelete, + onDismiss = { isMenuOpen.value = false }, + ) } @Composable @@ -309,36 +313,28 @@ private fun ListOptionsMenu( val optionalCloneName = remember { mutableStateOf(null) } val optionalCloneDescription = remember { mutableStateOf(null) } - DropdownMenu( - expanded = isExpanded, - onDismissRequest = onDismiss, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_edit_list_metadata)) - }, - onClick = { - onListEditMetadata() - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_copy_action_btn_label)) - }, - onClick = { - isCopyDialogOpen.value = true - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.quick_action_delete)) - }, - onClick = { - onDelete() - }, - ) + if (isExpanded) { + M3ActionDialog( + title = stringRes(R.string.list_management_dialog_title), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) { + onListEditMetadata() + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) { + isCopyDialogOpen.value = true + onDismiss() + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + onDismiss() + } + } + } } if (isCopyDialogOpen.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt index b0447f624b..5b089554ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType @@ -137,7 +138,22 @@ class FollowPackMetadataViewModel : ViewModel() { ) { onUploading(true) - val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + val sourceUri = + if (account.settings.stripLocationOnUpload) { + val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) + if (!result.stripped) { + onError( + stringRes(context, R.string.metadata_strip_failed_title), + stringRes(context, R.string.metadata_strip_failed_upload_cancelled), + ) + onUploading(false) + return + } + result.uri + } else { + galleryUri.uri + } + val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) try { val result = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt index dd17972cd0..f2032dae20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType @@ -138,7 +139,22 @@ class PeopleListMetadataViewModel : ViewModel() { ) { onUploading(true) - val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + val sourceUri = + if (account.settings.stripLocationOnUpload) { + val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) + if (!result.stripped) { + onError( + stringRes(context, R.string.metadata_strip_failed_title), + stringRes(context, R.string.metadata_strip_failed_upload_cancelled), + ) + onUploading(false) + return + } + result.uri + } else { + galleryUri.uri + } + val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) try { val result = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt index f7e4db06b9..7f37e0dadf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt @@ -33,10 +33,9 @@ import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.RemoveCircleOutline -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -50,6 +49,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +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.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding @@ -193,6 +195,30 @@ private fun UserAdditionOptions( ) { val isUserAddTapped = remember { mutableStateOf(false) } + if (isUserAddTapped.value) { + M3ActionDialog( + title = stringRes(R.string.add_member_dialog_title), + onDismiss = { isUserAddTapped.value = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.PersonAdd, + text = stringRes(R.string.follow_set_public_member_add_label), + ) { + onAddUserToList(false) + isUserAddTapped.value = false + } + M3ActionRow( + icon = Icons.Outlined.Lock, + text = stringRes(R.string.follow_set_private_member_add_label), + ) { + onAddUserToList(true) + isUserAddTapped.value = false + } + } + } + } + Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -231,29 +257,5 @@ private fun UserAdditionOptions( ) } } - - DropdownMenu( - expanded = isUserAddTapped.value, - onDismissRequest = { isUserAddTapped.value = false }, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_public_member_add_label)) - }, - onClick = { - onAddUserToList(false) - isUserAddTapped.value = false - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_private_member_add_label)) - }, - onClick = { - onAddUserToList(true) - isUserAddTapped.value = false - }, - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index dfe09eb44a..316e977757 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -28,11 +28,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,9 +51,6 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed -import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop -import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose @@ -64,45 +63,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.Sho import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer - -@Composable -fun RefreshableCardView( - feedContent: CardFeedContentState, - accountViewModel: AccountViewModel, - nav: INav, - routeForLastRead: String, - scrollStateKey: String? = null, - enablePullRefresh: Boolean = true, -) { - RefresheableBox(feedContent, enablePullRefresh) { - SaveableCardFeedState(feedContent, accountViewModel, nav, routeForLastRead, scrollStateKey) - } -} - -@Composable -private fun SaveableCardFeedState( - feedContent: CardFeedContentState, - accountViewModel: AccountViewModel, - nav: INav, - routeForLastRead: String, - scrollStateKey: String? = null, -) { - val listState = - if (scrollStateKey != null) { - rememberForeverLazyListState(scrollStateKey) - } else { - rememberLazyListState() - } - - WatchScrollToTop(feedContent, listState) - - RenderCardFeed(feedContent, accountViewModel, listState, nav, routeForLastRead) -} +import com.vitorpamplona.amethyst.ui.theme.imageModifier @Composable fun RenderCardFeed( feedContent: CardFeedContentState, + pollContent: OpenPollsState, accountViewModel: AccountViewModel, listState: LazyListState, nav: INav, @@ -128,6 +96,7 @@ fun RenderCardFeed( is CardFeedState.Loaded -> { FeedLoaded( loaded = state, + polls = pollContent, listState = listState, routeForLastRead = routeForLastRead, accountViewModel = accountViewModel, @@ -159,12 +128,14 @@ fun NotificationFeedEmpty(onRefresh: () -> Unit) { @Composable private fun FeedLoaded( loaded: CardFeedState.Loaded, + polls: OpenPollsState, listState: LazyListState, routeForLastRead: String, accountViewModel: AccountViewModel, nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val openPolls by polls.flow.collectAsStateWithLifecycle() LazyColumn( modifier = Modifier.fillMaxSize(), @@ -175,6 +146,37 @@ private fun FeedLoaded( ShowDonationCard(accountViewModel, nav) } + if (openPolls.isNotEmpty()) { + itemsIndexed( + items = openPolls, + key = { _, item -> "open-poll-${item.idHex}" }, + contentType = { _, _ -> "OpenPoll" }, + ) { _, note -> + Row(modifier = Modifier.padding(start = Size10dp, end = Size10dp, bottom = Size10dp)) { + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + OpenPollsSectionHeader() + Row(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + baseNote = note, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = routeForLastRead, + isBoostedNote = false, + isQuotedNote = false, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + itemsIndexed( items = items.list, key = { _, item -> item.id() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index 38f66df1bb..12238097b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -30,7 +30,10 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -45,6 +48,7 @@ fun NotificationScreen( NotificationScreen( notifFeedContentState = accountViewModel.feedStates.notifications, notifSummaryState = accountViewModel.feedStates.notificationSummary, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, sharedPrefs = accountViewModel.settings.uiSettingsFlow, accountViewModel = accountViewModel, nav = nav, @@ -55,6 +59,7 @@ fun NotificationScreen( fun NotificationScreen( notifFeedContentState: CardFeedContentState, notifSummaryState: NotificationSummaryState, + notifPolls: OpenPollsState, sharedPrefs: UiSettingsFlow, accountViewModel: AccountViewModel, nav: INav, @@ -88,13 +93,13 @@ fun NotificationScreen( modifier = Modifier.padding(it).consumeWindowInsets(it), ) { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) - RefreshableCardView( - feedContent = notifFeedContentState, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Notification", - scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, - ) + RefresheableBox(notifFeedContentState, true) { + val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) + + WatchScrollToTop(notifFeedContentState, listState) + + RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification") + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 105d71a939..f112a41737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel -import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.common.data.ExtraStore -import com.patrykandpatrick.vico.core.common.data.MutableExtraStore +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.compose.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.compose.common.data.ExtraStore +import com.patrykandpatrick.vico.compose.common.data.MutableExtraStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt new file mode 100644 index 0000000000..95f185ab1e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications + +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun OpenPollsSectionHeader() { + Text( + text = stringRes(R.string.open_poll), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt new file mode 100644 index 0000000000..08c9faf781 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache.notes +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@Stable +class OpenPollsState( + account: Account, + scope: CoroutineScope, +) { + val filter = + Filter( + kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), + authors = listOf(account.pubKey), + ) + + val flow: StateFlow> = + account.cache + .observeNotes(filter) + .map { notes -> filterOpenPolls(notes) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + private fun filterOpenPolls(notes: List): List { + val now = TimeUtils.now() + val oneDayInSeconds = TimeUtils.ONE_DAY + + return notes + .filter { note -> + val event = note.event ?: return@filter false + + when (event) { + is PollEvent -> { + val endsAt = event.endsAt() + if (endsAt != null) endsAt > now else event.createdAt + oneDayInSeconds > now + } + + is ZapPollEvent -> { + val closedAt = event.closedAt() + if (closedAt != null) closedAt > now else event.createdAt + oneDayInSeconds > now + } + + else -> { + false + } + } + }.sortedByDescending { it.createdAt() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt index 34675af1ac..70a2444ea8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import com.vitorpamplona.amethyst.ui.note.showAmountIntegerWithZero import com.vitorpamplona.amethyst.ui.note.showAmountWithZero import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ShowDecimals diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt index 7cc79fa6fe..50223df250 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import kotlin.math.roundToInt @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt index f438df0fdc..df1f61ef80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import android.util.LruCache import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlin.math.roundToInt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt index 961895f1c2..8c568b7afd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt @@ -21,34 +21,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent -import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom -import com.patrykandpatrick.vico.compose.cartesian.axis.rememberEnd -import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer.AreaFill.Companion.single +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer.Line import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart -import com.patrykandpatrick.vico.compose.common.fill -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis -import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel -import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.core.common.shader.ShaderProvider +import com.patrykandpatrick.vico.compose.common.Fill import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.RoyalBlue -fun makeLine(color: Color): LineCartesianLayer.Line = - LineCartesianLayer.Line( - fill = LineCartesianLayer.LineFill.single(fill(color)), +fun makeLine(color: Color): Line = + Line( + fill = LineCartesianLayer.LineFill.single(Fill(color)), areaFill = - LineCartesianLayer.AreaFill.single( - fill( - ShaderProvider.verticalGradient( - color.copy(alpha = 0.4f).toArgb(), - Color.Transparent.toArgb(), - ), + single( + Fill( + brush = + Brush.verticalGradient( + colors = listOf(color.copy(alpha = 0.4f), Color.Transparent), + ), ), ), pointConnector = LineCartesianLayer.PointConnector.cubic(), @@ -84,7 +84,10 @@ fun ShowChart(model: CartesianChartModel) { ), endAxis = VerticalAxis.rememberEnd( - label = rememberAxisLabelComponent(color = BitcoinOrange), + label = + rememberAxisLabelComponent( + style = TextStyle(color = BitcoinOrange, fontSize = 12.sp), + ), valueFormatter = AmountValueFormatter(), itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 718cdfcb7b..2e2f89eba6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -29,11 +29,12 @@ import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser @@ -87,6 +88,7 @@ class NotificationFeedFilter( VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND, WikiNoteEvent.KIND, + AttestationRequestEvent.KIND, ) val NOTIFICATION_KINDS = @@ -106,7 +108,7 @@ class NotificationFeedFilter( LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, PollEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, PrivateDmEvent.KIND, PublicMessageEvent.KIND, VideoNormalEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index a442d467cd..3a0e7ae936 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -131,6 +132,8 @@ fun NewPublicMessageScreen( WatchAndLoadMyEmojiList(accountViewModel) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() @@ -246,8 +249,9 @@ fun PublicMessageScreenContent( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + isUploading = postViewModel.mediaUploadTracker.isUploading, + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, @@ -321,6 +325,7 @@ private fun BottomRowActions( ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -328,7 +333,8 @@ private fun BottomRowActions( } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index d3ccd54210..35e94f93fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -42,9 +42,11 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -149,7 +151,9 @@ class NewPublicMessageViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -161,6 +165,9 @@ class NewPublicMessageViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + // Invoices var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) @@ -319,7 +326,7 @@ class NewPublicMessageViewModel : } suspend fun sendPostSync() { - val template = createTemplate() ?: return + val template = createTemplate() val extraNotesToBroadcast = mutableListOf() if (nip95attachments.isNotEmpty()) { @@ -351,7 +358,7 @@ class NewPublicMessageViewModel : broadcast.add(it.second) } - val template = createTemplate() ?: return + val template = createTemplate() accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast) } } @@ -419,8 +426,9 @@ class NewPublicMessageViewModel : server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, + stripMetadata: Boolean = true, ) = try { - uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata) } catch (e: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -435,11 +443,12 @@ class NewPublicMessageViewModel : server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, + stripMetadata: Boolean = true, ) { viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -449,6 +458,8 @@ class NewPublicMessageViewModel : server, account, context, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { @@ -456,7 +467,7 @@ class NewPublicMessageViewModel : if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) @@ -494,7 +505,7 @@ class NewPublicMessageViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -523,6 +534,8 @@ class NewPublicMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null + mediaUploadTracker.finishUpload() + iMetaAttachments.reset() emojiSuggestions?.reset() @@ -628,7 +641,7 @@ class NewPublicMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - !isUploadingImage && + !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index 660a390f61..273433753f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -64,7 +64,7 @@ class UserProfileConversationsFeedFilter( it.author == user && ( it.event is TextNoteEvent || - it.event is PollNoteEvent || + it.event is ZapPollEvent || it.event is PollResponseEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt new file mode 100644 index 0000000000..c37e0acc48 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterUserProfileLastSeen( + users: Map>, + since: SincePerRelayMap?, +): List { + if (users.isEmpty()) return emptyList() + + return users.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + authors = it.value.sorted(), + since = since?.get(it.key)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index d95d5f51ba..ef9ac5fbb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -23,10 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -64,8 +65,9 @@ val UserProfilePostKinds2 = InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, VoiceReplyEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, PinListEvent.KIND, + AttestationEvent.KIND, ) fun filterUserProfilePosts( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt index 5910100817..e6d21c9338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -52,6 +52,7 @@ class UserProfileMetadataFilterSubAssembler( return listOfNotNull( filterUserProfileMetadata(userPerRelay, since), filterUserProfileLists(userPerRelay, since), + filterUserProfileLastSeen(userPerRelay, since), ).flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index a614105d4b..d931e2cdc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt @@ -63,6 +63,7 @@ class UserProfileFollowersUserFeedViewModel( } } + @OptIn(kotlinx.coroutines.FlowPreview::class) val followersFlow: StateFlow> = account.cache .observeEvents(followerFilter) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt index dab5574840..0667d781cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt @@ -55,6 +55,7 @@ class UserProfileFollowsUserFeedViewModel( return LocalCache.load(nonHiddenFollows).sortedWith(sortingModel) } + @OptIn(kotlinx.coroutines.FlowPreview::class) val followsFlow: StateFlow> = contactList .flow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 0a8f6f31d4..0e943641de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -77,12 +77,13 @@ class UserProfileGalleryFeedFilter( val noteEvent = it.event return ( ( - it.event?.pubKey == user.pubkeyHex && ( - noteEvent is PictureEvent || - noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || - (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) - ) + it.event?.pubKey == user.pubkeyHex && + ( + noteEvent is PictureEvent || + noteEvent is RegularVideoEvent || + (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) + ) ) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ) && params.match(noteEvent, it.relays) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 2dd0138785..4a039d1b1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -46,8 +46,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size16Modifier -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse @Composable fun DisplayLNAddress( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 202a59ebc0..d88b0fb95a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -78,11 +80,16 @@ import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch private const val IDENTITY_ICON_CACHE_KEY = 0 @@ -200,6 +207,8 @@ fun DrawAdditionalInfo( } } + DisplayLastSeen(baseUser, accountViewModel) + DisplayNip05ProfileStatus(baseUser, accountViewModel) val lud16 = @@ -281,6 +290,33 @@ fun DrawAdditionalInfo( } } +@Composable +fun DisplayLastSeen( + user: User, + accountViewModel: AccountViewModel, +) { + val lastSeenFlow = + remember { + accountViewModel.account.cache + .observeLatestEvent(Filter(authors = listOf(user.pubkeyHex))) + .map { + it?.createdAt + }.flowOn(Dispatchers.IO) + } + + val lastSeen by lastSeenFlow.collectAsStateWithLifecycle(null) + + lastSeen?.let { timestamp -> + val context = LocalContext.current + Text( + text = stringRes(R.string.last_seen, timeAgo(timestamp, context)), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + @Composable fun DisplayNip05ProfileStatus( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index cc13d120df..7576872ed1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -71,6 +71,7 @@ fun DrawBanner( contentDescription = stringRes(id = R.string.profile_image), contentScale = ContentScale.Crop, placeholder = painterRes(R.drawable.profile_banner, 1), + error = painterRes(R.drawable.profile_banner, 1), modifier = Modifier .fillMaxWidth() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index d299d5aef4..0dd30e5ee2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -21,20 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.Intent -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +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.note.externalLinkForUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip56Reports.ReportType @Composable @@ -44,108 +48,94 @@ fun UserProfileDropDownMenu( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = onDismiss, + if (!popupExpanded) return + + M3ActionDialog( + title = stringRes(R.string.profile_actions_dialog_title), + onDismiss = onDismiss, ) { val clipboardManager = LocalClipboardManager.current - - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_user_id)) }, - onClick = { - clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) - onDismiss() - }, - ) - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + // Share section + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.ContentCopy, + text = stringRes(R.string.copy_user_id), + ) { + clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) + onDismiss() + } + M3ActionRow( + icon = Icons.Outlined.Share, + text = stringRes(R.string.quick_action_share), + ) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForUser(user), - ) + putExtra(Intent.EXTRA_TEXT, externalLinkForUser(user)) putExtra( Intent.EXTRA_TITLE, stringRes(context, R.string.quick_action_share_browser_link), ) } - - val shareIntent = - Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) + val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) context.startActivity(shareIntent) onDismiss() - }, - ) + } + } + // Moderation section (if not self) if (accountViewModel.userProfile() != user) { - HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.isHidden(user)) { - DropdownMenuItem( - text = { Text(stringRes(R.string.unblock_user)) }, - onClick = { + M3ActionSection { + if (accountViewModel.account.isHidden(user)) { + M3ActionRow( + icon = Icons.Outlined.CheckCircle, + text = stringRes(R.string.unblock_user), + ) { accountViewModel.show(user) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(id = R.string.block_hide_user)) }, - onClick = { + } + } else { + M3ActionRow( + icon = Icons.Outlined.Block, + text = stringRes(R.string.block_hide_user), + isDestructive = true, + ) { accountViewModel.hide(user) onDismiss() - }, - ) + } + } } - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_spam_scam)) }, - onClick = { + + // Report section + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_spam_scam), isDestructive = true) { accountViewModel.report(user, ReportType.SPAM) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.report_hateful_speech)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_hateful_speech), isDestructive = true) { accountViewModel.report(user, ReportType.PROFANITY) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_impersonation)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_impersonation), isDestructive = true) { accountViewModel.report(user, ReportType.IMPERSONATION) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.report_nudity_porn)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_nudity_porn), isDestructive = true) { accountViewModel.report(user, ReportType.NUDITY) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_illegal_behaviour)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_illegal_behaviour), isDestructive = true) { accountViewModel.report(user, ReportType.ILLEGAL) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_malware)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_malware), isDestructive = true) { accountViewModel.report(user, ReportType.MALWARE) onDismiss() - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index 8514dde91b..c55f6e580f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -56,7 +56,7 @@ fun WatchApp( LaunchedEffect(key1 = appState) { withContext(Dispatchers.IO) { - (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData -> + (appState.note.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData -> metaData.picture?.ifBlank { null }?.let { newLogo -> if (newLogo != appLogo) appLogo = newLogo } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index 920c0d3e33..ce44c39215 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -78,7 +78,7 @@ class UserProfileMutualFeedFilter( it.event is LongTextNoteEvent || it.event is WikiNoteEvent || it.event is NipTextEvent || - it.event is PollNoteEvent || + it.event is ZapPollEvent || it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index cf401a7230..bdaa1d8af6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -27,11 +27,15 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -80,14 +84,18 @@ class UserProfileNewThreadFeedFilter( it.event is LongTextNoteEvent || it.event is WikiNoteEvent || it.event is NipTextEvent || - it.event is PollNoteEvent || + it.event is ZapPollEvent || it.event is PollEvent || it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || it.event is AudioHeaderEvent || it.event is VoiceEvent || - it.event is TorrentEvent + it.event is TorrentEvent || + it.event is AttestationEvent || + it.event is AttestationRequestEvent || + it.event is AttestorRecommendationEvent || + it.event is AttestorProficiencyEvent ) && it.isNewThread() && account.isAcceptable(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 1c95843c22..35fd0bd23b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -112,6 +112,7 @@ class UserProfileZapsViewModel( return results.map { (user, amount) -> ZapAmount(user, amount) }.sortedWith(sortingModel) } + @OptIn(kotlinx.coroutines.FlowPreview::class) val receivedZapAmountsByUser: StateFlow> = account.cache .observeEvents(zapsToUser) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index 84ac86e8c6..a1538e3362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -58,7 +58,7 @@ val PostsByRelayKinds2 = AudioTrackEvent.KIND, AudioHeaderEvent.KIND, NipTextEvent.KIND, - PollNoteEvent.KIND, + ZapPollEvent.KIND, ) fun filterPostsByRelay( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index e2b79d62cb..c4d65da7b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -29,8 +29,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.FolderZip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,6 +53,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +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.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -188,6 +191,14 @@ fun MappedAllRelayListView( val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() + val outboxCounts by nip65ViewModel.homeCountResults.collectAsStateWithLifecycle() + val inboxCounts by nip65ViewModel.notifCountResults.collectAsStateWithLifecycle() + val dmCounts by dmViewModel.countResults.collectAsStateWithLifecycle() + val privateHomeCounts by privateOutboxViewModel.countResults.collectAsStateWithLifecycle() + val proxyCounts by proxyViewModel.countResults.collectAsStateWithLifecycle() + val indexerCounts by indexerViewModel.countResults.collectAsStateWithLifecycle() + val searchCounts by searchViewModel.countResults.collectAsStateWithLifecycle() + Scaffold( topBar = { SavingTopBar( @@ -261,7 +272,7 @@ fun MappedAllRelayListView( SettingsCategoryFirstModifier, ) } - renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav, outboxCounts) item { SettingsCategory( @@ -270,7 +281,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav, inboxCounts) item { SettingsCategoryWithButton( @@ -282,7 +293,7 @@ fun MappedAllRelayListView( }, ) } - renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav) + renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav, dmCounts) item { SettingsCategory( @@ -291,7 +302,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav) + renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav, privateHomeCounts) item { SettingsCategory( @@ -300,7 +311,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav) + renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav, proxyCounts) item { SettingsCategory( @@ -320,7 +331,7 @@ fun MappedAllRelayListView( ResetIndexerRelays(indexerViewModel) } } - renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav) + renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav, indexerCounts) item { SettingsCategoryWithButton( @@ -331,7 +342,7 @@ fun MappedAllRelayListView( ResetSearchRelays(searchViewModel) } } - renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav) + renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav, searchCounts) item { SettingsCategory( @@ -482,6 +493,7 @@ fun SettingsCategoryWithButton( @Composable fun ExportDropdownMenu(collection: () -> RelayListCollection) { var expanded by remember { mutableStateOf(false) } + val context = LocalContext.current IconButton(onClick = { expanded = true }) { Icon( @@ -490,24 +502,27 @@ fun ExportDropdownMenu(collection: () -> RelayListCollection) { ) } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.export_as_text)) }, - onClick = { - expanded = false - RelayExporter(context).export(collection()) - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.export_as_zip)) }, - onClick = { - expanded = false - RelayZipExporter(context).export(collection()) - }, - ) + if (expanded) { + M3ActionDialog( + title = stringRes(R.string.export_actions_dialog_title), + onDismiss = { expanded = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.Description, + text = stringRes(R.string.export_as_text), + ) { + expanded = false + RelayExporter(context).export(collection()) + } + M3ActionRow( + icon = Icons.Outlined.FolderZip, + text = stringRes(R.string.export_as_zip), + ) { + expanded = false + RelayZipExporter(context).export(collection()) + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 64ebae2953..139790f9a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.automirrored.filled.Feed import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AttachMoney import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Code @@ -61,7 +62,6 @@ import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Payment import androidx.compose.material.icons.filled.PrivacyTip -import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Topic @@ -128,6 +128,10 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -146,7 +150,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage @@ -191,8 +195,8 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -529,6 +533,10 @@ private fun kindDisplayName(kind: Int): Int = AppSpecificDataEvent.KIND -> R.string.kind_user_settings AudioHeaderEvent.KIND -> R.string.kind_audio_header AudioTrackEvent.KIND -> R.string.kind_audio_track + AttestationEvent.KIND -> R.string.attestation + AttestationRequestEvent.KIND -> R.string.attestation_request + AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation + AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency BadgeAwardEvent.KIND -> R.string.kind_badge_awards BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions BadgeProfilesEvent.KIND -> R.string.kind_profile_badges @@ -619,7 +627,7 @@ private fun kindDisplayName(kind: Int): Int = PeopleListEvent.KIND -> R.string.kind_people_lists PictureEvent.KIND -> R.string.kind_pictures PinListEvent.KIND -> R.string.kind_pins - PollNoteEvent.KIND -> R.string.kind_zap_poll + ZapPollEvent.KIND -> R.string.kind_zap_poll PollEvent.KIND -> R.string.kind_poll PollResponseEvent.KIND -> R.string.kind_poll_response PrivateDmEvent.KIND -> R.string.kind_nip04_dms @@ -661,7 +669,7 @@ val zaps = setOf(9734, 9735, 9041, 17375, 23194, 23195) val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, RequestToVanishEvent.KIND) @Composable -private fun KindChip(kind: Int) { +fun KindChip(kind: Int) { val nameResId = kindDisplayName(kind) val name = if (nameResId != -1) stringResource(nameResId) else "k$kind" val (bg, fg) = @@ -931,7 +939,7 @@ private fun OutboxEventsCard(eventIds: Set) { horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( - imageVector = Icons.Default.Send, + imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index 73fad951f1..625fde7c29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -63,6 +63,7 @@ fun BasicRelaySetupInfoClickableRow( onClick: () -> Unit, nip11CachedRetriever: Nip11CachedRetriever, modifier: Modifier = Modifier, + countResult: RelayCountResult? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -104,6 +105,11 @@ fun BasicRelaySetupInfoClickableRow( UsedBy(item, accountViewModel, nav) + RelayEventCountRow( + countResult = countResult, + modifier = ReactionRowHeightChatMaxWidth, + ) + RelayStatusRow( item = item, onClick = onClick, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 1a85e2aec2..3196a11747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -32,6 +32,7 @@ fun BasicRelaySetupInfoDialog( item: BasicRelaySetupInfo, nip11CachedRetriever: Nip11CachedRetriever, onDelete: ((BasicRelaySetupInfo) -> Unit)?, + countResult: RelayCountResult? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -43,6 +44,7 @@ fun BasicRelaySetupInfoDialog( onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, nip11CachedRetriever = nip11CachedRetriever, modifier = HalfVertPadding, + countResult = countResult, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 758c39646f..91bc1f373b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -40,6 +41,9 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { private val _relays = MutableStateFlow>(emptyList()) val relays = _relays.asStateFlow() + private val _countResults = MutableStateFlow>(emptyMap()) + val countResults = _countResults.asStateFlow() + var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -50,12 +54,15 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { fun load() { clear() loadRelayDocuments() + loadCounts() } abstract fun getRelayList(): List? abstract suspend fun saveRelayList(urlList: List) + open fun countFilters(relayUrl: NormalizedRelayUrl): List = emptyList() + fun create() { if (hasModified) { accountViewModel.launchSigner { @@ -79,6 +86,40 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { } } + private fun loadCounts() { + _countResults.value = emptyMap() + + val client = account.client + val relayList = _relays.value + if (relayList.isEmpty()) return + + relayList.forEach { item -> + val filters = countFilters(item.relay) + if (filters.isEmpty()) return@forEach + + filters.forEach { countFilter -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, countFilter.filter) + if (result != null) { + _countResults.update { currentMap -> + val current = currentMap[item.relay] ?: RelayCountResult() + val entries = current.counts.toMutableList() + val newEntry = + RelayCountResult.CountEntry( + label = countFilter.label, + count = result.count, + approximate = result.approximate, + ) + val existing = entries.indexOfFirst { it.label == countFilter.label } + if (existing >= 0) entries[existing] = newEntry else entries.add(newEntry) + currentMap + (item.relay to RelayCountResult(entries)) + } + } + } + } + } + } + open fun relayListBuilder(): List { val relayList = getRelayList() ?: emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt new file mode 100644 index 0000000000..835eb764dc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.countToHumanReadable +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.Size10Modifier +import com.vitorpamplona.amethyst.ui.theme.allGoodColor + +private val PillShape = RoundedCornerShape(12.dp) + +@Composable +fun RelayEventCountRow( + countResult: RelayCountResult?, + modifier: Modifier, +) { + if (countResult == null || countResult.counts.isEmpty()) return + + val pillColor = MaterialTheme.colorScheme.allGoodColor + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = modifier, + ) { + countResult.counts.forEachIndexed { index, entry -> + if (index > 0) { + Spacer(modifier = Modifier.width(6.dp)) + } + + val countText = + if (entry.approximate) { + "~${countToHumanReadable(entry.count, stringRes(entry.label))}" + } else { + countToHumanReadable(entry.count, stringRes(entry.label)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clip(PillShape) + .border(width = 1.dp, color = pillColor.copy(alpha = 0.4f), shape = PillShape) + .background(pillColor.copy(alpha = 0.1f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = stringRes(R.string.relay_event_count), + modifier = Size10Modifier, + tint = pillColor, + ) + + Spacer(modifier = Modifier.width(3.dp)) + + Text( + text = countText, + maxLines = 1, + fontSize = Font10SP, + fontWeight = FontWeight.Medium, + color = pillColor, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt new file mode 100644 index 0000000000..5aaa39b6ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +@Immutable +data class RelayCountResult( + val counts: List = emptyList(), +) { + @Immutable + data class CountEntry( + val label: Int, + val count: Int, + val approximate: Boolean = false, + ) +} + +data class CountFilter( + val label: Int, + val filter: Filter, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index 67ca079d1f..e7664350b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip56Reports.ReportEvent @Stable class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { @@ -41,11 +42,14 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { val users = mutableSetOf() reqs.forEach { id, filters -> filters.forEach { filter -> - filter.authors?.forEach { pubkey -> - users.add(pubkey) - } - filter.tags?.get("p")?.forEach { pubkey -> - users.add(pubkey) + val isReportFilter = filter.kinds?.size == 1 && filter.kinds?.firstOrNull() == ReportEvent.KIND + if (!isReportFilter) { + filter.authors?.forEach { pubkey -> + users.add(pubkey) + } + filter.tags?.get("p")?.forEach { pubkey -> + users.add(pubkey) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 37e02b99be..f6e9395714 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun DMRelayList( @@ -64,12 +66,14 @@ fun LazyListScope.renderDMItems( postViewModel: DMRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "DM" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index d08612a485..53a6a5ed01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -21,8 +21,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @Stable class DMRelayListViewModel : BasicRelaySetupInfoModel() { @@ -31,4 +36,16 @@ class DMRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveDMRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = R.string.dms, + filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(account.pubKey)), + ), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt new file mode 100644 index 0000000000..a03e6d6192 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt @@ -0,0 +1,660 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_ACTIVITY_LOG +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_CONCURRENT_RELAYS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.RELAY_TIMEOUT_MS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.LiveSyncActivity.SourceRelayInfo +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.cancellation.CancellationException + +/** + * Syncs the user's events across all known relays: + * 1. Downloads all events authored by the user and sends them to their outbox relays. + * 2. Downloads all events that p-tag the user (non-DM) and sends them to their inbox relays. + * 3. Downloads kind-4 and kind-1059 events that p-tag the user and sends them to DM relays. + * + * Up to [MAX_CONCURRENT_RELAYS] relays are queried in parallel. As soon as one relay is fully + * exhausted (all pages retrieved) the next relay from the list starts immediately, keeping the + * concurrency window full at all times. + * + * Each relay is paginated individually: after EOSE the oldest [Event.createdAt] seen on that + * relay becomes the next `until` cursor, repeating until the relay returns no new events. + * + * OK (true) responses from destination relays are tracked via [IRelayClientListener] and + * attributed back to the source relay that contributed each event. + * + * Live activity is emitted via [liveActivity] so the UI can show a per-relay log of events + * received and events accepted by destination relays. + * + * Scoped to the AccountViewModel so the sync survives navigation within the same session. + */ +@Stable +class EventSync( + private val accountPubKey: HexKey, + private val relayDb: () -> List, + private val outboxTargets: () -> Set, + private val inboxTargets: () -> Set, + private val dmTargets: () -> Set, + private val clientBuilder: () -> INostrClient, + private val scope: CoroutineScope, +) { + companion object { + /** Maximum number of relays queried at the same time. */ + const val MAX_CONCURRENT_RELAYS = 50 + + /** How long (ms) to wait for a single relay to reply per page before giving up. */ + const val RELAY_TIMEOUT_MS = 30_000L + + /** Maximum number of completed-relay entries kept in the activity log. */ + const val MAX_ACTIVITY_LOG = 5000 + } + + // ------------------------------------------------------------------------- + // Public state + // ------------------------------------------------------------------------- + + sealed class SyncState { + object Idle : SyncState() + + data class Running( + val relaysCompleted: MutableStateFlow, + val totalRelays: MutableStateFlow, + val eventsSent: MutableStateFlow, + val eventsReceived: MutableStateFlow, + val eventsAccepted: MutableStateFlow, + val sinceFilter: Long? = null, + val untilFilter: Long? = null, + ) : SyncState() { + constructor(relaysCompleted: Int, totalRelays: Int, eventsSent: Int, eventsReceived: Int, eventsAccepted: Int, sinceFilter: Long? = null, untilFilter: Long? = null) : + this( + relaysCompleted = MutableStateFlow(relaysCompleted), + totalRelays = MutableStateFlow(totalRelays), + eventsSent = MutableStateFlow(eventsSent), + eventsReceived = MutableStateFlow(eventsReceived), + eventsAccepted = MutableStateFlow(eventsAccepted), + sinceFilter = sinceFilter, + untilFilter = untilFilter, + ) + } + + data class Done( + val totalEventsReceived: Int, + val totalEventsSent: Int, + val totalEventsAccepted: Int, + val durationMs: Long, + val sinceFilter: Long? = null, + val untilFilter: Long? = null, + ) : SyncState() + + data class Error( + val message: String, + val sinceFilter: Long? = null, + val untilFilter: Long? = null, + ) : SyncState() + } + + /** + * Per-relay activity snapshot emitted continuously while the sync runs. + * + * @param completedRelays Last [MAX_ACTIVITY_LOG] relays that finished, sorted by most events + * found (descending) so the most productive sources appear first. + * @param outboxTargets Relays receiving events authored by the user. + * @param inboxTargets Relays receiving events that mention the user. + * @param dmTargets Relays receiving DMs addressed to the user. + */ + @Stable + data class LiveSyncActivity( + val runningRelays: Map = emptyMap(), + val completedRelays: Map = emptyMap(), + val outboxTargets: Map = emptyMap(), + val inboxTargets: Map = emptyMap(), + val dmTargets: Map = emptyMap(), + ) { + companion object { + val DefaultOrder = compareByDescending { it.eventsFound.value }.thenByDescending { it.status.value == ConnectionStatus.Completed } + } + + val sortedCompletedRelays = completedRelays.values.sortedWith(DefaultOrder) + + constructor( + runningRelays: List, + completedRelays: List, + outboxTargets: List, + inboxTargets: List, + dmTargets: List, + ) : this( + runningRelays.associateBy { it.relay }, + completedRelays.associateBy { it.relay }, + outboxTargets.associateBy { it.relay }, + inboxTargets.associateBy { it.relay }, + dmTargets.associateBy { it.relay }, + ) + + sealed interface ConnectionStatus { + object Connecting : ConnectionStatus + + object Querying : ConnectionStatus + + class Error( + val msg: String, + ) : ConnectionStatus + + object Completed : ConnectionStatus + } + + /** + * @param eventsFound Total events received from this relay across all pages. + * @param eventsAccepted Events from this relay that destination relays accepted as new + * (OK true). Reflects the count at relay-completion time; late + * OK responses may not be included. + */ + @Stable + data class SourceRelayInfo( + val relay: NormalizedRelayUrl, + val status: MutableStateFlow, + val eventsFound: MutableStateFlow, + val eventsAccepted: MutableStateFlow, + val pageUntil: MutableStateFlow = MutableStateFlow(null), + ) { + constructor(relay: NormalizedRelayUrl, status: ConnectionStatus, eventsFound: Int, eventsAccepted: Int, untilPage: Long? = null) : + this( + relay = relay, + status = MutableStateFlow(status), + eventsFound = MutableStateFlow(eventsFound), + eventsAccepted = MutableStateFlow(eventsAccepted), + pageUntil = MutableStateFlow(untilPage), + ) + } + + /** + * @param relay The destination relay URL. + * @param eventsSent Number of events sent to this relay. + * @param eventsAccepted Number of OK=true responses received from this relay. + */ + @Stable + data class DestinationRelayInfo( + val relay: NormalizedRelayUrl, + val eventsSent: MutableStateFlow, + val eventsAccepted: MutableStateFlow, + ) { + constructor(relay: NormalizedRelayUrl, eventsSent: Int, eventsAccepted: Int) : + this(relay, MutableStateFlow(eventsSent), MutableStateFlow(eventsAccepted)) + } + } + + private val _syncState = MutableStateFlow(SyncState.Idle) + val syncState: StateFlow = _syncState + + private val _liveActivity = MutableStateFlow(LiveSyncActivity()) + val liveActivity: StateFlow = _liveActivity + + private fun emitLiveSnapshot( + runningRelays: Set = emptySet(), + completedRelays: Set = emptySet(), + liveOutboxTargets: Set = emptySet(), + liveInboxTargets: Set = emptySet(), + liveDmTargets: Set = emptySet(), + ) { + _liveActivity.value = + LiveSyncActivity( + runningRelays = + runningRelays.associateWith { + SourceRelayInfo( + relay = it, + status = MutableStateFlow(LiveSyncActivity.ConnectionStatus.Connecting), + eventsFound = MutableStateFlow(0), + eventsAccepted = MutableStateFlow(0), + ) + }, + completedRelays = + completedRelays.associateWith { + SourceRelayInfo( + relay = it, + status = MutableStateFlow(LiveSyncActivity.ConnectionStatus.Connecting), + eventsFound = MutableStateFlow(0), + eventsAccepted = MutableStateFlow(0), + ) + }, + outboxTargets = + liveOutboxTargets.associateWith { relay -> + LiveSyncActivity.DestinationRelayInfo( + relay = relay, + eventsSent = MutableStateFlow(0), + eventsAccepted = MutableStateFlow(0), + ) + }, + inboxTargets = + liveInboxTargets.associateWith { relay -> + LiveSyncActivity.DestinationRelayInfo( + relay = relay, + eventsSent = MutableStateFlow(0), + eventsAccepted = MutableStateFlow(0), + ) + }, + dmTargets = + liveDmTargets.associateWith { relay -> + LiveSyncActivity.DestinationRelayInfo( + relay = relay, + eventsSent = MutableStateFlow(0), + eventsAccepted = MutableStateFlow(0), + ) + }, + ) + } + + // ------------------------------------------------------------------------- + // Control functions + // ------------------------------------------------------------------------- + + /** User-selected date range for the sync filter (epoch seconds). */ + val sinceSecs = MutableStateFlow(null) + val untilSecs = MutableStateFlow(null) + + private var syncJob: Job? = null + + fun start() { + if (_syncState.value is SyncState.Running) return + _liveActivity.value = LiveSyncActivity() + val capturedSince = sinceSecs.value + val capturedUntil = untilSecs.value + syncJob = + scope.launch(Dispatchers.IO) { + runSync(capturedSince, capturedUntil) + } + } + + fun cancel() { + syncJob?.cancel() + } + + // ------------------------------------------------------------------------- + // Sync logic + // ------------------------------------------------------------------------- + suspend fun runSync( + filterSince: Long? = null, + filterUntil: Long? = null, + ) { + val startTime = System.currentTimeMillis() + + _liveActivity.value = LiveSyncActivity() + + val myPubKey = accountPubKey + + val relaysToProcess = relayDb() + + if (relaysToProcess.isEmpty()) { + _syncState.value = + SyncState.Error("No known relays found. Browse some content first to discover relays.", filterSince, filterUntil) + return + } + + val totalRelays = relaysToProcess.size + + val outboxTargets = outboxTargets() + val inboxTargets = inboxTargets() + val dmTargets = dmTargets() + + val defaultFilters = + buildList { + if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey), since = filterSince, until = filterUntil)) + if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) { + add(Filter(tags = mapOf("p" to listOf(myPubKey)), since = filterSince, until = filterUntil)) + } + } + + if (defaultFilters.isEmpty()) { + _syncState.value = SyncState.Error("No outbox, inbox, or DM relays configured.", filterSince, filterUntil) + return + } + + val usersRelays = outboxTargets + inboxTargets + dmTargets + + val perRelayFilters = + relaysToProcess.associateWith { + if (it !in usersRelays) { + defaultFilters + } else { + buildList { + if (it !in outboxTargets) add(Filter(authors = listOf(myPubKey), since = filterSince, until = filterUntil)) + if (it !in inboxTargets && it !in dmTargets) { + add(Filter(tags = mapOf("p" to listOf(myPubKey)), since = filterSince, until = filterUntil)) + } + } + } + } + + emitLiveSnapshot( + emptySet(), + emptySet(), + outboxTargets, + inboxTargets, + dmTargets, + ) + + // Thread-safe dedup sets — prevent the same event from being forwarded twice when + // multiple source relays return the same event concurrently. + val outboxDedup = ConcurrentHashMap.newKeySet() + val inboxDedup = ConcurrentHashMap.newKeySet() + val dmDedup = ConcurrentHashMap.newKeySet() + + val sourceRelayOfEvent = ConcurrentHashMap() + + val runningState = + SyncState.Running( + relaysCompleted = 0, + totalRelays = totalRelays, + eventsSent = 0, + eventsReceived = 0, + eventsAccepted = 0, + sinceFilter = filterSince, + untilFilter = filterUntil, + ) + + val okListener = + object : IRelayClientListener { + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + super.onCannotConnect(relay, errorMessage) + val currentStatus = liveActivity.value.runningRelays[relay.url]?.status + if (currentStatus?.value !is LiveSyncActivity.ConnectionStatus.Error) { + currentStatus?.tryEmit(LiveSyncActivity.ConnectionStatus.Error(errorMessage)) + } + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + super.onSent(relay, cmdStr, cmd, success) + if (cmd is EventCmd) { + var hasSent = false + + if (outboxDedup.contains(cmd.event.id)) { + liveActivity.value.outboxTargets[relay.url] + ?.eventsSent + ?.update { it + 1 } + hasSent = true + } + if (inboxDedup.contains(cmd.event.id)) { + liveActivity.value.inboxTargets[relay.url] + ?.eventsSent + ?.update { it + 1 } + hasSent = true + } + if (dmDedup.contains(cmd.event.id)) { + liveActivity.value.dmTargets[relay.url] + ?.eventsSent + ?.update { it + 1 } + hasSent = true + } + + if (hasSent) { + runningState.eventsSent.update { it + 1 } + } + } else if (cmd is ReqCmd) { + val currentStatus = liveActivity.value.runningRelays[relay.url]?.status + if (currentStatus?.value != LiveSyncActivity.ConnectionStatus.Querying) { + currentStatus?.tryEmit(LiveSyncActivity.ConnectionStatus.Querying) + } + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is OkMessage && msg.success && msg.message.isBlank()) { + // remove() is atomic: returns non-null only for the first OK per event. + val sourceRelay = sourceRelayOfEvent.remove(msg.eventId) + if (sourceRelay != null) { + liveActivity.value.runningRelays[sourceRelay] + ?.eventsAccepted + ?.update { it + 1 } + } + + if (outboxDedup.contains(msg.eventId)) { + val relayTarget = liveActivity.value.outboxTargets[relay.url] + if (relayTarget != null) { + relayTarget.eventsAccepted.update { it + 1 } + runningState.eventsAccepted.update { it + 1 } + } + } + if (dmDedup.contains(msg.eventId)) { + val relayTarget = liveActivity.value.dmTargets[relay.url] + if (relayTarget != null) { + relayTarget.eventsAccepted.update { it + 1 } + runningState.eventsAccepted.update { it + 1 } + } + } + if (inboxDedup.contains(msg.eventId)) { + val relayTarget = liveActivity.value.inboxTargets[relay.url] + if (relayTarget != null) { + relayTarget.eventsAccepted.update { it + 1 } + runningState.eventsAccepted.update { it + 1 } + } + } + } + } + } + + _syncState.emit(runningState) + + clientBuilder().use { client -> + client.subscribe(okListener) + try { + client.downloadFromPool( + relays = relaysToProcess, + filters = perRelayFilters, + onNewPage = { until, sourceRelay -> + _liveActivity.value.runningRelays[sourceRelay] + ?.pageUntil + ?.tryEmit(until) + }, + onEvent = { event, sourceRelay -> + val isMyEvent = event.pubKey == myPubKey + val mentionsMe = event.tags.isTaggedUser(myPubKey) + val isDmKind = event.kind == 4 || event.kind == 1059 + + val live = liveActivity.value + + var newEvent = false + var matchesAtLeastOneFilter = false + + // Each routing rule is independent: an event can match more than one. + if (isMyEvent && outboxTargets.isNotEmpty()) { + if (outboxDedup.add(event.id)) { + client.send(event, outboxTargets) + newEvent = true + } + matchesAtLeastOneFilter = true + } + if (mentionsMe && isDmKind && dmTargets.isNotEmpty()) { + if (dmDedup.add(event.id)) { + client.send(event, dmTargets) + newEvent = true + } + matchesAtLeastOneFilter = true + } + if (mentionsMe && !isDmKind && inboxTargets.isNotEmpty()) { + if (inboxDedup.add(event.id)) { + client.send(event, inboxTargets) + newEvent = true + } + matchesAtLeastOneFilter = true + } + + if (newEvent) { + sourceRelayOfEvent[event.id] = sourceRelay + } + + if (matchesAtLeastOneFilter) { + runningState.eventsReceived.update { it + 1 } + + live.runningRelays[sourceRelay]?.eventsFound?.update { it + 1 } + live.completedRelays[sourceRelay]?.eventsFound?.update { it + 1 } + } + }, + onRelayStart = { relay -> + _liveActivity.update { + it.copy( + runningRelays = + it.runningRelays + + Pair(relay, SourceRelayInfo(relay, LiveSyncActivity.ConnectionStatus.Connecting, 0, 0)), + ) + } + }, + onRelayComplete = { relay -> + _liveActivity.update { + val newCompleted = it.runningRelays[relay] + it.copy( + runningRelays = it.runningRelays.minus(relay), + completedRelays = + if (newCompleted != null) { + it.completedRelays.plus(relay to newCompleted) + } else { + it.completedRelays + }, + ) + } + + val status = _liveActivity.value.completedRelays[relay]?.status + + if (status?.value !is LiveSyncActivity.ConnectionStatus.Error) { + status?.tryEmit(LiveSyncActivity.ConnectionStatus.Completed) + } + + runningState.relaysCompleted.update { it + 1 } + }, + ) + + _syncState.value = + SyncState.Done( + totalEventsReceived = runningState.eventsReceived.value, + totalEventsSent = runningState.eventsSent.value, + totalEventsAccepted = runningState.eventsAccepted.value, + durationMs = System.currentTimeMillis() - startTime, + sinceFilter = filterSince, + untilFilter = filterUntil, + ) + } catch (e: Exception) { + _syncState.value = + SyncState.Done( + totalEventsReceived = runningState.eventsReceived.value, + totalEventsSent = runningState.eventsSent.value, + totalEventsAccepted = runningState.eventsAccepted.value, + durationMs = System.currentTimeMillis() - startTime, + sinceFilter = filterSince, + untilFilter = filterUntil, + ) + + if (e is CancellationException) throw e + _syncState.value = SyncState.Error(e.message ?: "Unknown error", filterSince, filterUntil) + } finally { + client.unsubscribe(okListener) + } + } + } + + /** + * Maintains a sliding window of up to [MAX_CONCURRENT_RELAYS] active relay workers. + * As soon as one relay finishes (all pages exhausted), the next relay from [relays] + * starts immediately — no waiting for an entire batch to drain. + * + * [onEvent] receives the event and the URL of the relay it came from. + */ + private suspend fun INostrClient.downloadFromPool( + relays: List, + filters: Map>, + onNewPage: (Long, NormalizedRelayUrl) -> Unit, + onEvent: (Event, NormalizedRelayUrl) -> Unit, + onRelayStart: (NormalizedRelayUrl) -> Unit, + onRelayComplete: (NormalizedRelayUrl) -> Unit, + ) { + val semaphore = Semaphore(MAX_CONCURRENT_RELAYS) + supervisorScope { + for (relay in relays) { + if (!isActive) break + semaphore.acquire() + launch { + try { + onRelayStart(relay) + filters[relay]?.let { filtersForRelay -> + downloadFromRelay( + relay = relay, + filters = filtersForRelay, + onNewPage = { onNewPage(it, relay) }, + onEvent = { onEvent(it, relay) }, + ) + } ?: 0 + onRelayComplete(relay) + } finally { + semaphore.release() + } + } + } + } + } + + /** + * Fetches all pages from a single [relay] using paginated `until` cursors. + * Delegates to the Quartz [downloadFromRelay] extension. + * + * @return total number of events received across all pages. + */ + private suspend fun INostrClient.downloadFromRelay( + relay: NormalizedRelayUrl, + filters: List, + onNewPage: (Long) -> Unit, + onEvent: (Event) -> Unit, + ): Int = reqBypassingRelayLimits(relay, filters, RELAY_TIMEOUT_MS, onNewPage, onEvent) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt new file mode 100644 index 0000000000..77b2cafdf2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt @@ -0,0 +1,987 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.Scaffold +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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDotNoDay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun EventSyncScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val syncViewModel = accountViewModel.eventSync + val isMobileOrMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle() + val syncState by syncViewModel.syncState.collectAsStateWithLifecycle() + val liveActivity by syncViewModel.liveActivity.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.event_sync_title), + popBack = nav::popBack, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + EventScreenBody( + syncState = syncState, + liveActivity = liveActivity, + isMobileOrMetered = isMobileOrMetered, + onSinceChanged = { syncViewModel.sinceSecs.value = it }, + onUntilChanged = { syncViewModel.untilSecs.value = it }, + onStart = syncViewModel::start, + onCancel = syncViewModel::cancel, + ) + } + } +} + +@Composable +fun EventScreenBody( + syncState: EventSync.SyncState, + liveActivity: EventSync.LiveSyncActivity, + isMobileOrMetered: Boolean = false, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, + onStart: () -> Unit = {}, + onCancel: () -> Unit = {}, +) { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item { + // ---- Progress / Status area ---- + when (syncState) { + is EventSync.SyncState.Idle -> ExplanationCard(isMobileOrMetered, onSinceChanged, onUntilChanged, onStart) + is EventSync.SyncState.Running -> SyncProgressCard(state = syncState, onCancel) + is EventSync.SyncState.Done -> DoneCard(state = syncState, isMobileOrMetered, onSinceChanged, onUntilChanged, onStart) + is EventSync.SyncState.Error -> ErrorCard(syncState, isMobileOrMetered, onSinceChanged, onUntilChanged, onStart) + } + } + + // ---- Live relay activity (shown during and after sync) ---- + if (liveActivity.outboxTargets.isNotEmpty() || + liveActivity.inboxTargets.isNotEmpty() || + liveActivity.dmTargets.isNotEmpty() + ) { + item { + Spacer(Modifier.height(16.dp)) + DestinationRelaysCard(activity = liveActivity) + } + } + + val runningSize = liveActivity.runningRelays.size + if (runningSize > 0) { + item { + Spacer(Modifier.height(16.dp)) + Text( + text = stringRes(R.string.event_sync_activity_log, runningSize), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(5.dp)) + } + + itemsIndexed(liveActivity.runningRelays.values.toList(), key = { _, item -> item.relay.url }) { index, info -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + ActivityLogRow(info = info) + } + } + + val completedSize = liveActivity.completedRelays.size + if (completedSize > 0) { + item { + Spacer(Modifier.height(16.dp)) + Text( + text = stringRes(R.string.event_sync_activity_log_finished, completedSize), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(5.dp)) + } + + itemsIndexed(liveActivity.sortedCompletedRelays, key = { _, item -> item.relay.url }) { index, info -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + ActivityLogRow(info = info) + } + } + } +} + +@Composable +private fun StartSyncButton( + isMobileOrMetered: Boolean, + onClick: () -> Unit, +) { + var showMobileDataDialog by remember { mutableStateOf(false) } + + Button( + onClick = { + if (isMobileOrMetered) { + showMobileDataDialog = true + } else { + onClick() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.event_sync_start)) + } + + // ---- Mobile-data confirmation dialog ---- + if (showMobileDataDialog) { + AlertDialog( + onDismissRequest = { showMobileDataDialog = false }, + title = { Text(stringRes(R.string.event_sync_mobile_data_dialog_title)) }, + text = { Text(stringRes(R.string.event_sync_wifi_warning)) }, + confirmButton = { + Button( + onClick = { + showMobileDataDialog = false + onClick() + }, + ) { + Text(stringRes(R.string.event_sync_start_anyway)) + } + }, + dismissButton = { + TextButton(onClick = { showMobileDataDialog = false }) { + Text(stringRes(R.string.event_sync_cancel)) + } + }, + ) + } +} + +@Composable +private fun ExplanationCard( + isMobileOrMetered: Boolean, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, + onStart: () -> Unit = {}, +) { + // ---- Explanation card ---- + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringRes(R.string.event_sync_what_happens_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringRes(R.string.event_sync_what_happens_body), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + StepRow(number = "1", text = stringRes(R.string.event_sync_step1)) + Spacer(Modifier.height(4.dp)) + StepRow(number = "2", text = stringRes(R.string.event_sync_step2)) + Spacer(Modifier.height(4.dp)) + StepRow(number = "3", text = stringRes(R.string.event_sync_step3)) + Spacer(Modifier.height(10.dp)) + + // ---- WiFi warning ---- + if (isMobileOrMetered) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Text( + text = stringRes(R.string.event_sync_wifi_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp), + ) + } + Spacer(Modifier.height(10.dp)) + } + DateRangeFilterCard( + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + onSinceChanged = onSinceChanged, + onUntilChanged = onUntilChanged, + ) + Spacer(Modifier.height(14.dp)) + StartSyncButton(isMobileOrMetered = isMobileOrMetered, onStart) + } + } +} + +// ------------------------------------------------------------------------- +// Progress / status cards +// ------------------------------------------------------------------------- + +@Composable +private fun SyncProgressCard( + state: EventSync.SyncState.Running, + onCancel: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + RelayStatement(state) + Spacer(Modifier.height(8.dp)) + EventsReceivedStatement(state) + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringRes(R.string.event_sync_cancel)) + } + } + } +} + +@Composable +private fun EventsReceivedStatement(state: EventSync.SyncState.Running) { + val eventsReceived by state.eventsReceived.collectAsStateWithLifecycle() + val eventsSent by state.eventsSent.collectAsStateWithLifecycle() + val eventsAccepted by state.eventsAccepted.collectAsStateWithLifecycle() + + Text( + text = stringRes(R.string.event_sync_events_sent, eventsAccepted, eventsSent, eventsReceived), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun RelayStatement(state: EventSync.SyncState.Running) { + val relaysCompleted by state.relaysCompleted.collectAsStateWithLifecycle() + val totalRelays by state.totalRelays.collectAsStateWithLifecycle() + + Text( + text = stringRes(R.string.event_sync_relays_progress, relaysCompleted, totalRelays), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { + if (totalRelays > 0) { + relaysCompleted / totalRelays.toFloat() + } else { + 0f + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun DoneCard( + state: EventSync.SyncState.Done, + isMobileOrMetered: Boolean = false, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, + onStart: () -> Unit = { }, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringRes(R.string.event_sync_done_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = stringRes(R.string.event_sync_done_sent, state.totalEventsSent, state.totalEventsReceived), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringRes(R.string.event_sync_done_accepted, state.totalEventsAccepted), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringRes(R.string.event_sync_done_duration, (state.durationMs / 1000).toInt()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + Spacer(Modifier.height(14.dp)) + + DateRangeFilterCard( + backgroundColor = MaterialTheme.colorScheme.primaryContainer, + onSinceStartValue = state.sinceFilter, + onUntilStartValue = state.untilFilter, + onSinceChanged = onSinceChanged, + onUntilChanged = onUntilChanged, + ) + + Spacer(Modifier.height(10.dp)) + StartSyncButton(isMobileOrMetered, onStart) + } + } +} + +@Composable +private fun ErrorCard( + state: EventSync.SyncState.Error, + isMobileOrMetered: Boolean = false, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, + onStart: () -> Unit = {}, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringRes(R.string.event_sync_error_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Spacer(Modifier.height(14.dp)) + + DateRangeFilterCard( + backgroundColor = MaterialTheme.colorScheme.errorContainer, + onSinceStartValue = state.sinceFilter, + onUntilStartValue = state.untilFilter, + onSinceChanged = onSinceChanged, + onUntilChanged = onUntilChanged, + ) + Spacer(Modifier.height(10.dp)) + StartSyncButton(isMobileOrMetered, onStart) + } + } +} + +// ------------------------------------------------------------------------- +// Live activity cards +// ------------------------------------------------------------------------- + +/** + * Shows where events are being sent: outbox, inbox, and DM relay lists. + */ +@Composable +private fun DestinationRelaysCard(activity: EventSync.LiveSyncActivity) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringRes(R.string.event_sync_sending_to), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + if (activity.outboxTargets.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + DestinationSection( + label = stringRes(R.string.event_sync_outbox_relays), + relays = activity.outboxTargets.values, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (activity.inboxTargets.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(10.dp)) + DestinationSection( + label = stringRes(R.string.event_sync_inbox_relays), + relays = activity.inboxTargets.values, + color = MaterialTheme.colorScheme.secondary, + ) + } + + if (activity.dmTargets.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(10.dp)) + DestinationSection( + label = stringRes(R.string.event_sync_dm_relays), + relays = activity.dmTargets.values, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } +} + +@Composable +private fun DestinationSection( + label: String, + relays: Collection, + color: androidx.compose.ui.graphics.Color, +) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + relays.forEachIndexed { index, info -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + DestinationRelayRow(info = info, color = color) + } + } +} + +@Composable +private fun DestinationRelayRow( + info: EventSync.LiveSyncActivity.DestinationRelayInfo, + color: androidx.compose.ui.graphics.Color, +) { + val eventsSent by info.eventsSent.collectAsStateWithLifecycle() + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) + Text( + text = info.relay.displayHost(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + modifier = Modifier.weight(1f), + ) + if (eventsSent > 0) { + val eventsAccepted by info.eventsAccepted.collectAsStateWithLifecycle() + Text( + text = stringRes(R.string.event_sync_log_sent, formatCount(eventsSent)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.3f), + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + ) + Text( + text = stringRes(R.string.event_sync_log_new, formatCount(eventsAccepted)), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal, + color = if (eventsAccepted > 0) color else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.3f), + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + ) + } + } +} + +@Composable +private fun ActivityLogRow(info: EventSync.LiveSyncActivity.SourceRelayInfo) { + val eventsFound by info.eventsFound.collectAsStateWithLifecycle() + val hasEvents = eventsFound > 0 + val dotColor = + if (hasEvents) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + } + val textColor = + if (hasEvents) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(dotColor), + ) + Text( + text = info.relay.displayHost(), + style = MaterialTheme.typography.bodySmall, + color = textColor, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + modifier = Modifier.weight(0.6f), + ) + if (hasEvents) { + val context = LocalContext.current + val untilPage by info.pageUntil.collectAsStateWithLifecycle() + val eventsAccepted by info.eventsAccepted.collectAsStateWithLifecycle() + untilPage?.let { + Text( + text = stringRes(R.string.event_sync_less_than_until, timeAgoNoDotNoDay(it, context)), + style = MaterialTheme.typography.bodySmall, + color = textColor, + modifier = Modifier.weight(0.3f), + maxLines = 1, + textAlign = TextAlign.End, + overflow = TextOverflow.StartEllipsis, + ) + } + Text( + text = stringRes(R.string.event_sync_log_recv, formatCount(eventsFound)), + style = MaterialTheme.typography.bodySmall, + color = textColor, + modifier = Modifier.weight(0.3f), + maxLines = 1, + textAlign = TextAlign.End, + overflow = TextOverflow.StartEllipsis, + ) + Text( + text = stringRes(R.string.event_sync_log_new, formatCount(eventsAccepted)), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal, + color = + if (eventsAccepted > 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + textAlign = TextAlign.End, + overflow = TextOverflow.StartEllipsis, + modifier = Modifier.weight(0.3f), + ) + } else { + val status by info.status.collectAsStateWithLifecycle() + Text( + text = + when (status) { + EventSync.LiveSyncActivity.ConnectionStatus.Connecting -> stringRes(R.string.event_sync_status_connecting) + EventSync.LiveSyncActivity.ConnectionStatus.Querying -> stringRes(R.string.event_sync_status_downloading) + is EventSync.LiveSyncActivity.ConnectionStatus.Error -> (status as EventSync.LiveSyncActivity.ConnectionStatus.Error).msg.ifBlank { stringRes(R.string.event_sync_status_error) } + EventSync.LiveSyncActivity.ConnectionStatus.Completed -> stringRes(R.string.event_sync_status_completed) + }, + style = MaterialTheme.typography.bodySmall, + color = textColor, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + modifier = Modifier.weight(0.45f), + ) + } + } +} + +/** Oldest possible "since" — roughly 2020-01-01 in epoch seconds. */ +private const val EPOCH_2023 = 1_672_549_201L + +@Composable +private fun DateRangeFilterCard( + backgroundColor: Color, + onSinceStartValue: Long? = null, + onUntilStartValue: Long? = null, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, +) { + val now = remember { System.currentTimeMillis() / 1000 } + + // Slider range: 0f = EPOCH_2020, 1f = now + val range = (now - EPOCH_2023).toFloat() + + // Initialise from lastSyncTimestamp when available, otherwise full range + var sliderValues by remember(onSinceStartValue, onUntilStartValue) { + val initialSince = + if (onSinceStartValue != null) { + ((onSinceStartValue - EPOCH_2023) / range).coerceIn(0f, 1f) + } else { + 0f + } + + val finalSince = + if (onUntilStartValue != null) { + ((onUntilStartValue - EPOCH_2023) / range).coerceIn(0f, 1f) + } else { + 1f + } + mutableStateOf(initialSince..finalSince) + } + + // Derive actual timestamps + val sinceEpoch = (EPOCH_2023 + (sliderValues.start * range).toLong()) + val untilEpoch = (EPOCH_2023 + (sliderValues.endInclusive * range).toLong()) + + // Fire callbacks + val effectiveSince = if (sliderValues.start <= 0.001f) null else sinceEpoch + val effectiveUntil = if (sliderValues.endInclusive >= 0.999f) null else untilEpoch + + // Notify parent on each change + LaunchedEffect(effectiveSince) { onSinceChanged(effectiveSince) } + LaunchedEffect(effectiveUntil) { onUntilChanged(effectiveUntil) } + + Card( + modifier = + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringRes(R.string.event_sync_date_filter_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(Modifier.height(12.dp)) + + // Labels for current selection + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = + if (effectiveSince == null) { + stringRes(R.string.event_sync_date_filter_all_time) + } else { + stringRes(R.string.event_sync_date_filter_since) + " " + formatEpochDate(sinceEpoch) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = + if (effectiveUntil == null) { + stringRes(R.string.event_sync_date_filter_now) + } else { + stringRes(R.string.event_sync_date_filter_until) + " " + formatEpochDate(untilEpoch) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Box( + // block left drawer and back gestures when sliding + Modifier.pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + } + }, + ) { + RangeSlider( + value = sliderValues, + onValueChange = { sliderValues = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +private fun formatEpochDate(epochSecs: Long): String { + val sdf = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + return sdf.format(Date(epochSecs * 1000)) +} + +// ------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------- + +@Composable +private fun StepRow( + number: String, + text: String, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "$number.", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +/** Strips the WebSocket scheme and trailing slash for compact display. */ +private fun NormalizedRelayUrl.displayHost(): String = + url + .removePrefix("wss://") + .removePrefix("ws://") + .trimEnd('/') + +/** Formats a count with K/M suffix for large numbers. */ +private fun formatCount(n: Int): String = + when { + n >= 1_000_000 -> "${n / 1_000}K" + else -> n.toString() + } + +// ------------------------------------------------------------------------- +// Preview data +// ------------------------------------------------------------------------- + +private val previewRunning = + listOf( + EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://relay.damus.io"), EventSync.LiveSyncActivity.ConnectionStatus.Querying, 1247, 891), + EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nos.lol"), EventSync.LiveSyncActivity.ConnectionStatus.Querying, 892, 45), + EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nos2.lol"), EventSync.LiveSyncActivity.ConnectionStatus.Connecting, 0, 0), + ) + +private val previewCompletions = + listOf( + EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://relay.nostr.band"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 3500, 3498), + EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://slow.relay.example.com"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 0, 0), + EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nostr.bitcoiner.social"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 15, 0), + EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://unreachable.relay.xyz"), EventSync.LiveSyncActivity.ConnectionStatus.Error("connection failed"), 0, 0), + ) + +private val previewActivity = + EventSync.LiveSyncActivity( + runningRelays = previewRunning, + completedRelays = previewCompletions, + outboxTargets = + listOf( + EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://outbox.nostr.com"), 1247, 891), + EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://relay.damus.io"), 892, 45), + ), + inboxTargets = + listOf( + EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://inbox.nostr.com"), 500, 500), + EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://nos.lol"), 0, 0), + ), + dmTargets = + listOf( + EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://dm.nostr.com"), 15, 10), + ), + ) + +// ------------------------------------------------------------------------- +// Previews +// ------------------------------------------------------------------------- + +@Composable +@Preview +fun IdleCardWifiPreview() { + ThemeComparisonColumn { + ExplanationCard(false) + } +} + +@Composable +@Preview +fun IdleCardMobilePreview() { + ThemeComparisonColumn { + ExplanationCard(true) + } +} + +@Composable +@Preview +fun SyncProgressCardPreview() { + ThemeComparisonColumn { + SyncProgressCard( + state = + EventSync.SyncState.Running( + relaysCompleted = 312, + totalRelays = 1024, + eventsAccepted = 12, + eventsSent = 4821, + eventsReceived = 10000, + ), + onCancel = {}, + ) + } +} + +@Composable +@Preview +fun DoneCardPreview() { + ThemeComparisonColumn { + DoneCard( + state = + EventSync.SyncState.Done( + totalEventsReceived = 20_000, + totalEventsSent = 18_432, + totalEventsAccepted = 14_891, + durationMs = 187_000, + ), + ) + } +} + +@Composable +@Preview +fun ErrorCardPreview() { + ThemeComparisonColumn { + ErrorCard(state = EventSync.SyncState.Error("No outbox, inbox, or DM relays configured.")) + } +} + +@Composable +@Preview +fun DestinationRelaysCardPreview() { + ThemeComparisonColumn { + DestinationRelaysCard(activity = previewActivity) + } +} + +@Composable +@Preview(device = "spec:width=1800px,height=2340px,dpi=440") +fun EventScreenBodyPreview() { + ThemeComparisonRow { + EventScreenBody( + EventSync.SyncState.Idle, + EventSync.LiveSyncActivity(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), + ) + } +} + +@Composable +@Preview(device = "spec:width=1800px,height=2340px,dpi=440") +fun EventScreenBody2Preview() { + ThemeComparisonRow { + EventScreenBody( + EventSync.SyncState.Running(1047, 1224, 100, 4821, 10000), + previewActivity, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt index 1af2490cdf..56dbf8018c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun IndexerRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderIndexerItems( postViewModel: IndexerRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Indexer" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index 8379666245..46da1f83e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -21,8 +21,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @Stable class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { @@ -33,4 +38,16 @@ class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveIndexerRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = R.string.profiles, + filter = Filter(kinds = listOf(MetadataEvent.KIND)), + ), + CountFilter( + label = R.string.relay_settings_lower, + filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index 212e1b3b50..a372d7286b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun PrivateOutboxRelayList( @@ -64,12 +66,14 @@ fun LazyListScope.renderPrivateOutboxItems( postViewModel: PrivateOutboxRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index a3e5f9f41f..1003e95606 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +36,12 @@ class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.savePrivateOutboxRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = R.string.events, + filter = Filter(authors = listOf(account.pubKey)), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 7ac2497f0c..73c201b6bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun Nip65RelayList( @@ -107,12 +109,14 @@ fun LazyListScope.renderNip65HomeItems( postViewModel: Nip65RelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) @@ -133,12 +137,14 @@ fun LazyListScope.renderNip65NotifItems( postViewModel: Nip65RelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteNotifRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 134f7425c9..e6fb7883a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -24,11 +24,16 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import kotlinx.coroutines.Dispatchers @@ -48,6 +53,12 @@ class Nip65RelayListViewModel : ViewModel() { private val _notificationRelays = MutableStateFlow>(emptyList()) val notificationRelays = _notificationRelays.asStateFlow() + private val _homeCountResults = MutableStateFlow>(emptyMap()) + val homeCountResults = _homeCountResults.asStateFlow() + + private val _notifCountResults = MutableStateFlow>(emptyMap()) + val notifCountResults = _notifCountResults.asStateFlow() + var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -58,6 +69,7 @@ class Nip65RelayListViewModel : ViewModel() { fun load() { clear() loadRelayDocuments() + loadCounts() } fun create() { @@ -111,6 +123,51 @@ class Nip65RelayListViewModel : ViewModel() { } } + private fun loadCounts() { + _homeCountResults.value = emptyMap() + _notifCountResults.value = emptyMap() + + val client = Amethyst.instance.client + + _homeRelays.value.forEach { item -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, Filter(authors = listOf(account.pubKey))) + if (result != null) { + val countResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = R.string.events, + count = result.count, + approximate = result.approximate, + ), + ), + ) + _homeCountResults.update { it + (item.relay to countResult) } + } + } + } + + _notificationRelays.value.forEach { item -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, Filter(tags = mapOf("p" to listOf(account.pubKey)))) + if (result != null) { + val countResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = R.string.events, + count = result.count, + approximate = result.approximate, + ), + ), + ) + _notifCountResults.update { it + (item.relay to countResult) } + } + } + } + } + fun clear() { hasModified = false _homeRelays.update { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt index 2997a5c398..a737641f2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun ProxyRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderProxyItems( postViewModel: ProxyRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Proxy" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index 15f6975960..1077d42e2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun SearchRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderSearchItems( postViewModel: SearchRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Search" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index e2e3ba0aa0..2f219c5347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +36,12 @@ class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveSearchRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = R.string.events, + filter = Filter(), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 028b9eec37..d29bb70375 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -90,6 +90,7 @@ class SearchBarViewModel( val listState: LazyListState = LazyListState(0, 0) + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val directNip05Resolver: Flow = searchTerm .debounce(400) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 21760c45e1..1f2473402d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -55,7 +55,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -63,6 +62,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index ef2f01e2ce..16af23f491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -213,7 +213,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = JavaLocale(currentTranslateTo).displayName, + text = JavaLocale.forLanguageTag(currentTranslateTo).displayName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) @@ -227,7 +227,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) { SearchableLanguageList( languages = allLanguages, onSelect = { locale -> - accountViewModel.updateTranslateTo(locale) + accountViewModel.updateTranslateTo(locale.language) showPicker = false }, ) @@ -265,7 +265,7 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) { InputChip( selected = true, onClick = { accountViewModel.removeDontTranslateFrom(languageCode) }, - label = { Text(JavaLocale(languageCode).displayName) }, + label = { Text(JavaLocale.forLanguageTag(languageCode).displayName) }, trailingIcon = { Icon( imageVector = Icons.Default.Close, @@ -367,8 +367,8 @@ private fun LanguagePreferenceCard( preference: String, accountViewModel: AccountViewModel, ) { - val sourceName = JavaLocale(source).displayName - val targetName = JavaLocale(target).displayName + val sourceName = JavaLocale.forLanguageTag(source).displayName + val targetName = JavaLocale.forLanguageTag(target).displayName OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(12.dp)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 27bc8972ad..0b1212fbf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -139,6 +139,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage @@ -190,6 +194,10 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -199,7 +207,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -620,6 +628,14 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) + } else if (noteEvent is AttestationEvent) { + RenderAttestation(baseNote, 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AttestationRequestEvent) { + RenderAttestationRequest(baseNote, 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AttestorRecommendationEvent) { + RenderAttestorRecommendation(baseNote, 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AttestorProficiencyEvent) { + RenderAttestorProficiency(baseNote, 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is AdvertisedRelayListEvent) { DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is LnZapEvent) { @@ -692,7 +708,7 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) - } else if (noteEvent is PollNoteEvent) { + } else if (noteEvent is ZapPollEvent) { RenderZapPoll( baseNote, false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt new file mode 100644 index 0000000000..c0451ede7a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +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.fillMaxSize +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.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.stringRes +import java.text.NumberFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletReceiveScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + DisposableEffect(Unit) { + onDispose { walletViewModel.resetReceiveState() } + } + + val receiveState by walletViewModel.receiveState.collectAsState() + var amountText by remember { mutableStateOf("") } + var descriptionText by remember { mutableStateOf("") } + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_receive)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (val state = receiveState) { + is ReceiveState.Idle -> { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it.filter { c -> c.isDigit() } }, + label = { Text(stringRes(R.string.wallet_amount_sats)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = descriptionText, + onValueChange = { descriptionText = it }, + label = { Text(stringRes(R.string.wallet_description)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + val amount = amountText.toLongOrNull() + if (amount != null && amount > 0) { + walletViewModel.createInvoice( + amountSats = amount, + description = descriptionText.ifBlank { null }, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + enabled = amountText.isNotBlank() && (amountText.toLongOrNull() ?: 0L) > 0, + ) { + Text( + stringRes(R.string.wallet_create_invoice), + fontWeight = FontWeight.SemiBold, + ) + } + } + + is ReceiveState.Creating -> { + Spacer(modifier = Modifier.weight(1f)) + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_creating_invoice), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + is ReceiveState.Created -> { + Spacer(modifier = Modifier.height(8.dp)) + + val formattedAmount = + remember(state.amount) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(state.amount) + } + + Text( + text = "$formattedAmount ${stringRes(R.string.wallet_sats)}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + QrCodeDrawer( + contents = state.invoice, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = state.invoice, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("invoice", state.invoice)) + }, + modifier = + Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_copy_invoice)) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + is ReceiveState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { walletViewModel.resetReceiveState() }) { + Text(stringRes(R.string.back)) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt new file mode 100644 index 0000000000..8ef7878156 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +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.fillMaxSize +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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import java.text.NumberFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel.account) + + val hasWallet by walletViewModel.hasWalletSetup.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + if (!hasWallet) { + NoWalletSetup( + modifier = Modifier.padding(padding), + nav = nav, + ) + } else { + WalletHomeContent( + walletViewModel = walletViewModel, + modifier = Modifier.padding(padding), + nav = nav, + ) + } + } +} + +@Composable +private fun NoWalletSetup( + modifier: Modifier, + nav: INav, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringRes(R.string.wallet_no_connection), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringRes(R.string.wallet_no_connection_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { nav.nav(Route.Nip47NWCSetup()) }) { + Text(stringRes(R.string.wallet_setup)) + } + } +} + +@Composable +private fun WalletHomeContent( + walletViewModel: WalletViewModel, + modifier: Modifier, + nav: INav, +) { + val balance by walletViewModel.balanceSats.collectAsState() + val walletAlias by walletViewModel.walletAlias.collectAsState() + val isLoading by walletViewModel.isLoading.collectAsState() + val error by walletViewModel.error.collectAsState() + + LaunchedEffect(Unit) { + walletViewModel.fetchBalance() + walletViewModel.fetchInfo() + } + + Column( + modifier = + modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Wallet name + if (walletAlias != null) { + Text( + text = walletAlias!!, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Balance display + if (isLoading && balance == null) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } else { + val formattedBalance = + remember(balance) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(balance ?: 0L) + } + Text( + text = formattedBalance, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(R.string.wallet_sats), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Error + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button( + onClick = { nav.nav(Route.WalletReceive) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.ArrowDownward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_receive), fontWeight = FontWeight.SemiBold) + } + + Button( + onClick = { nav.nav(Route.WalletSend) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_send), fontWeight = FontWeight.SemiBold) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Transactions button + OutlinedButton( + onClick = { nav.nav(Route.WalletTransactions) }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_transactions)) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt new file mode 100644 index 0000000000..ca5bd26096 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletSendScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + DisposableEffect(Unit) { + onDispose { walletViewModel.resetSendState() } + } + + val sendState by walletViewModel.sendState.collectAsState() + var invoiceText by remember { mutableStateOf("") } + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_send)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (val state = sendState) { + is SendState.Idle -> { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = invoiceText, + onValueChange = { invoiceText = it }, + label = { Text(stringRes(R.string.wallet_paste_invoice)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + minLines = 3, + maxLines = 5, + trailingIcon = { + IconButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = clipboard.primaryClip + if (clip != null && clip.itemCount > 0) { + invoiceText = clip.getItemAt(0).text?.toString() ?: "" + } + }) { + Icon( + imageVector = Icons.Filled.ContentPaste, + contentDescription = "Paste", + ) + } + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + if (invoiceText.isNotBlank()) { + walletViewModel.sendPayment(invoiceText.trim()) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + enabled = invoiceText.isNotBlank(), + ) { + Text( + stringRes(R.string.wallet_pay), + fontWeight = FontWeight.SemiBold, + ) + } + } + + is SendState.Sending -> { + Spacer(modifier = Modifier.weight(1f)) + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_payment_sending), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + is SendState.Success -> { + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_payment_success), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { nav.popBack() }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Text(stringRes(R.string.back)) + } + Spacer(modifier = Modifier.height(24.dp)) + } + + is SendState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { walletViewModel.resetSendState() }) { + Text(stringRes(R.string.back)) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt new file mode 100644 index 0000000000..60235e15a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +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.fillMaxSize +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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 androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletTransactionsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + walletViewModel.fetchTransactions() + } + + val transactions by walletViewModel.filteredTransactions.collectAsState() + val isLoading by walletViewModel.isLoading.collectAsState() + val isLoadingMore by walletViewModel.isLoadingMore.collectAsState() + val hasMore by walletViewModel.hasMoreTransactions.collectAsState() + val currentFilter by walletViewModel.transactionFilter.collectAsState() + + val listState = rememberLazyListState() + + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleIndex >= totalItems - 5 && !isLoadingMore && hasMore && transactions.isNotEmpty() + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + walletViewModel.loadMoreTransactions() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_transactions)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + actions = { + IconButton(onClick = { walletViewModel.fetchTransactions() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringRes(R.string.wallet_refresh), + ) + } + }, + ) + }, + ) { padding -> + if (isLoading && transactions.isEmpty()) { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_loading), + style = MaterialTheme.typography.bodyLarge, + ) + } + } else if (transactions.isEmpty()) { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + stringRes(R.string.wallet_no_transactions), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.padding(padding), + state = listState, + ) { + item { + TransactionFilterRow(currentFilter) { walletViewModel.setTransactionFilter(it) } + } + items(transactions) { tx -> + TransactionItem(tx, accountViewModel, nav) + HorizontalDivider() + } + if (isLoadingMore) { + item { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + } + } +} + +@Composable +private fun TransactionFilterRow( + currentFilter: TransactionFilter, + onFilterSelected: (TransactionFilter) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = currentFilter == TransactionFilter.ALL, + onClick = { onFilterSelected(TransactionFilter.ALL) }, + label = { Text(stringRes(R.string.wallet_filter_all)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.ZAPS, + onClick = { onFilterSelected(TransactionFilter.ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_zaps)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.NON_ZAPS, + onClick = { onFilterSelected(TransactionFilter.NON_ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_non_zaps)) }, + ) + } +} + +@Composable +private fun TransactionItem( + tx: NwcTransaction, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isIncoming = tx.type == NwcTransactionType.INCOMING + val amountSats = (tx.amount ?: 0L) / 1000L + val formattedAmount = + remember(amountSats) { + val fmt = NumberFormat.getIntegerInstance() + (if (isIncoming) "+" else "-") + fmt.format(amountSats) + } + + val dateText = + remember(tx.created_at) { + tx.created_at?.let { + val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + sdf.format(Date(it * 1000L)) + } ?: "" + } + + val parsed = remember(tx.metadata) { tx.parsedMetadata() } + + // For incoming: show who sent it (nostr pubkey or payer name/email) + // For outgoing: show who received it (nostr recipient or recipient identifier) + val counterpartyPubkeyHex = + remember(parsed) { + if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex() + } + + val counterpartyDisplayName = + remember(parsed) { + if (isIncoming) { + parsed?.senderDisplayName() + } else { + parsed?.recipientIdentifier() + } + } + + // Show comment only if it differs from description + val commentText = + remember(parsed, tx.description) { + parsed?.comment?.let { comment -> + if (tx.description == null || !comment.equals(tx.description, ignoreCase = true)) { + comment + } else { + null + } + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (counterpartyPubkeyHex != null) { + UserPicture( + userHex = counterpartyPubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = Modifier.width(12.dp)) + } else { + Icon( + imageVector = + if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, + contentDescription = + if (isIncoming) { + stringRes(R.string.wallet_incoming) + } else { + stringRes(R.string.wallet_outgoing) + }, + modifier = Modifier.size(40.dp), + tint = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + if (counterpartyPubkeyHex != null) { + TransactionUserName(counterpartyPubkeyHex, counterpartyDisplayName, accountViewModel) + } else if (counterpartyDisplayName != null) { + Text( + text = counterpartyDisplayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (commentText != null) { + Text( + text = commentText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else if (counterpartyPubkeyHex != null || counterpartyDisplayName != null) { + val descOrType = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing) + Text( + text = descOrType, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = dateText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "$formattedAmount sats", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } +} + +@Composable +private fun TransactionUserName( + pubkeyHex: String, + fallbackName: String?, + accountViewModel: AccountViewModel, +) { + LoadUser(baseUserHex = pubkeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Medium, + accountViewModel = accountViewModel, + ) + } else { + Text( + text = fallbackName ?: (pubkeyHex.take(8) + "..."), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt new file mode 100644 index 0000000000..7737923a01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +sealed class SendState { + data object Idle : SendState() + + data object Sending : SendState() + + data class Success( + val preimage: String?, + ) : SendState() + + data class Error( + val message: String, + ) : SendState() +} + +sealed class ReceiveState { + data object Idle : ReceiveState() + + data object Creating : ReceiveState() + + data class Created( + val invoice: String, + val amount: Long, + ) : ReceiveState() + + data class Error( + val message: String, + ) : ReceiveState() +} + +enum class TransactionFilter { + ALL, + ZAPS, + NON_ZAPS, +} + +private const val NWC_TIMEOUT_MS = 30_000L +private const val PAYMENT_FAILED = "Payment failed" + +class WalletViewModel : ViewModel() { + private var account: Account? = null + + private val _hasWalletSetup = MutableStateFlow(false) + val hasWalletSetup = _hasWalletSetup.asStateFlow() + + private val _balanceSats = MutableStateFlow(null) + val balanceSats = _balanceSats.asStateFlow() + + private val _walletAlias = MutableStateFlow(null) + val walletAlias = _walletAlias.asStateFlow() + + private val allTransactions = MutableStateFlow>(emptyList()) + + private val _transactionFilter = MutableStateFlow(TransactionFilter.ALL) + val transactionFilter = _transactionFilter.asStateFlow() + + val filteredTransactions = + combine(allTransactions, _transactionFilter) { txs, filter -> + when (filter) { + TransactionFilter.ALL -> txs + TransactionFilter.ZAPS -> txs.filter { it.parsedMetadata()?.nostr != null } + TransactionFilter.NON_ZAPS -> txs.filter { it.parsedMetadata()?.nostr == null } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore = _isLoadingMore.asStateFlow() + + private val _hasMoreTransactions = MutableStateFlow(true) + val hasMoreTransactions = _hasMoreTransactions.asStateFlow() + + private val pageSize = 20 + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + private val _sendState = MutableStateFlow(SendState.Idle) + val sendState = _sendState.asStateFlow() + + private val _receiveState = MutableStateFlow(ReceiveState.Idle) + val receiveState = _receiveState.asStateFlow() + + private fun launchTimeout(onTimeout: () -> Unit): Job = + viewModelScope.launch(Dispatchers.IO) { + delay(NWC_TIMEOUT_MS) + _error.value = "Wallet request timed out" + onTimeout() + } + + fun init(account: Account) { + this.account = account + _hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup() + } + + fun refreshWalletSetup() { + _hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true + } + + fun fetchBalance() { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + _error.value = null + val timeoutJob = launchTimeout { _isLoading.value = false } + try { + acc.sendNwcRequest(GetBalanceMethod.create()) { response -> + timeoutJob.cancel() + when (response) { + is GetBalanceSuccessResponse -> { + // NWC balance is in millisats, convert to sats + _balanceSats.value = (response.result?.balance ?: 0L) / 1000L + } + + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Balance request failed" + } + + else -> {} + } + _isLoading.value = false + } + } catch (e: Exception) { + timeoutJob.cancel() + _error.value = e.message + _isLoading.value = false + } + } + } + + fun fetchInfo() { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + acc.sendNwcRequest(GetInfoMethod.create()) { response -> + when (response) { + is GetInfoSuccessResponse -> { + _walletAlias.value = response.result?.alias + } + + else -> {} + } + } + } catch (e: Exception) { + // ignore info errors + } + } + } + + fun fetchTransactions() { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + _hasMoreTransactions.value = true + val timeoutJob = launchTimeout { _isLoading.value = false } + try { + acc.sendNwcRequest( + ListTransactionsMethod.create( + limit = pageSize, + offset = 0, + unpaid = false, + ), + ) { response -> + timeoutJob.cancel() + when (response) { + is ListTransactionsSuccessResponse -> { + val txs = response.result?.transactions ?: emptyList() + allTransactions.value = txs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + txs.size < totalCount + } else { + txs.size >= pageSize + } + } + + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Failed to load transactions" + } + + else -> {} + } + _isLoading.value = false + } + } catch (e: Exception) { + timeoutJob.cancel() + _error.value = e.message + _isLoading.value = false + } + } + } + + fun loadMoreTransactions() { + if (_isLoadingMore.value || !_hasMoreTransactions.value) return + val acc = account ?: return + val currentOffset = allTransactions.value.size + viewModelScope.launch(Dispatchers.IO) { + _isLoadingMore.value = true + val timeoutJob = launchTimeout { _isLoadingMore.value = false } + try { + acc.sendNwcRequest( + ListTransactionsMethod.create( + limit = pageSize, + offset = currentOffset, + unpaid = false, + ), + ) { response -> + timeoutJob.cancel() + when (response) { + is ListTransactionsSuccessResponse -> { + val newTxs = response.result?.transactions ?: emptyList() + + allTransactions.value += newTxs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + allTransactions.value.size < totalCount + } else { + newTxs.size >= pageSize + } + } + + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Failed to load more transactions" + } + + else -> {} + } + _isLoadingMore.value = false + } + } catch (e: Exception) { + timeoutJob.cancel() + _error.value = e.message + _isLoadingMore.value = false + } + } + } + + fun sendPayment(bolt11: String) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _sendState.value = SendState.Sending + try { + acc.sendNwcRequest(PayInvoiceMethod.create(bolt11)) { response -> + when (response) { + is PayInvoiceSuccessResponse -> { + _sendState.value = SendState.Success(response.result?.preimage) + // Refresh balance after payment + fetchBalance() + } + + is PayInvoiceErrorResponse -> { + _sendState.value = + SendState.Error( + response.error?.message ?: PAYMENT_FAILED, + ) + } + + is NwcErrorResponse -> { + _sendState.value = + SendState.Error( + response.error?.message ?: PAYMENT_FAILED, + ) + } + + else -> { + _sendState.value = SendState.Error("Unexpected response") + } + } + } + } catch (e: Exception) { + _sendState.value = SendState.Error(e.message ?: PAYMENT_FAILED) + } + } + } + + fun createInvoice( + amountSats: Long, + description: String? = null, + ) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _receiveState.value = ReceiveState.Creating + try { + // NWC expects millisats + acc.sendNwcRequest( + MakeInvoiceMethod.create( + amount = amountSats * 1000L, + description = description, + ), + ) { response -> + when (response) { + is MakeInvoiceSuccessResponse -> { + val invoice = response.result?.invoice + if (invoice != null) { + _receiveState.value = ReceiveState.Created(invoice, amountSats) + } else { + _receiveState.value = ReceiveState.Error("No invoice returned") + } + } + + is NwcErrorResponse -> { + _receiveState.value = + ReceiveState.Error( + response.error?.message ?: "Invoice creation failed", + ) + } + + else -> { + _receiveState.value = ReceiveState.Error("Unexpected response") + } + } + } + } catch (e: Exception) { + _receiveState.value = ReceiveState.Error(e.message ?: "Invoice creation failed") + } + } + } + + fun resetSendState() { + _sendState.value = SendState.Idle + } + + fun resetReceiveState() { + _receiveState.value = ReceiveState.Idle + } + + fun clearError() { + _error.value = null + } + + fun setTransactionFilter(filter: TransactionFilter) { + _transactionFilter.value = filter + } +} diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index fb662a5156..14d9da01d9 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -661,4 +661,5 @@ حذف الكل متأكد من حذف جميع المسودات؟ + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index c15476f541..e371e164f0 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -573,4 +573,5 @@ এখনই দান করুন QR কোড স্ক্যান করুন + diff --git a/amethyst/src/main/res/values-ca-rES/strings.xml b/amethyst/src/main/res/values-ca-rES/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ca-rES/strings.xml +++ b/amethyst/src/main/res/values-ca-rES/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index bb5983ec04..e506a5f42f 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -246,6 +246,7 @@ "Chyba při načítání odpovědí: " Zkusit znovu Zatím žádná oznámení. + Otevřená anketa Zdroj je prázdný. Obnovit vytvořeno @@ -634,6 +635,10 @@ %1$s satů Od %1$s pro %1$s + Odpovědět + Označit jako přečtené + Nové zprávy + Nové zapsy Upozornit: Připojit se ke konverzaci ID uživatele nebo skupiny @@ -669,6 +674,7 @@ Zapisovat do Relay Množství bajtů, které bylo odesláno na toto relé, včetně filtrů a událostí Množství bajtů, které bylo přijato z tohoto relé, včetně filtrů a událostí + Uložené události Při pokusu o získání informací z Relay se vyskytla chyba z %1$s Vlastník Používáno @@ -923,6 +929,8 @@ Ujistěte se, že podepisující aplikace autorizovala tuto transakci Nebyly nalezeny žádné peněženky pro platbu bleskové faktury (Chyba: %1$s). Prosím, nainstalujte si bleskovou peněženku pro použití Zapů Nebyly nalezeny žádné peněženky pro platbu bleskové faktury. Prosím, nainstalujte si bleskovou peněženku pro použití Zapů + Nelze otevřít Blossom odkazy + Nebyly nalezeny žádné aplikace Blossom. Nainstalujte prosím lokální aplikaci Blossom pro zobrazení tohoto souboru Skrytá slova Skrýt nové slovo nebo větu Profilový obrázek @@ -1026,6 +1034,12 @@ Nelze připravit informace v záhlaví: %1$s Komprese zrušena Komprese se nepodařilo vrátit soubor + Šifrovat soubory + Šifrovat soubory před nahráním pro ochranu soukromí. Některé servery nemusí přijímat šifrované soubory na bezplatných účtech. + Šifrované nahrávání selhalo + Mnoho serverů nepřijímá šifrované soubory na bezplatných účtech. Můžete to zkusit znovu bez šifrování. + Zkusit znovu bez šifrování + Upozornění: Bez šifrování může kdokoli s odkazem na soubor vidět jeho obsah. Kvalita médií Vyberte Nízkou kvalitu pro kompresi médií na menší soubor s nižší kvalitou, Vysokou kvalitu pro kompresi na větší soubor s vyšší kvalitou, nebo \"Bez komprese\" pro nahrání médií bez komprese. Nízká @@ -1034,6 +1048,13 @@ Bez komprese Použít kodek H.265/HEVC Lepší kvalita při menší velikosti souboru, ale ne všechna zařízení podporují přehrávání H.265. + Odstranit soukromá metadata + Pokusí se odstranit soukromá metadata z podporovaných mediálních souborů před nahráním + Metadata nelze odstranit + Tento formát souboru nepodporuje odstranění metadat. Mohou být obsaženy soukromé informace, jako je poloha a informace o zařízení. Přesto nahrát? + Přesto nahrát + Nepodařilo se odstranit soukromá metadata z média. Nahrávání zrušeno. + Nahrávání zrušeno Upravit koncept Přihlášení pomocí QR kódu Trasa @@ -1045,6 +1066,32 @@ Globální Krátké Šachy + Peněženka + Zůstatek + Odeslat + Přijmout + Transakce + Žádná peněženka není připojena + Nastavte připojení Nostr Wallet Connect (NWC) v nastavení zapů, abyste mohli peněženku používat. + Nastavit peněženku + sats + Vložte fakturu BOLT-11 + Zaplatit + Platba úspěšná + Odesílání platby… + Částka (sats) + Popis (volitelné) + Vytvořit fakturu + Vytváření faktury… + Kopírovat fakturu + Zatím žádné transakce + Načítání… + Přijato + Odesláno + Obnovit + Vše + Zapy + Ostatní Bezpečnostní filtry Importovat sledované Nový příspěvek @@ -1092,6 +1139,13 @@ Zrušit rozdělení Zap Přidat upozornění na obsah Odstranit upozornění na obsah + Přidat datum vypršení + Odebrat datum vypršení + Datum vypršení + Klienti příspěvek po tomto datu skryjí (NIP-40) + Vyberte datum a čas vypršení + Vyprší za %1$s + Čas vypršení Zobrazit npub jako QR kód Zobrazit nprofile jako QR kód Neplatná adresa @@ -1105,6 +1159,7 @@ Dobré možnosti jsou:\n - auth.nostr1.com (zdarma)\n - inbox.nostr.wine (placeno)\n - relay.0xchat.com (zdarma) Vložte 1–3 relé, která budou sloužit jako vaše soukromá schránka. Relé DM schránky by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Nastavit nyní + Příchozí DM relé příjemce nenalezena. Zprávy nelze doručit, dokud si nenastaví svůj seznam relé. Vyhledávací relé Nastavte svá vyhledávací relé Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky. @@ -1161,6 +1216,9 @@ Blokované Relé Blokované Relé Amethyst se k těmto relé nikdy nepřipojí + Exportovat nastavení relé + Exportovat jako text + Exportovat jako ZIP (JSON) Zapni vývojáře! Váš příspěvek nám pomáhá dělat rozdíl. Každý sat se počítá! Přispět nyní @@ -1253,6 +1311,10 @@ Moje seznamy Uživatelé Vyberte seznam pro filtrování kanálu + Kanály + Hashtagy + Komunity + Seznamy Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1266,6 +1328,19 @@ Vyhledávání hashtag: #%1$s Nepřekládat z Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit. + Přeložit do + Vyberte jazyk, do kterého chcete obsah přeložit. + Předvolby zobrazení jazyka + Pro každý přeložený jazykový pár zvolte, který jazyk zobrazit jako první. + %1$s → %2$s + Hledat jazyky + Přidat jazyk + Přidat jazykový pár + Zdrojový jazyk + Cílový jazyk + Zobrazit %1$s jako první + Zatím žádné předvolby zobrazení jazyka. Vytvoří se automaticky při překladu nebo je můžete přidat ručně. + Smazat předvolbu Pozastavit Hrát Otevřít rozbalovací nabídku @@ -1275,6 +1350,7 @@ Nalezen záznam o pádu Chcete poslat poslední záznam o pádu do Amethystu v soukromé zprávě? Žádné osobní údaje nebudou sdíleny Odeslat + Tato zpráva zmizí za %1$s Tato zpráva zmizí za %1$d dní Vybrat podepisovatele Již v seznamu @@ -1503,4 +1579,84 @@ Vybrat vše %1$d%% dostupnost Nastavení Namecoin + Synchronizace relé + Synchronizace relé + Znovu publikujte své události na všech známých relé, aby byly vaše relé pro odchozí, příchozí a DM zprávy aktuální. Vyžaduje Wi-Fi — může spotřebovat velké množství dat. + Otevřít synchronizaci relé… + Co toto provede + Tento nástroj prohledá všechna relé, která vaše aplikace viděla, a přerozdělí vaše události na správná místa určení: + Stáhne všechny události, které jste vytvořili, a odešle je na vaše odchozí relé. + Stáhne všechny události, které vás zmiňují, a odešle je na vaše příchozí relé. + Stáhne všechny přímé zprávy adresované vám a odešle je na vaše DM relé. + ⚠ Zdá se, že jste na měřeném nebo mobilním připojení. Tato operace může přenést velmi velké množství dat. Před spuštěním se připojte k Wi-Fi. + Použít mobilní data? + Spustit synchronizaci + Spustit přesto (mobilní data) + Pozastavit + Pokračovat + Začít znovu + Zrušit + Relé: %1$d / %2$d + Přerozděleno událostí: %1$d nových z %2$d odeslaných a %3$d přijatých + Synchronizace pozastavena + Dokončeno %1$d z %2$d relé — dosud přerozděleno %3$d událostí. Klepněte na Pokračovat. + Synchronizace dokončena + Předáno %1$d událostí na cílová relé z %2$d přijatých. + %1$d událostí přijato jako nové cílovými relé. + Dokončeno za %1$d sekund. + Chyba synchronizace + Odesílání na + Odchozí + Příchozí + DM + Právě se kontroluje (%1$d relé) + Dokončeno (%1$d relé) + odesláno %1$s + přijato %1$s + nových %1$s + žádné události + Průzkumník Bitcoin (OTS) + události + DMs + profily + nastavení relé + Naposledy viděn před %1$s + <%1$s + Připojování + Stahování + Chyba + Dokončeno + + Označit jako přečtené + Akce poznámky + Akce profilu + Akce médií + Přehrávání + Akce balíčku + Akce seznamu + Akce záložky + Akce skupiny + Přidat záložku + Přidat člena + Správa seznamu + Akce odkazu + Exportovat + Atestace + Platná + Neplatná + Přijata + Zamítnuta + Ověřování + Ověřena + Odvolána + Platná od %1$s + Platná do %1$s + Žádost o atestaci + Žádost o atestaci události + Doporučení atestátora + Doporučen pro druhy: %1$s + Odbornost atestátora + Odborník na ověřování druhů: %1$s + Potvrzuje + Žádosti o atestaci pro diff --git a/amethyst/src/main/res/values-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-da-rDK/strings.xml b/amethyst/src/main/res/values-da-rDK/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-da-rDK/strings.xml +++ b/amethyst/src/main/res/values-da-rDK/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 2684746a0b..2a394810d5 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -250,6 +250,7 @@ anz der Bedingungen ist erforderlich "Fehler beim Laden der Antworten: " Erneut versuchen Noch keine Benachrichtigungen. + Offene Umfrage Feed ist leer. Aktualisieren erstellt @@ -639,6 +640,10 @@ anz der Bedingungen ist erforderlich %1$s Sats Von %1$s für %1$s + Antworten + Als gelesen markieren + Neue Nachrichten + Neue Zaps Benachrichtigen: Unterhaltung beitreten Benutzer- oder Gruppen-ID @@ -674,6 +679,7 @@ anz der Bedingungen ist erforderlich In Relay schreiben Die Menge in Bytes, die an dieses Relais gesendet wurde, einschließlich Filter und Ereignisse Die Menge in Bytes, die von diesem Relais empfangen wurde, einschließlich Filter und Ereignisse + Ereignisse gespeichert Ein Fehler ist beim Abrufen von Relay-Informationen von %1$s aufgetreten Inhaber Verwendet von @@ -928,6 +934,8 @@ anz der Bedingungen ist erforderlich Stellen Sie sicher, dass die Unterzeichner-Anwendung diese Transaktion autorisiert hat Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen (Fehler: %1$s). Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen. Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden + Blossom-Links können nicht geöffnet werden + Keine Blossom-Apps gefunden. Bitte installiere eine lokale Blossom-App, um diese Datei anzuzeigen Versteckte Wörter Neues Wort oder neuen Satz verstecken Profilbild @@ -1031,6 +1039,12 @@ anz der Bedingungen ist erforderlich Konnte Kopfzeileninformationen nicht vorbereiten: %1$s Komprimierung abgebrochen Komprimierung fehlgeschlagen eine Datei zurückzugeben + Dateien verschlüsseln + Dateien vor dem Hochladen verschlüsseln, um die Privatsphäre zu schützen. Einige Server akzeptieren möglicherweise keine verschlüsselten Dateien bei kostenlosen Konten. + Verschlüsselter Upload fehlgeschlagen + Viele Server akzeptieren keine verschlüsselten Dateien bei kostenlosen Konten. Du kannst es ohne Verschlüsselung erneut versuchen. + Ohne Verschlüsselung wiederholen + Warnung: Ohne Verschlüsselung kann jeder mit dem Dateilink den Inhalt sehen. Medienqualität Wählen Sie Niedrige Qualität, um Ihre Medien in eine kleinere Datei mit geringerer Qualität zu komprimieren, Hohe Qualität, um sie in eine größere Datei mit höherer Qualität zu komprimieren, oder \"Ohne Kompression\", um die Medien ohne Kompression hochzuladen. Niedrig @@ -1039,6 +1053,13 @@ anz der Bedingungen ist erforderlich Ohne Kompression H.265/HEVC-Codec verwenden Bessere Qualität bei kleinerer Dateigröße, aber nicht alle Geräte unterstützen die H.265-Wiedergabe. + Private Metadaten entfernen + Versucht, private Metadaten aus unterstützten Mediendateien vor dem Hochladen zu entfernen + Metadaten konnten nicht entfernt werden + Dieses Dateiformat unterstützt das Entfernen von Metadaten nicht. Private Informationen wie Standort und Geräteinformationen können enthalten sein. Trotzdem hochladen? + Trotzdem hochladen + Private Metadaten konnten nicht aus der Mediendatei entfernt werden. Hochladen abgebrochen. + Hochladen abgebrochen Entwurf bearbeiten Einloggen mit QR-Code Route @@ -1050,6 +1071,32 @@ anz der Bedingungen ist erforderlich Global Kurzfilme Schach + Wallet + Guthaben + Senden + Empfangen + Transaktionen + Keine Wallet verbunden + Richte eine Nostr Wallet Connect (NWC)-Verbindung in deinen Zap-Einstellungen ein, um die Wallet zu nutzen. + Wallet einrichten + Sats + BOLT-11-Rechnung einfügen + Bezahlen + Zahlung erfolgreich + Zahlung wird gesendet… + Betrag (Sats) + Beschreibung (optional) + Rechnung erstellen + Rechnung wird erstellt… + Rechnung kopieren + Noch keine Transaktionen + Wird geladen… + Empfangen + Gesendet + Aktualisieren + Alle + Zaps + Keine Zaps Sicherheitsfilter Folgeliste importieren Neuer Beitrag @@ -1097,6 +1144,13 @@ anz der Bedingungen ist erforderlich Zap-Aufteilung abbrechen Inhaltswarnung hinzufügen Inhaltswarnung entfernen + Ablaufdatum hinzufügen + Ablaufdatum entfernen + Ablaufdatum + Der Beitrag wird von Clients nach diesem Datum ausgeblendet (NIP-40) + Ablaufdatum und -uhrzeit auswählen + Läuft ab in %1$s + Ablaufzeit Npub als QR-Code anzeigen nprofile als QR-Code anzeigen Ungültige Adresse @@ -1110,6 +1164,7 @@ anz der Bedingungen ist erforderlich Gute Optionen sind:\n - auth.nostr1.com (gratis)\n - inbox.nostr.wine (bezahlt)\n - relay.0xchat.com (gratis) Fügen Sie 1–3 Relais ein, die als Ihr privater Posteingang dienen sollen. DM Posteingangs-Relais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Jetzt einrichten + DM-Inbox-Relays nicht gefunden. Nachrichten können nicht zugestellt werden, bis die Relay-Liste konfiguriert wurde. Suchrelais Richten Sie Ihre Suchrelais ein Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern. @@ -1166,6 +1221,9 @@ anz der Bedingungen ist erforderlich Blockierte Relays Blockierte Relays Amethyst wird sich niemals mit diesen Relays verbinden + Relay-Einstellungen exportieren + Als Text exportieren + Als ZIP exportieren (JSON) Zap die Entwickler! Deine Spende hilft uns, einen Unterschied zu machen. Jeder Sat zählt! Jetzt spenden @@ -1258,6 +1316,10 @@ anz der Bedingungen ist erforderlich Meine Listen Benutzer Liste zum Filtern des Feeds auswählen + Feeds + Hash-Tags + Gemeinschaften + Listen Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1271,6 +1333,19 @@ anz der Bedingungen ist erforderlich Suche Hashtag: #%1$s Nicht übersetzen von Die hier angezeigten Sprachen werden nicht übersetzt. Wählen Sie eine Sprache, um sie zu entfernen und lassen Sie sie erneut übersetzen. + Übersetzen in + Wähle die Sprache, in die der Inhalt übersetzt werden soll. + Sprachanzeigeeinstellungen + Wähle für jedes übersetzte Sprachpaar, welche Sprache zuerst angezeigt werden soll. + %1$s → %2$s + Sprachen suchen + Sprache hinzufügen + Sprachpaar hinzufügen + Ausgangssprache + Zielsprache + %1$s zuerst anzeigen + Noch keine Sprachanzeigeeinstellungen. Diese werden automatisch bei Übersetzungen erstellt oder können manuell hinzugefügt werden. + Einstellung löschen Pausen Abspielen Dropdown-Menü öffnen @@ -1280,6 +1355,7 @@ anz der Bedingungen ist erforderlich Absturzbericht gefunden Möchten Sie den letzten Absturzbericht per Direktnachricht an Amethyst senden? Es werden keine persönlichen Daten weitergegeben Senden + Diese Nachricht verschwindet in %1$s Diese Nachricht verschwindet in %1$d Tagen Signierer auswählen Bereits in der Liste @@ -1508,4 +1584,84 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen + Relay-Synchronisierung + Relay-Synchronisierung + Veröffentliche deine Ereignisse erneut auf allen bekannten Relays, um deine Outbox-, Inbox- und DM-Relays aktuell zu halten. Erfordert WLAN — dies kann viele Daten verbrauchen. + Relay-Synchronisierung öffnen… + Was passiert + Dieses Tool durchsucht alle Relays, die deine App gesehen hat, und verteilt deine Ereignisse an die richtigen Ziele: + Lade alle Ereignisse herunter, die du erstellt hast, und sende sie an deine Outbox-Relays. + Lade alle Ereignisse herunter, die dich erwähnen, und sende sie an deine Inbox-Relays. + Lade alle Direktnachrichten herunter, die an dich gerichtet sind, und sende sie an deine DM-Relays. + ⚠ Du scheinst eine getaktete oder mobile Verbindung zu nutzen. Dieser Vorgang kann sehr viele Daten übertragen. Verbinde dich vor dem Start mit einem WLAN. + Mobile Daten verwenden? + Synchronisierung starten + Trotzdem starten (mobile Daten) + Pausen + Fortsetzen + Neu starten + Abbrechen + Relais: %1$d / %2$d + Ereignisse verteilt: %1$d neue von %2$d gesendeten und %3$d empfangenen + Synchronisierung pausiert + %1$d von %2$d Relays abgeschlossen — bisher %3$d Ereignisse verteilt. Tippe auf Fortsetzen. + Synchronisierung abgeschlossen + %1$d Ereignisse an Ziel-Relays weitergeleitet von %2$d empfangenen. + %1$d Ereignisse von Ziel-Relays als neu akzeptiert. + Abgeschlossen in %1$d Sekunden. + Synchronisierungsfehler + Senden an + Outbox + Inbox + DMs + Wird geprüft (%1$d Relays) + Abgeschlossen (%1$d Relays) + ges. %1$s + empf. %1$s + neu %1$s + keine Ereignisse + Bitcoin Explorer (OTS) + ereignisse + DMs + profile + relaiseinstellungen + Zuletzt gesehen vor %1$s + <%1$s + Verbinden + Herunterladen + Fehler + Abgeschlossen + + Als gelesen markieren + Notiz-Aktionen + Profilaktionen + Medienaktionen + Wiedergabe + Paket-Aktionen + Listenaktionen + Lesezeichen-Aktionen + Gruppenaktionen + Lesezeichen hinzufügen + Mitglied hinzufügen + Listenverwaltung + Link-Aktionen + Exportieren + Attestierung + Gültig + Ungültig + Akzeptiert + Abgelehnt + Wird verifiziert + Verifiziert + Widerrufen + Gültig ab %1$s + Gültig bis %1$s + Attestierungsanfrage + Attestierung für ein Ereignis anfordern + Empfehlung des Attestierers + Empfohlen für Arten: %1$s + Kompetenz des Attestierers + Kompetent für die Verifizierung von Arten: %1$s + Bestätigt + Beantragt Attestierung für diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index c4e013be18..ebe34bf4e5 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -515,4 +515,5 @@ Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 29171ea5f1..fec8238fe1 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -8,4 +8,5 @@ Show Anyway 👀 + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 69a3a6a64d..1719ad9c97 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -443,4 +443,5 @@ Mesaĝi la Uzanto Okej + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 5ef8dfde6b..ff871b996b 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Imagen de grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Icono del transmisor Autor desconocido @@ -100,10 +103,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Imagen de vista previa para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se ha encontrado la descripción "Sobre nosotros…" @@ -118,15 +123,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Fuente de mensajes privados Fuente de chat público Fuente global Fuente de búsqueda + Buscar y añadir usuario + Añadir un usuario Añadir transmisor Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -140,6 +151,7 @@ Pronombres Dirección LN Dirección LN (antigua) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería La descarga del vídeo ha comenzado… @@ -148,11 +160,14 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Cargar imagen + Cargar archivo Hacer una foto Grabar un video Grabar un mensaje Grabar un mensaje Haz clic y mantén pulsado para grabar un mensaje + Volver a grabar + Grabación Cargando… El usuario no tiene una configuración de dirección Lightning para recibir sats "responde aquí… " @@ -353,6 +368,25 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Icono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores + Transmitir listas de marcadores + Eliminar lista de marcadores + Ver publicaciones + Ver artículos + Ver enlaces + Ver hashtags + Aún no tienes ninguna lista de marcadores. Pulsa el nuevo botón de abajo para crear una. + Publicaciones privadas + Publicaciones privadas(%1$s) + Publicaciones públicas + Eliminar de la lista de marcadores + Los metadatos de las listas de marcadores son visibles para cualquier usuario de Nostr. Solo los miembros privados están cifrados. + Mover a público + Mover a privado Servicio de conexión a monedero Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a monedero @@ -360,11 +394,21 @@ Secreto de conexión a monedero Clave secreta de conexión a monedero clave privada nsec / hex + Conectada + No conectada + Avanzado: introduce manualmente los detalles de la conexión + Cantidad de zap rápido + Aparece al presionar el botón de zap. Pulsa una cantidad para eliminarla. Si la dejas vacía, se abrirá el cuadro de diálogo para introducir una cantidad cada vez. + Privacidad de zaps + Controla cómo se muestra tu identidad al enviar un zap. + Conectar cartera + Ver fuente de relés Destinar cantidad en sats Publicar encuesta Campos obligatorios: Destinatarios de zaps Descripción de encuesta principal… + Opción Opción %s Descripción de opción de encuesta Campos opcionales: @@ -372,6 +416,7 @@ Zap máximo Consenso (0–100)% + Fecha y hora de cierre de la encuesta Cerrar después de días No se puede votar @@ -457,11 +502,26 @@ Añadir autor a conjunto de seguimiento Añadir o eliminar usuario de las listas, o crear una nueva lista con este usuario. + Perfiles privados + miembro + miembros + Sin miembros + Vacío %1$s no se encuentra en esta lista + %1$s no es miembro + Tus listas y %1$s Tus conjuntos de seguimiento No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Pulsa abajo para actualizar o utiliza el menú para crear uno. Ha habido un problema al recuperar: %1$s Crear nueva lista + Nueva lista con membresía de %1$s + Crea un nuevo conjunto de seguimiento y añade a %1$s como miembro de %2$s. + Nueva lista de seguimiento + Nuevo paquete de seguimiento + Copiar/Clonar lista de seguimiento + Modificar descripción + Esta lista no tiene una descripción + Descripción actual: Nombre del conjunto Descripción del conjunto (opcional) Crear conjunto @@ -568,13 +628,27 @@ Contacto NIP compatibles Tasas de admisión + Publicación + Pagos %1$s URL de pagos + Público objetivo + Políticas y enlaces + Comisiones y pagos Limitaciones Países Idiomas Etiquetas + Temas + Todos los países + Todos los idiomas Política de publicación + Política de privacidad + Términos y condiciones Errores y avisos de este relé + Suscripciones activas + Eventos de outbox pendientes + Suscripciones REQ (%1$d) + Suscripciones COUNT (%1$d) Longitud del mensaje Suscripciones Filtros @@ -582,9 +656,20 @@ Prefijo mínimo Etiquetas de evento máximas Longitud del contenido + Tamaño de contenido + Conectividad + Control de acceso PoW mínima Autenticación + Se requiere autorización Pago + Se requiere pago + Longitud máxima del mensaje + Suscripciones máximas + Filtros máximos por suscripción + Límite máximo (devolución de eventos) + Límite predeterminado (devolución de eventos) + Longitud máxima de subID Token de Cashu Canjear Enviar a monedero de zaps @@ -603,6 +688,7 @@ Etiquetas seguidas Relés Paquetes de seguimiento + Estas son listas de usuarios que recomiendas a otras personas. Solo se permiten usuarios públicos. Lecturas Algoritmos de fuentes Mercado @@ -614,8 +700,11 @@ Esta comunidad no tiene descripción. Habla con el propietario para agregar una. Contenido delicado Agrega una advertencia de contenido sensible antes de mostrarlo + Preferencias de interfaz de usuario Preferencias de la app Preferencias de usuario + Traducciones + Reacciones Configuración Siempre Solo Wi-Fi @@ -849,6 +938,9 @@ No se pudo preparar la información del encabezado: %1$s Compresión cancelada La compresión no pudo devolver un archivo + Muchos servidores no aceptan archivos cifrados en cuentas gratuitas. Puedes volver a intentarlo sin cifrado. + Reintentar sin cifrado + Advertencia: Sin cifrado, cualquiera con el enlace del archivo puede ver el contenido. Calidad multimedia Selecciona baja calidad para comprimir tus archivos multimedia a un tamaño más pequeño con menos calidad, alta calidad para comprimir a un archivo más grande con mayor calidad, o sin comprimir para subir los medios sin compresión. Baja @@ -867,7 +959,20 @@ Notificaciones Global Cortos + Ajedrez + Cartera + Saldo + Enviar + Recibir + Transacciones + No hay cartera conectada + Configure una conexión de Nostr Wallet Connect (NWC) en la configuración de zaps para usar la cartera. + Configurar cartera + sats + Pegar una factura BOLT-11 + Pagar Filtros de seguridad + Importar seguidos Nueva publicación Nuevos cortos: imágenes o vídeos Nueva nota comunitaria @@ -880,6 +985,20 @@ Me gusta Zap Cambiar reacciones rápidas + Fila de reacciones + Configura qué botones de reacción se muestran, su orden y si se muestran o no. + Activado + Mostrar recuento + Reordenar + Responder + Responder a esta nota + Impulsar + Volver a publicar o citar esta nota + Me gusta + Reaccionar a esta nota con un emoji + Zap + Enviar un pago de Lightning al autor + Compartir Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -1064,4 +1183,5 @@ Este mensaje desaparecerá en %1$d días Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 1639273c36..8a61866011 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Foto de grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Ícono de relé Autor desconocido @@ -99,10 +102,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Vista previa de imagen de tarjeta para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se encontró la descripción "Quiénes somos…" @@ -117,15 +122,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Feed de mensajes privados Feed del chat público Feed global Feed de búsqueda + Buscar y agregar usuario + Agregar un usuario Agregar un relé Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -139,6 +150,7 @@ Pronombres Dirección de Lightning URL de Lightning (obsoleta) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería Comenzó la descarga del video… @@ -147,10 +159,13 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Subir imagen + Subir archivo Tomar una foto Grabar un mensaje Grabar un mensaje Haz clic y mantén presionado para grabar un mensaje + Volver a grabar + Grabación Subiendo… El usuario no tiene configurada una dirección de Lightning para recibir sats "responde aquí… " @@ -349,6 +364,25 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Ícono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores + Transmitir listas de marcadores + Eliminar lista de marcadores + Ver publicaciones + Ver artículos + Ver enlaces + Ver hashtags + Aún no tienes ninguna lista de marcadores. Toca el nuevo botón de abajo para crear una. + Publicaciones privadas + Publicaciones privadas(%1$s) + Publicaciones públicas + Eliminar de la lista de marcadores + Los metadatos de las listas de marcadores son visibles para cualquier usuario de Nostr. Solo los miembros privados están cifrados. + Mover a público + Mover a privado Servicio de conexión a billetera Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a billetera @@ -356,11 +390,21 @@ Secreto de conexión a billetera Clave secreta de conexión a billetera clave privada nsec / hex + Conectada + No conectada + Avanzado: introduce manualmente los detalles de la conexión + Cantidad de zap rápido + Aparece al presionar el botón de zap. Toca una cantidad para eliminarla. Si la dejas vacía, se abrirá el cuadro de diálogo para introducir una cantidad cada vez. + Privacidad de zaps + Controla cómo se muestra tu identidad al enviar un zap. + Conectar billetera + Ver fuente de relés Destinar cantidad en sats Publicar encuesta Campos obligatorios: Destinatarios de zaps Descripción de encuesta principal… + Opción Opción %s Descripción de opción de encuesta Campos opcionales: @@ -368,6 +412,7 @@ Zap máximo Consenso (0–100)% + Fecha y hora de cierre de la encuesta Cerrar después de días No se puede votar @@ -452,11 +497,26 @@ Agregar autor a conjunto de seguimiento Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario. + Perfiles privados + miembro + miembros + Sin miembros + Vacío %1$s no se encuentra en esta lista + %1$s no es miembro + Tus listas y %1$s Tus conjuntos de seguimiento No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Toca abajo para actualizar o utiliza el menú para crear uno. Hubo un problema al recuperar: %1$s Crear nueva lista + Nueva lista con membresía de %1$s + Crea un nuevo conjunto de seguimiento y añade a %1$s como miembro de %2$s. + Nueva lista de seguimiento + Nuevo paquete de seguimiento + Copiar/Clonar lista de seguimiento + Modificar descripción + Esta lista no tiene una descripción + Descripción actual: Nombre del conjunto Descripción del conjunto (opcional) Crear conjunto @@ -563,13 +623,27 @@ Contacto NIP compatibles Tarifas de admisión + Publicación + Pagos %1$s URL de pagos + Público objetivo + Políticas y enlaces + Comisiones y pagos Limitaciones Países Idiomas Etiquetas + Temas + Todos los países + Todos los idiomas Política de publicación + Política de privacidad + Términos y condiciones + N/D Errores y avisos de este relé + Suscripciones activas + Eventos de outbox pendientes + Suscripciones REQ (%1$d) Longitud del mensaje Suscripciones Filtros @@ -577,9 +651,20 @@ Prefijo mínimo Etiquetas de evento máximas Longitud del contenido + Tamaño de contenido + Conectividad + Control de acceso PoW mínima Autenticación + Se requiere autorización Pago + Se requiere pago + Longitud máxima del mensaje + Suscripciones máximas + Filtros máximos por suscripción + Límite máximo (devolución de eventos) + Límite predeterminado (devolución de eventos) + Longitud máxima de subID Token de Cashu Canjear Enviar a billetera de zaps @@ -598,6 +683,7 @@ Etiquetas seguidas Relés Paquetes de seguimiento + Estas son listas de usuarios que recomiendas a otras personas. Solo se permiten usuarios públicos. Lecturas Algoritmos del feed Mercado @@ -609,8 +695,11 @@ Esta comunidad no tiene descripción. Habla con el propietario para agregar una. Contenido delicado Agrega una advertencia de contenido sensible antes de mostrarlo + Preferencias de interfaz de usuario Preferencias de la app Preferencias de usuario + Traducciones + Reacciones Configuración Siempre Solo Wi-Fi @@ -839,6 +928,9 @@ No se pudo preparar la información del encabezado: %1$s Compresión cancelada La compresión no pudo devolver un archivo + Muchos servidores no aceptan archivos cifrados en cuentas gratuitas. Puedes volver a intentarlo sin cifrado. + Reintentar sin cifrado + Advertencia: Sin cifrado, cualquiera con el enlace del archivo puede ver el contenido. Calidad multimedia Selecciona baja calidad para comprimir tus archivos multimedia a un tamaño más pequeño con menos calidad, alta calidad para comprimir a un archivo más grande con mayor calidad, o sin comprimir para subir los medios sin compresión. Baja @@ -857,7 +949,20 @@ Notificaciones Global Cortos + Ajedrez + Billetera + Saldo + Enviar + Recibir + Transacciones + No hay billetera conectada + Configure una conexión de Nostr Wallet Connect (NWC) en la configuración de zaps para usar la billetera. + Configurar billetera + sats + Pegar una factura BOLT-11 + Pagar Filtros de seguridad + Importar seguidos Nueva publicación Nuevos cortos: imágenes o videos Nueva nota comunitaria @@ -870,6 +975,20 @@ Me gusta Zap Cambiar reacciones rápidas + Fila de reacciones + Configura qué botones de reacción se muestran, su orden y si se muestran o no. + Activado + Mostrar recuento + Reordenar + Responder + Responder a esta nota + Impulsar + Volver a publicar o citar esta nota + Me gusta + Reaccionar a esta nota con un emoji + Zap + Enviar un pago de Lightning al autor + Compartir Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -1040,4 +1159,5 @@ Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index ce5ac0327a..817fc89e87 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Imagen del grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Ícono de relé Autor desconocido @@ -99,10 +102,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Vista previa de imagen de tarjeta para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se encontró la descripción "Quiénes somos…" @@ -117,15 +122,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Feed de mensajes privados Feed del chat público Feed global Feed de búsqueda + Buscar y agregar usuario + Agregar un usuario Agregar un relé Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -139,6 +150,7 @@ Pronombres Dirección de Lightning URL de Lightning (obsoleta) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería Comenzó la descarga del video… @@ -147,10 +159,13 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Subir imagen + Subir archivo Tomar una foto Grabar un mensaje Grabar un mensaje Haz clic y mantén presionado para grabar un mensaje + Volver a grabar + Grabación Subiendo… El usuario no tiene configurada una dirección de Lightning para recibir sats "responde aquí… " @@ -349,6 +364,25 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Ícono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores + Transmitir listas de marcadores + Eliminar lista de marcadores + Ver publicaciones + Ver artículos + Ver enlaces + Ver hashtags + Aún no tienes ninguna lista de marcadores. Toca el nuevo botón de abajo para crear una. + Publicaciones privadas + Publicaciones privadas(%1$s) + Publicaciones públicas + Eliminar de la lista de marcadores + Los metadatos de las listas de marcadores son visibles para cualquier usuario de Nostr. Solo los miembros privados están cifrados. + Mover a público + Mover a privado Servicio de conexión a billetera Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a billetera @@ -356,11 +390,21 @@ Secreto de conexión a billetera Clave secreta de conexión a billetera clave privada nsec / hex + Conectada + No conectada + Avanzado: introduce manualmente los detalles de la conexión + Cantidad de zap rápido + Aparece al presionar el botón de zap. Toca una cantidad para eliminarla. Si la dejas vacía, se abrirá el cuadro de diálogo para introducir una cantidad cada vez. + Privacidad de zaps + Controla cómo se muestra tu identidad al enviar un zap. + Conectar billetera + Ver fuente de relés Destinar cantidad en sats Publicar encuesta Campos obligatorios: Destinatarios de zaps Descripción de encuesta principal… + Opción Opción %s Descripción de opción de encuesta Campos opcionales: @@ -368,6 +412,7 @@ Zap máximo Consenso (0–100)% + Fecha y hora de cierre de la encuesta Cerrar después de días No se puede votar @@ -452,11 +497,26 @@ Agregar autor a conjunto de seguimiento Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario. + Perfiles privados + miembro + miembros + Sin miembros + Vacío %1$s no se encuentra en esta lista + %1$s no es miembro + Tus listas y %1$s Tus conjuntos de seguimiento No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Toca abajo para actualizar o utiliza el menú para crear uno. Hubo un problema al recuperar: %1$s Crear nueva lista + Nueva lista con membresía de %1$s + Crea un nuevo conjunto de seguimiento y añade a %1$s como miembro de %2$s. + Nueva lista de seguimiento + Nuevo paquete de seguimiento + Copiar/Clonar lista de seguimiento + Modificar descripción + Esta lista no tiene una descripción + Descripción actual: Nombre del conjunto Descripción del conjunto (opcional) Crear conjunto @@ -563,13 +623,28 @@ Contacto NIP compatibles Comisiones de admisión + Publicación + Pagos %1$s URL de pagos + Público objetivo + Políticas y enlaces + Comisiones y pagos Limitaciones Países Idiomas Etiquetas + Temas + Todos los países + Todos los idiomas Política de publicación + Política de privacidad + Términos y condiciones + N/D Errores y avisos de este relé + Suscripciones activas + Eventos de outbox pendientes + Suscripciones REQ (%1$d) + Suscripciones COUNT (%1$d) Longitud del mensaje Suscripciones Filtros @@ -577,9 +652,20 @@ Prefijo mínimo Etiquetas de evento máximas Longitud del contenido + Tamaño de contenido + Conectividad + Control de acceso PoW mínima Autenticación + Se requiere autorización Pago + Se requiere pago + Longitud máxima del mensaje + Suscripciones máximas + Filtros máximos por suscripción + Límite máximo (devolución de eventos) + Límite predeterminado (devolución de eventos) + Longitud máxima de subID Token de Cashu Canjear Enviar a billetera de zaps @@ -598,6 +684,7 @@ Etiquetas seguidas Relés Paquetes de seguimiento + Estas son listas de usuarios que recomiendas a otras personas. Solo se permiten usuarios públicos. Lecturas Algoritmos del feed Mercado @@ -609,8 +696,11 @@ Esta comunidad no tiene descripción. Habla con el propietario para agregar una. Contenido delicado Agrega una advertencia de contenido sensible antes de mostrarlo + Preferencias de interfaz de usuario Preferencias de la app Preferencias de usuario + Traducciones + Reacciones Configuración Siempre Solo Wi-Fi @@ -839,6 +929,9 @@ No se pudo preparar la información del encabezado: %1$s Compresión cancelada La compresión no pudo devolver un archivo + Muchos servidores no aceptan archivos cifrados en cuentas gratuitas. Puedes volver a intentarlo sin cifrado. + Reintentar sin cifrado + Advertencia: Sin cifrado, cualquiera con el enlace del archivo puede ver el contenido. Calidad multimedia Selecciona baja calidad para comprimir tus archivos multimedia a un tamaño más pequeño con menos calidad, alta calidad para comprimir a un archivo más grande con mayor calidad, o sin comprimir para subir los medios sin compresión. Baja @@ -857,7 +950,20 @@ Notificaciones Global Cortos + Ajedrez + Billetera + Saldo + Enviar + Recibir + Transacciones + No hay billetera conectada + Configure una conexión de Nostr Wallet Connect (NWC) en la configuración de zaps para usar la billetera. + Configurar billetera + sats + Pegar una factura BOLT-11 + Pagar Filtros de seguridad + Importar seguidos Nueva publicación Nuevos cortos: imágenes o videos Nueva nota comunitaria @@ -870,6 +976,20 @@ Me gusta Zap Cambiar reacciones rápidas + Fila de reacciones + Configura qué botones de reacción se muestran, su orden y si se muestran o no. + Activado + Mostrar recuento + Reordenar + Responder + Responder a esta nota + Impulsar + Volver a publicar o citar esta nota + Me gusta + Reaccionar a esta nota con un emoji + Zap + Enviar un pago de Lightning al autor + Compartir Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -1040,4 +1160,5 @@ Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index ca89c3a9e7..b7918beed9 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -948,4 +948,5 @@ با قفل کردن دستگاه از حساب کاربری خارج شو پیام خصوصی + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index a8aaca7bd9..1e71d96dfb 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -570,4 +570,5 @@ Mediaa ei voitu ladata palvelimelta Paikallista tiedostoa ei voitu valmistella ladattavaksi: %1$s + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index eb90843ab3..378c19b9fa 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -125,6 +125,7 @@ Octets Erreur Erreurs + Pourcentage de connexions réussies vers le relais Le nombre d\'erreurs de connexion dans cette session Vos notifications Flux de messages privés @@ -245,6 +246,7 @@ "Erreur lors du chargement des réponses : " Essayer à nouveau Aucune notification pour le moment. + Ouvrir le sondage Le flux est vide. Rafraîchir créé @@ -633,6 +635,10 @@ %1$s sats De %1$s pour %1$s + Répondre + Marquer comme lu + Nouveaux messages + Nouveaux zaps Notifier: Rejoindre Conversation Utilisateur ou Groupe ID @@ -641,6 +647,7 @@ Rejoindre Aujourd\'hui Avertissement de contenu + Attention : %1$s Ce message contient du contenu sensible que certaines personnes peuvent trouver offensant ou dérangeant Toujours cacher les contenus sensibles Toujours afficher les contenus sensibles @@ -667,6 +674,7 @@ Écrire sur le Relay Le nombre d\'octets qui ont été envoyés à ce relais, y compris les filtres et les événements Le nombre d\'octets qui ont été reçus depuis ce relais, y compris les filtres et les événements + Événements enregistrés Une erreur s\'est produite en récupérant les informations du relay depuis %1$s Propriétaire Utilisé par @@ -700,6 +708,9 @@ N/A Erreurs et Notifications de ce Relais Abonnements actifs + Abonnements REQ (%1$d) + Abonnements COUNT (%1$d) + Aucun abonnement actif pour ce relais %1$d auteurs %1$d ids depuis %1$s @@ -822,6 +833,7 @@ Chargement de la localisation Pas d\'autorisations de localisation Ajoute un avertissement de contenu sensible avant de montrer votre contenu. C\'est idéal pour tout contenu NSFW ou contenu que certaines personnes peuvent trouver offensant ou dérangeant + Raison (optionnel) Nouvelle Fonctionnalité Pour activer ce mode, Amethyst doit envoyer un message NIP-17 (GiftWrapped, Sealed Direct et Group Messages). Le protocole NIP-17 est nouveau et la plupart des clients ne l\'ont pas encore mis en oeuvre. Assurez-vous que le destinataire utilise un client compatible. Activer @@ -906,6 +918,8 @@ Assurez-vous que l\'application signataire a autorisé cette transaction Aucun portefeuille trouvé pour payer une facture lightning (Erreur : %1$s). Veuillez installer un portefeuille Lightning pour utiliser les zaps Aucun portefeuille trouvé pour payer une facture lightning. Veuillez installer un portefeuille Lightning pour utiliser les zaps + Impossible d\'ouvrir les liens Blossom + Les applications Blossom n\'ont pas été trouvées. Veuillez installer une application Blossom locale pour voir ce fichier Mots Masqués Masquer un nouveau mot ou une phrase Photo de profil @@ -1009,6 +1023,7 @@ Impossible de préparer les informations d\'en-tête : %1$s Compression annulée La compression n\'a pas réussi à renvoyer un fichier + Chiffrer les fichiers Qualité des médias Sélectionnez une qualité basse pour compresser vos médias en un fichier plus petit, avec moins de qualité ou sélectionnez qualité haute pour compresser en un fichier plus grand, avec une meilleure qualité. Basse @@ -1017,6 +1032,7 @@ Non compressé Utiliser le codec H.265/HEVC Une meilleure qualité avec des tailles de fichier plus petites, mais tous les appareils ne supportent pas la lecture H.265. + Supprimer les métadonnées privées Modifier le brouillon Se connecter avec un QR Code Route @@ -1028,6 +1044,31 @@ Général Vidéos courtes Échecs + Portefeuille + Solde + Envoyer + Recevoir + Transactions + Aucun portefeuille connecté + Configurer le portefeuille + sats + Coller une facture BOLT-11 + Payer + Paiement réussi + Envoi du paiement… + Montant (sats) + Description (optionnel) + Créer une facture + Création de la facture… + Copier la facture + Pas encore de transactions + Chargement… + Reçu + Envoyé + Rafraîchir + Tout + Zaps + Non Zaps Filtres de Sécurité Nouveau Message Nouveaux Shorts : images ou vidéos @@ -1072,6 +1113,13 @@ Annuler le partage des Zaps Ajouter un avertissement de contenu Retirer l\'avertissement de contenu + Ajouter une date d\'expiration + Supprimer la date d\'expiration + Date d\'expiration + La publication sera masquée par les clients après cette date (NIP-40) + Sélectionner la date et l\'heure d\'expiration + Expire dans %1$s + Date d\'expiration Afficher npub en tant que QR code Afficher le nprofile en tant que code QR Adresse invalide @@ -1137,6 +1185,8 @@ Relais bloqués Relais bloqués Amethyst ne se connectera jamais à ces relais + Exporter en texte + Exporter en ZIP (JSON) Zap les Devs ! Votre don nous aide à faire la différence. Chaque sat compte ! Faire un don maintenant @@ -1229,6 +1279,10 @@ Mes listes Utilisateurs Sélectionnez une liste pour filtrer le fil + Flux + Hashtags + Communautés + Listes Se déconnecter au verrouillage de l\'appareil Message privé Message public @@ -1242,6 +1296,16 @@ Hashtag de recherche : #%1$s Ne pas traduire depuis Les langues affichées ici ne seront pas traduites. Sélectionnez une langue pour la supprimer et l\'avoir traduite à nouveau. + Traduire vers + Choisir la langue dans laquelle traduire le contenu. + Préférences d\'affichage de la langue + %1$s - %2$s + Rechercher des langues + Ajouter une langue + Ajouter une paire de langues + Langue source + Langue cible + Afficher %1$s en premier Pause Lecture Ouvrir le menu déroulant @@ -1273,13 +1337,17 @@ Supprimer la liste Supprimer le pack Kinds + Échec de la nouvelle tentative Appuyer pour afficher les détails %1$s envoyé Diffuser les résultats Diffusions (%1$d) Réduire Agrandir + Échec de la nouvelle tentative (%1$d) +%1$d diffusions supplémentaires + " (%1$d en cours)" + Relais %1$d/%2$d Délai expiré Nouvelle tentative… Réessayer @@ -1296,6 +1364,7 @@ Réaction Message vocal Réponse vocale + Heure de fermeture Confirmer Suivant Ajouter un bouton d\'option de sondage @@ -1320,6 +1389,7 @@ Recommandations d\'applications Paramètres utilisateur Piste audio + Récompenses de badge Relais bloqués Calendrier Rendez-vous @@ -1340,6 +1410,7 @@ Blogs Profil Liste en sourdine + NIP Nostr Connect Listes de personnes Photos @@ -1365,4 +1436,7 @@ Continuer Ignorer pour le moment Tout sélectionner + Recommencer + Annuler + diff --git a/amethyst/src/main/res/values-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e733a94834..d87351af47 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -125,6 +125,7 @@ अष्टक अपक्रम अपक्रम + सफल संयोजनों का प्रतिशत पुनःप्रसारक के साथ संयोजन अपक्रमों की संख्या इस सत्र में मुख्य सूचनावली निजी संदेश सूचनावली @@ -150,6 +151,7 @@ सर्वनाम लै॰जाल पता लै॰जाल पता (पुराना) + संचारयन्त्र में अभिलेखन करें चित्रालय में अभिलेखन करें चित्र का अभिलेखन किया गया चित्रालय क्रमक में चलचित्र अवरोहण आरम्भ हुआ … @@ -158,6 +160,7 @@ चलचित्र को संचारयन्त्र के चलचित्रालय में सुरक्षित रखा गया चलचित्र को सुरक्षित रखने में असफल चित्र आरोहण + अभिलेख आरोहण एक चित्र लें चलचित्र का अभिलेखन करें ऐक संदेश का अभिलेखन करें @@ -243,6 +246,7 @@ "प्रतिवचनों को प्राप्त करने में अपक्रम : " पुनः प्रयास करें अभी तक कोई सूचनाएँ नहीं। + खुला मतदान सूचनावली रिक्त है। नवीकरण बनाया गया @@ -429,6 +433,7 @@ ज्साप गोपनीयता नियन्त्रण करता है कि आपका परिचय कैसे दिखाया जाता है ज्साप भेजने पर। संयोजन धनकोष + पुनःप्रसारक सूचनावली देखें प्रतिज्ञा मात्रा साट्स में मतदान प्रकाशित करें अनिवार्य प्रपत्रस्थान : @@ -634,6 +639,10 @@ %1$s साट्स %1$s से %1$s के लिए + उत्तर + पढा हुआ चिह्नित करें + नए सन्देश + नए ज्साप सूचित करें : संवाद में जुड जाएँ उपयोगकर्ता अथवा झुण्ड विभेदक @@ -642,6 +651,7 @@ जुडें आज विषयवस्तु चेतावनी + सावधान : %1$s इस पत्र में संवेदनशील विषयवस्तु समावेशित है जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है संवेदनशील विषयवस्तु सर्वदा छिपाएँ संवेदनशील विषयवस्तु सर्वदा दिखाएँ @@ -668,8 +678,10 @@ पुनःप्रसारक को लिखकर भेजें अष्टकों में मात्रा जो इस पुनःप्रसारक को भेजा गया था छलनियाँ तथा घटनाएँ समेत अष्टकों में मात्रा जो इस पुनःप्रसारक से प्राप्त हुआ था छलनियाँ तथा घटनाएँ समेत + घटनाएँ रखे गए %1$s से पुनःप्रसारक जानकारी प्राप्त करने के प्रयास में अपक्रम हुआ अधिपति + द्वारा उपयुक्त सेवा कुंजी %1$s चलाया जा रहा है %1$s (%2$s) चलाया जा रहा है @@ -828,6 +840,7 @@ स्थान प्राप्त किया जा रहा है स्थान प्राप्त करने की अनुमति नहीं आपके विषयवस्तु दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है। यह आदर्श है किसी कार्यालय अनुचित विषयवस्तु के लिए अथवा जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है + कारण (विकल्पात्मक) नयी सुविधा इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा निप॰-१७ संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह निप॰-१७ नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं। सक्रिय करें @@ -866,6 +879,7 @@ चित्रों का अवरोहण कब करें चिति की अनुकृति करें टाँकाफलक में अनुकृति करें + टाँकाफलक में एन॰परिचय की अनुकृति करें टाँकाफलक में एनपुब॰ की अनुकृति करें बाँटें अथवा अभिलेखन करें टाँकाफलक में जालपता की अनुकृति करें @@ -920,6 +934,8 @@ सुनिश्चित करें हस्ताक्षर क्रमक ने इस व्यापार को अनुमति दिया कोई धनकोष प्राप्त नहीं लैटनिंग चालान चुकाने के लिए (अपक्रम : %1$s)। कृपया एक लैटनिंग धनकोष की स्थापना करें ज्सापों का प्रयोग करने के लिए कोई धनकोष प्राप्त नहीं लैटनिंग चालान चुकाने के लिए। कृपया एक लैटनिंग धनकोष की स्थापना करें ज्सापों का प्रयोग करने के लिए + ब्लोस्सम॰ योजक खोला नहीं जा सकता + ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए छिपाए गये शब्द नया शब्द अथवा वाक्य छिपाएँ परिचय चित्र @@ -1023,6 +1039,12 @@ शीर्षक जानकारी आयोजित करने में असफल : %1$s संक्षिप्तीकरण निरस्त किया गया संक्षिप्तीकरण से अभिलेख प्राप्त नहीं हुआ + अभिलेख रहस्यीकरण + गोपनीयता के लिए अभिलेखों का रहस्यीकरण करें। कुछ सेवासंगणक रहस्यीकृत अभिलेखों को सम्भाव्यतः अस्वीकार कर सकते हैं निःशुल्क लेखाओं के लिए। + रहस्यीकृत आरोहण असफल + अनेक सेवासंगणक रहस्यीकृत अभिलेखों को स्वीकार नहीं करते निःशुल्क लेखाओं के लिए। आप पुनःप्रयास कर सकते हैं रहस्यीकरण के बिना। + रहस्यीकरण के बिना पुनःप्रयास + सावधान : रहस्यीकरण के बिना कोई भी विषयवस्तु देख सकेगा अभिलेख योजक के साथ। अभिलेख गुणस्तर निम्न गुणस्तर चुनें अपने अभिलेख को अल्प गुणवत्ता युक्त छोटे आकार अभिलेख तक संकुचित करने के लिए अथवा उच्च गुणस्तर चुनें उच्चतर गुणवत्ता युक्त बृहत्तर अभिलेख तक संकुचित करने के लिए। निम्न @@ -1031,6 +1053,13 @@ असंकुचित उच्च कार्यक्षमता चलचित्र संकुचनविस्तारण अथवा एच॰२६५ क्रमलेख का प्रयोग करें अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं। + निजी परितथ्य हटाएँ + निजी परितथ्य हटाने का प्रयास करता है आलम्बित चित्रध्वनिदृश्य अभिलेखों से आरोहण पूर्व + परितथ्य हटाने में असफल + यह अभिलेख प्रारूप परितथ्य हटाने का अवलम्बन नहीं करता। निजी जानकारी जैसे स्थान तथा यन्त्र विवरण समाविष्ट हो सकते हैं। क्या आरोहण करें। + आरोहण करें + अभिलेख से निजी परितथ्य हटाने में असफल। आरोहण निरस्त। + आरोहण निरस्त पाण्डुलिपि का सम्पादन करें क्यूआर॰ क्रमचित्र के साथ प्रवेशांकन करें मार्ग @@ -1042,7 +1071,34 @@ वैश्विक छोटे चतुरंग + धनकोष + शेष + भेजें + प्राप्त करें + लेनदेन + कोई धनकोष संयोजित नहीं + एक नोस्टर धनकोष संयोजन (एनडबल्यूसी॰) स्थापित करें आपके ज्साप स्थापना विकल्पों में धनकोष का उपयोग करने के लिए। + धनकोष की स्थापना करें + साट्स + बोल्ट॰-११ चालान चिपकाएँ + भुगतान करें + भुगतान सफल + भुगतान भेजा जा रहा है… + संख्या (साट्स) + विवरण (विकल्पात्मक) + चालान बनाएँ + चालान बनाया जा रहा है… + चालान अनुकृति + अभी कोई लेनदेन नहीं + आवहन चालू… + प्राप्त + भेजा गया + नवीकरण + सभी + ज्साप + ज्साप अतिरिक्त सुरक्षार्थ छलनियाँ + अनुचरित आयात करें नया पत्र प्रकाशन नये छोटे : चित्र अथवा चलचित्र नया सामुदायिक टीका @@ -1077,8 +1133,8 @@ पुनःप्रसारक सूची चयनकर्ता मतदान मतदान अक्षम करें - बिटकोयिन चालान - बिटकोयिन चालान निरस्त करें + द्व्यंकरूप्य चालान + द्व्यंकरूप्य चालान निरस्त करें वस्तु बिक्री निरस्त करें ज्सापोपार्जन योजना ज्सापोपार्जन योजना निरस्त करें @@ -1088,7 +1144,15 @@ ज्साप विभाजन निरस्त करें विषयवस्तु चेतावनी जोडें विषयवस्तु चेतावनी हटाएँ + समापन दिनांक जोडें + समापन दिनांक हटाएँ + समापन दिनांक + पत्र इस दिनांक के पश्चात ग्राहकों द्वारा छिपाया जाएगा (निप॰४०) + समापन दिनांक तथा समय चुनें + %1$s में समाप्त + समापन समय क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ + क्यूआर॰ क्रमचित्र के रूप में एन॰परिचय को दिखाएँ अमान्य पता अमेथिस्ट को एक वैश्विक वस्तु विभेदक प्राप्त हुआ खोलने के लिए परन्तु वह विभेदक अमान्य था : %1$s सीधा संदेश आगतपेटिका पुनःप्रसारक @@ -1100,6 +1164,7 @@ ये अच्छे विकल्प हैं :\n - auth.nostr1.com (शुल्करहित)\n - inbox.nostr.wine (सशुल्क)\n - relay.0xchat.com (शुल्करहित) निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को सब से सन्देश स्वीकारना चाहिए पर उनका अवरोहण करने की अनुमति केवल आपको देना चाहिए। अभी स्थापना करें + सीधासन्देश आगतपेटिका पुनःप्रसारक अनुपलब्ध। सन्देश नहीं भेजे जा सकते जब तक वे अपने पुनःप्रसारक सूची की समाकृति नहीं करते। खोज पुनःप्रसारक आपके खोज पुनःप्रसारकों की स्थापना करें खोज तथा उपयोगकर्ता सूचक जोडने के लिए स्पष्टतः रूपांकित पुनःप्रसारक सूची बनाने से इन परिणामों में शोधन होगा। @@ -1156,6 +1221,9 @@ बाधित पुनःप्रसारक बाधित पुनःप्रसारक अमेथिस्ट इन पुनःप्रसारकों से कभी नहीं जुडेगा + पुनःप्रसारक स्थापना विकल्प निर्यात + लेख के रूप में निर्यात + ज्सिप॰ (जेसोन॰) के रूप में निर्यात क्रमलेखकों को ज्साप करें! आपका दान हमारा सहायक है परिवर्तन लाने में। प्रत्येक साट गणनीय है! दान करें अभी @@ -1172,7 +1240,7 @@ अनुकृति : ओ॰टी॰एस : %1$s समयांकन प्रमाण - प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया बिटकोयिन खण्डश्रृंखला में उस समय उस दिन पर। + प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। पत्र का सम्पादन करें पत्र शोधन के लिए प्रस्ताव परिवर्तनों का साराम्श @@ -1248,6 +1316,10 @@ मेरे सूचियाँ प्रयोक्ता सूची सूचनावली छानने के लिए सूची चुनें + सूचनावली + विषयसूचक + समुदाय + सूचियाँ यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश सार्वजनिक संदेश @@ -1261,6 +1333,19 @@ विषयसूचक खोज : #%1$s अनुवाद ना करें यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा। + अनुवाद इस में + भाषा चयन करें जिसमें विषयवस्तु का अनुवाद करना है। + भाषा प्रस्तुति आद्यताएँ + प्रत्येक अनुवाद भाषा युग्म के लिए चयन करें किस भाषा को पहले दिखाना है। + %1$s से %2$s + भाषा ढूँढें + भाषा जोडें + भाषा युग्म जोडें + स्रोत भाषा + लक्ष्य भाषा + पहले %1$s दिखाएँ + अभी कोई भाषा प्रस्तुति आद्यताएँ नहीं। ये स्वचालित रूप से बनाए जाएँगे जब अनुवाद होगा अथवा आप इनहें स्वयम हाथ से जोड सकते हैं। + आद्यता मिटाएँ विराम चलाएँ विकल्प सूची खोलें @@ -1270,6 +1355,7 @@ क्रमदोष सूचनापत्र प्राप्त क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी भेजें + यह सन्देश %1$s में अदृश्य हो जाएगा यह सन्देश %1$d दिनों में अदृश्य हो जाएगा हस्ताक्षरकर्ता चुनें पहले से ही सूची में @@ -1393,8 +1479,8 @@ अभिलेख शीर्षक परिचय चित्रालय अभिलेख सेवासंगणक - अंकीय तथ्याभिलेख - अंकीय अभिलेख शीर्षक + द्व्यंकीय अभिलेख + द्व्यंकीय अभिलेख शीर्षक चिकित्सा अभिलेख अनुचरण पोटलियाँ पुनःप्रकाशन (१६) @@ -1471,4 +1557,111 @@ ध्वनि सन्देश ध्वनि उत्तर विकि॰ + एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं। + अनुचरित सूची आयात + प्रयोक्ता चुनें अनुचरण करने के लिए + प्रयोक्ता परिचय जिससे आयात करना है + खोज, एनपुब॰१…, alice@example.com + एनपुब॰ एन॰परिचय निप॰०५ षोडशांक तथा नामरूप्य का आलम्बन करता है (.bit, d/, id/) + अनुचरण सूची देखें + पारितोषिक + %1$d लेखाएँ प्राप्त + %1$d चयनित + नामरूप्य द्वारा सुलझा गया + अब %1$d लेखाएँ अनुचरित + आपकी सूचनावली उपलब्ध है। + छोडें + अन्य खोजें + %1$d लेखाओं का अनुचरण करें + अधिक आयात + चलते रहें + अभी के लिए छोडें + %1$s का सुलझन चालू… + अनुचरण सूची प्राप्त की जा रही है… + कोई अनुचरित नहीं + %1$d लेखाओं का अनुचरण… + "किसी मित्र अथवा समूह नेता का परिचय प्रविष्ट करें। उनके एनपुब॰ अथवा निप॰०५ पता अथवा नामरूप्य नाम जैसे कि alice@example.com अथवा id/alice का उपयोग आप कर सकते हैं खण्डश्रृंखला सत्यापित विभेदकों के लिए।" + सभी चुनें + %1$d%% समय निरन्तर उपलब्ध + नामरूप्य स्थापना विकल्प + पुनःप्रसारक समचरणीकरण + पुनःप्रसारक समचरणीकरण + अपने घटनाओं को पुनःप्रकाशित करें सभी ज्ञात पुनःप्रसारकों तक अपने निर्गतपेटिका आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों का अद्यतन करने के लिए। वैफै॰ आवश्यक। यह सम्भाव्यतः बहुत जानकारी भेज सकता है। + पुनःप्रसारक समचरणीकरण खोलें… + यह क्या करता है + यह उपकरण आपके क्रमक द्वारा देखे गए सभी पुनःप्रसारकों को परखता है तथा आपके घटनाओं को सम्यक गन्तव्यों तक पुनःवितरण करता है : + आपके द्वारा लिखे गए सभी घटनाओं का अवरोहण करें तथा आपके निर्गतपेटिका पुनःप्रसारकों को भेजें। + आपका उल्लेख करनेवाले सभी घटनाओं का अवरोहण करें तथा आपके आगतपेटिका पुनःप्रसारकों को भेजें। + आपके लिए सम्बोधित सभी सीधेसन्देशों का अवरोहण करें तथा आपके सीधासन्देश पुनःप्रसारकों को भेजें। + ⚠ आप एक मात्रांकित अथवा चलनशील संयोजन पर होते हुए दिखते हैं। यह परिचालन बहुत बडी मात्रा में जानकारी भेज सकता है। वैफै॰ के साथ जुडें आरम्भ करने से पहले। + क्या चलनशील संयोजान का प्रयोग करें। + समचरणीकरण आरम्भ करें + आरम्भ करें (चलनशील संयोजन पर भी) + विराम + पुनःचालू + पुनः आरम्भ से + निरस्त करें + पुनःप्रसारक : %1$d / %2$d + घटनाएँ पुनःवितरित : %1$d नए %2$d भेजे गए तथा %3$d प्राप्त + समचरणीकरण विराम + सम्पन्न %2$d में से %1$d पुनःप्रसारक। %3$d घटनाएँ पुनःवितरित अब तक। पुनःचालू दबाएँ चलते रहने के लिए। + समचरणीकरण समाप्त + अग्रतःप्रेषित %2$d प्राप्त में से %1$d घटनाएँ गन्तव्य पुनःप्रसारकों तक। + %1$d घटनाएँ गन्तव्य पुनःप्रसारकों द्वारा नव्यतः स्वीकृत। + %1$d सेकण्ड में सम्पन्न। + समचरणीकरण दोष + इनको भेजा जाएगा + निर्गतपेटिका + आगतपेटिका + सीधेसन्देश + जाँच चालू (%1$d पुनःप्रसारक) + समाप्त (%1$d पुनःप्रसारक) + प्रेषित %1$s + प्राप्त %1$s + नव्य %1$s + कोई घटना नहीं + द्व्यंकरूप्य समन्वेषक (ओटीएस॰) + घटनाएँ + सीधेसन्देश + परिचय + पुनःप्रसारक स्थापना विकल्प + पूर्व दृष्ट %1$s पहले + <%1$s + संयोजन किया जा रहा है + अवरोहण चालू + अपक्रम + सम्पन्न + + पढा हुआ चिह्नित करें + टीका कार्य + परिचय कार्य + अभिलेख कार्य + चालन + पोटली कार्य + सूची कार्य + स्मर्त्तव्यचिह्न कार्य + समूह कार्य + स्मर्त्तव्यचिह्न जोडें + सदस्य जोडें + सूची प्रबन्धन + योजक कार्य + निर्यात + साक्ष्यांकन + मान्य + अमान्य + स्वीकृत + अस्वीकृत + सत्यापन चालू + सत्यापित + निरस्त + %1$s से मान्य + %1$s तक मान्य + साक्ष्यांकन अनुरोध + घटना के लिए साक्ष्यांकन अनुरोध किया जा रहा है + साक्षी अनुशंसा + प्रकारों के लिए अनुशंसित : + साक्षी निपुणता + प्रकारों के सत्यापन में निपुण : %1$s + इस के लिए साक्षी + इस के लिए साक्ष्यांकन अनुरोध diff --git a/amethyst/src/main/res/values-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f385c1347e..21345ede6f 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -61,7 +61,7 @@ Az aláíró nem engedélyezte a művelet végrehajtásához szükséges visszafejtést. Aktiválja az NIP-44 visszafejtést az aláíró alkalmazásban, és próbálja meg újra Nem található az aláíró Az aláíró alkalmazás el lett távolítva? Ellenőrizze, hogy az aláíró telepítve van-e és rendelkezik-e ezzel a fiókkal. Jelentkezzen ki és jelentkezzen be újra, ha az aláíró alkalmazás megváltozott. - Zap + Zap-ek Megtekintések száma Megtolás megtolta @@ -241,11 +241,12 @@ Már van Nostr-fiókja? Új fiók létrehozása Új kulcs előállítása - Hírforrás betöltése… + Hírfolyam betöltése… Fiók betöltése… "Hiba a válaszok betöltésekor: " Próbálja újra Még nincsenek értesítések. + Szavazás megnyitása A hírfolyam üres. Frissítés létrehozva @@ -418,7 +419,7 @@ Áthelyezés a nyilvános könyvjelzőkbe Áthelyezés a privát könyvjelzőkbe Wallet Connect szolgáltatás - Hitelesíti, hogy a Nostr Secret az alkalmazásból való kilépés nélkül fizessen a Zap-et. Tartsa biztonságban a titkot, és lehetőség szerint használjon privát átjátszót + Lehetővé teszi az Amethyst számára, hogy az alkalmazásból kilépés nélkül fizessen. Tartsa biztonságos helyen a titkot! Wallet Connect nyilvános kulcs Wallet Connect átjátszó Wallet Connect titok @@ -638,6 +639,10 @@ %1$s satoshi Tőle: %1$s neki: %1$s + Válasz + Megjelölés olvasottként + Új üzenetek + Új zap-ek Értesítés: Csatlakozás a beszélgetéshez Felhasználó- vagy csoport-azonosító @@ -673,6 +678,7 @@ Írás az átjátszóra Az átjátszónak küldött bájt-mennyiség, beleértve a szűrőket és eseményeket is Az átjátszótól kapott bájt-mennyiség, beleértve a szűrőket és eseményeket is + Tárolt események Hiba lépett fel, amikor megpróbálta lekérni az átjátszó-információt innen: %1$s Tulajdonos Használat a következővel: @@ -765,7 +771,7 @@ Követett csomagok Ezek a listák azon felhasználók listái, akiket másoknak ajánlhat. Csak nyilvános felhasználók szerepelhetnek a listán. Olvasmányok - Hírforrás-algoritmus + Hírfolyam-algoritmus Piac Élő közvetítések Közösségek @@ -1033,6 +1039,12 @@ Nem sikerült előkészíteni a fejlécadatokat: %1$s Tömörítés visszavonva A tömörítés nem tudott visszaadni egy fájlt + Fájlok titkosítása + Az adatok védelme érdekében titkosítsa a fájlokat a feltöltés előtt. Egyes kiszolgálók az ingyenes fiókok esetében nem biztos, hogy elfogadják a titkosított fájlokat. + Titkosított feltöltés sikertelen + Sok kiszolgáló nem fogad el titkosított fájlokat ingyenes fiókok esetén. Próbálja újra titkosítás nélkül. + Újrapróbálás titkosítás nélkül + Figyelem: Titkosítás nélkül bárki láthatja a tartalmat, akinek megvan a fájlhoz tartozó hivatkozás. Média minősége Válassza az alacsony minőséget a média kisebb, de kevésbé jó minőségű fájlba tömörítéséhez, a magas minőséget a nagyobb, de jobb minőségű fájlba tömörítéshez, vagy a tömörítetlen fájlt a tömörítés nélküli feltöltéshez. Alacsony @@ -1041,6 +1053,13 @@ Tömörítetlen H.265/HEVC-kodek használata Jobb minőség kisebb fájlméret mellett, de nem minden eszköz támogatja a H.265 lejátszást. + Privát metaadatok törlése + Kísérletek a támogatott médiafájlokból a privát metaadatok eltávolítására a feltöltés előtt + A metaadatok nem távolíthatók el + Ez a fájlformátum nem támogatja a metaadatok eltávolítását. Lehet, hogy a fájl tartalmaz személyes adatokat, például hely- és eszközadatokat. Mindenképp fel akarja tölteni? + Feltöltés mindenképp + Nem sikerült eltávolítani a médiafájlokból a privát metaadatokat. Feltöltés megszakítva. + Feltölrés megszakítva Piszkozat szerkesztése Bejelentkezés QR-kóddal Útvonal @@ -1052,6 +1071,32 @@ Globális Rövidek Sakk + Pénztárca + Egyenleg + Küldés + Fogadás + Tranzakciók + Nincs pénztárca összekapcsolva + A pénztárca használatához állítson be egy Nostr Wallet Connect (NWC) kapcsolatot a zap-beállításokban. + Pénztárca beállítása + satoshik + Egy BOLT-11 számla beillesztése + Fizetés + Sikeres fizetés + Fizetés küldése… + Összeg (satoshiban) + Leírás (nem kötelező) + Számla létrehozása + Számla létrehozása… + Számla másolása + Még nincsenek tranzakciók + Betöltés… + Fogadott + Elküldött + Frissítés + Összes + Zap-ek + Nem-zap-ek Biztonsági szűrők Követettek importálása Új bejegyzés @@ -1099,6 +1144,13 @@ Zap-megosztások visszavonása Tartalmi figyelmeztetés hozzáadása Tartalmi figyelmeztetés eltávolítása + Lejárati dátum hozzáadása + Lejárati dátum törlése + Lejárati dátum + Ezen dátum után a klienek elrejtik a bejegyzést (NIP-40) + Válassza ki a lejárati dátumot és időpontot + Lejár ekkor: %1$s + Lejárati idő Az npub-kulcs megjelenítése QR-kódként nprofile-kulcs megjelenítése QR-kódként Érvénytelen cím @@ -1112,6 +1164,7 @@ Jó választási lehetőségek:\n - auth.nostr1.com (ingyenes)\n - inbox.nostr.wine (fizetős)\n - relay.0xchat.com (ingyenes) Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljanak. A bejövő privát üzenetek átjátszóinak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Ön engedélyezheti. Beállítás most + Nem találhatók a beérkező közvetlen üzenetek átjátszói. Az üzenetek kézbesítése addig nem lehetséges, amíg be nem állítja az átjátszók listáját. Keresési átjátszók Keresési átjátszók beállítása A kifejezetten a kereséshez és a felhasználói címkézéshez tervezett átjátszólista létrehozása javítani fogja ezeket az eredményeket. @@ -1153,8 +1206,8 @@ Megbízható átjátszók Megbízható átjátszók Az átjátszóknak, amelyekben megbízik, nincs szükségük Tor kapcsolatra a következőhöz: - Kedvenc átjátszó-hírforrások - Kedvenc átjátszó-hírforrások + Kedvenc hírfolyam-átjátszók + Kedvenc hírfolyam-átjátszók Átjátszók, amelyeket gyakran felkeres a globális hírfolyamuk megtekintéséhez Proxyzott átjátszók Proxyzott átjátszók @@ -1168,6 +1221,9 @@ Letiltott átjátszók Letiltott átjátszók Az Amethyst soha nem fog csatlakozni ezekhez az átjátszókhoz + Átjátszóbeállítások exportálása + Exportálás szövegként + Exportálás ZIP-fájlként (JSON) Zap a fejlesztőknek! Az Ön adománya segít nekünk abban, hogy változtassunk a dolgokon. Minden satoshi számít! Adományozás most @@ -1259,7 +1315,11 @@ Saját lista/gyüjtemény Saját listák Felhasználók - Lista kiválasztása a hírfolyam szűréséhez + Szempont kiválasztása a hírfolyam szűréséhez + Hírfolyamok + Hashtagek + Közösségek + Listák Kijelentkeztetés az eszköz zárolása esetén Privát üzenet Nyílvános üzenet @@ -1295,6 +1355,7 @@ Összeomlási jelentés megtalálva Szeretné elküldeni a legutóbbi összeomlási jelentést az Amethystnek egy közvetlen üzenetben? A személyes adatait nem osztja meg Küldés + Ez az üzenet %1$s után eltűnik Ez az üzenet %1$d nap múlva eltűnik Aláíró kiválasztása Már rajta van a listán @@ -1523,4 +1584,84 @@ Összes kijelölése Üzemidő: %1$d%% Namecoin-beállítások + Átjátszószinkronizálás + Átjátszószinkronizálás + Tegye közzé újra az eseményeit az összes ismert átjátszón, hogy a kimenő, beérkező és privát üzenetek átjátszói mindig naprakészek legyenek. Wi-Fi-kapcsolat szükséges – ez jelentős adatforgalmat eredményezhet. + Átjátszószinkronizálás megnyitása… + Hogyan működik + Ez az eszköz átvizsgálja az alkalmazás által eddig észlelt összes átjátszót, és az eseményeket a megfelelő célállomásokra továbbítja: + Töltse le az összes létrehozott eseményt, és küldje el őket a kimenő üzenetek átjátszóinak. + Töltse le az összes olyan eseményt, amely megemlíti Önt, és küldje el őket a beérkező üzenetek átjátszóinak. + Töltse le az összes Önnek címzett közvetlen üzenetet, és küldje el őket a közvetlen üzenetek átjátszóinak. + ⚠ Úgy tűnik, forgalmi díjas vagy mobilinternet-kapcsolatot használ. Ez a művelet nagyon nagy mennyiségű adatot képes átvinni. Indítás előtt csatlakozzon Wi-Fi-hez. + Mobiladat-forgalom használata? + Szinkronizálás indítása + Indítás mindenképpen (mobiladat-forgalom) + Szüneteltetés + Folytatás + Újrakezdés + Mégse + Átjátszók: %1$d / %2$d + Újraelosztott események: %1$d új / %2$d elküldött és %3$d fogadott + Szinkronizálás szüneteltetve + %2$d átjátszóból %1$d teljesítve — %3$d esemény újraelosztása eddig. A folytatáshoz koppintson a folytatás gombra. + Szinkronizálás kész + %1$d esemény továbbítva a célátjátszóknak a fogadott %2$d eseményből. + A célátjátszó által %1$d esemény újként elfogadva. + %1$d másodperc alatt elkészült. + Szinkronizálási hiba + Küldés ide: + Kimenő + Beérkező + Közvetlen üzenetek + Jelenleg ellenőrzés alatt (%1$d átjátszó) + Kész (%1$d átjátszó) + elküldve: %1$s + fogadott: %1$s + új: %1$s + nincsenek események + Bitcoin felfedező (OTS) + események + Közvetlen üzenetek + profilok + átjátszóbeállítások + Utoljára %1$s ezelőtt látták + <%1$s + Kapcsolódás + Letöltés + Hiba + Kész + + Megjelölés olvasottként + Bejegyzésműveletek + Profilműveletek + Médiaműveletek + Lejátszás + Csomagműveletek + Listaműveletek + Könyvjelző-műveletek + Csoportműveletek + Könyvjelző hozzáadása + Tag hozzáadása + Listakezelés + Listaműveletek + Exportálás + Tanúsítás + Érvényes + Érvénytelen + Elfogadva + Elutasítva + Ellenőrzés + Ellenőrizve + Visszavonva + Érvényes ekkortól: %1$s + Érvényes eddig: %1$s + Tanúsításkérés + Tanúsításkérés egy eseményhez + Tanúsító ajánlása + Javasolt az alábbi típusokhoz: + Tanúsítói jártasság + Jártas a következő típusok igazolásában: %1$s + Tanúsítás ehhez: + Tanúsítás kérése ehhez: diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 735d180cbe..4e5b60c383 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -512,4 +512,5 @@ Seharusnya %3$s Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index c84df0a1ac..01cc929ad8 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -433,4 +433,5 @@ Globale Cerca + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index a8b78fd9ae..3fd134573e 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -390,4 +390,5 @@ 自動的にURLプレビューを表示 画像読み込み + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index e35584eb3b..eea0aa7ada 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -172,4 +172,5 @@ Lejupielādēt Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni. + diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index d79ecae4fb..3f51aeb8f0 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -1145,4 +1145,5 @@ Verwijder lijst Verwijder pakket + diff --git a/amethyst/src/main/res/values-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a10363d70b..5f2c6efd61 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -246,6 +246,7 @@ "Błąd wczytywania odpowiedzi: " Spróbuj ponownie Brak powiadomień. + Ankieta Otwarta Brak zawartości. Odśwież stworzył (a) @@ -518,7 +519,7 @@ Obserwowane Wszystkie obserwowane Domyślna lista obserwowanych - Obserwuje przez proxy + Obserwowani przez proxy W pobliżu Wszystkie Szachy @@ -635,6 +636,10 @@ %1$s Satsów Od %1$s dla %1$s + Odpowiedz + Przeczytane + Nowe wiadomości + Nowe zapy Powiadom: Dołącz do dyskusji ID Użytkownika lub Grupy @@ -670,6 +675,7 @@ Zapisz do Transmitera Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia Ilość w bajtach, która została otrzymana z tego transmitera, w tym filtry i wydarzenia + Zapisane zdarzenia Wystąpił błąd podczas próby uzyskania informacji o transmiterze z %1$s Operator Używany przez @@ -925,6 +931,8 @@ Upewnij się, że aplikacja logującego autoryzuje tę operację Nie znaleziono portfeli do zapłacenia faktury z Lightning (Error: %1$s). Proszę zainstalować Lightning wallet, aby używać zapów Nie znaleziono portfeli do zapłacenia faktury z Lightning. Proszę zainstalować Lightning wallet, aby używać zapów + Nie można otworzyć linków Blossom + Nie znaleziono aplikacji Blossom. Zainstaluj lokalną aplikację Blossom, aby wyświetlić ten plik Ukryte słowa Ukryj nowe słowo lub wyrażenie Zdjęcie profilowe @@ -1028,6 +1036,12 @@ Nie można przygotować informacji nagłówkowych: %1$s Kompresja anulowana Kompresja nie powiodła się przy zwracaniu pliku + Szyfrowanie plików + W celu zapewnienia prywatności zaszyfruj pliki przed ich przesłaniem. Niektóre serwery mogą nie akceptować zaszyfrowanych plików na bezpłatnych kontach. + Nie udało się przesłać zaszyfrowanego pliku + Wiele serwerów nie akceptuje plików zaszyfrowanych na kontach bezpłatnych. Możesz spróbować ponownie bez szyfrowania. + Ponów bez szyfrowania + Ostrzeżenie: Bez szyfrowania każdy, kto posiada link do pliku, może zobaczyć jego zawartość. Jakość Załącznika Wybierz Niską jakość, aby skompresować media do mniejszego pliku o mniejszej jakości lub wybierz Wysoką jakość, aby skompresować do większego pliku o wyższej jakości. Niska @@ -1036,6 +1050,13 @@ Nieskompresowane Użyj kodeka H.265/HEVC Lepsza jakość przy mniejszych rozmiarach plików, lecz nie wszystkie urządzenia obsługują odtwarzanie H.265. + Usuń prywatne metadane + Próba usunięcia prywatnych metadanych z obsługiwanych plików multimedialnych przed przesłaniem + Metadane nie mogą zostać usunięte + Ten format pliku nie obsługuje usuwania metadanych. Plik może zawierać dane osobowe, takie jak informacje o lokalizacji i urządzeniu. Czy mimo to chcesz go przesłać? + Prześlij mimo wszystko + Nie udało się usunąć prywatnych metadanych z plików multimedialnych. Przesyłanie anulowane. + Anulowano przesyłanie Edytuj wersję roboczą Zaloguj się przy użyciu QR kodu Ścieżka @@ -1047,8 +1068,34 @@ Wszystkie Filmiki Szachy + Portfel + Saldo + Wyślij + Odbierz + Transakcje + Nie podłączono portfela + Aby korzystać z portfela, skonfiguruj połączenie Nostr Wallet Connect (NWC) w ustawieniach zap. + Skonfiguruj portfel + satosze + Wstaw fakturę BOLT-11 + Zapłać + Płatność udana + Wysyłanie zapłaty… + Kwota (satoszy) + Opis (opcjonalnie) + Utwórz fakturę + Tworzenie faktury… + Kopiuj fakturę + Brak dostępnych transakcji + Wczytywanie… + Otrzymano + Wysłano + Odśwież + Wszystkie + Zapy + Bez zapów Filtry bezpieczeństwa - Importuj Obserwujących + Import Obserwowanych Nowy post Nowe Króciaki: zdjęcia lub filmiki Nowy wpis w społeczności @@ -1056,7 +1103,7 @@ Nowy GEO-ekskluzywny Wpis Otwórz wszystkie odzewy na ten post Zamknij wszystkie odzewy na ten post - Odpowiedz + Odpowiedź Powtórz lub Zacytuj Lubię Zap @@ -1114,6 +1161,7 @@ Dobre opcje to:\n - auth.nostr1.com (darmowe)\n - inbox.nostr.wine (płatne)\n - relay.0xchat.com (darmowe) Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Transmitery DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Konfiguruj + Nie znaleziono transmiterów skrzynki odbiorczej prywatnych wiadomości. Wiadomości nie mogą zostać dostarczone, dopóki nie skonfigurują listy transmiterów. Transmitery wyszukujące Skonfiguruj transmitery wyszukujące Stworzenie listy transmiterów specjalnie przeznaczonych do wyszukiwania i tagowania użytkowników poprawi te wyniki. @@ -1265,6 +1313,10 @@ Moje listy Użytkownicy Wybierz listę profili, aby filtrować aktualności + Kanały + Hashtagi + Społeczności + Listy Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość @@ -1282,6 +1334,7 @@ Wybierz język, na który chcesz przetłumaczyć treść. Ustawienia językowe Dla każdej pary językowej wybierz, który język ma być wyświetlany jako pierwszy. + %1$s - %2$s Wyszukiwanie Języków Dodaj język Dodaj pary językowe @@ -1299,6 +1352,7 @@ Znaleziono raport o błędzie Czy chcesz wysłać ostatni raport o awarii do Amethyst w DM? Żadne dane osobowe nie będą udostępnione Prześlij + Ta wiadomość zniknie za %1$s Ta wiadomość zniknie za %1$d dni Wybierz Sygnatariusza Już jest na liście @@ -1391,7 +1445,13 @@ Spotkanie RSVP Spotkania Szachy + Autoryzacja szachów Ulubione Transmitery + Wyzwania w szachach + Akceptuj Szachy + Ruch szachów + Koniec gry + Propozycja remisu szachowego Określenie kanału Ukryta wiadomość kanału Lista kanałów @@ -1403,12 +1463,16 @@ Transmitery DM Ogłoszenia Komentarze + Def Społeczności Lista społeczności + Post społeczności Lista obserwowanych Usunięcia Wersje robocze Pakiety emoji Lista Pakietów Emoji + Czat efemeryczny + Efemeryczne pokoje czatowe Nagłówek pliku Galeria profilu Serwery Plików @@ -1425,20 +1489,33 @@ Cele Zap-a Obserwacja hashtagów Najważniejsze informacje + Autoryzacja http Indeks listy transmiterów + Wstęp do przygody + Scena Przygody + Lektura przygody Opisane zakładki + Czaty na żywo + Na żywo Zapy Zapytanie NWC Odpowiedź NWC Prywatne Zapy + Sugestia zapa Blogi Pokój spotkań + Stan pokoju Strefa spotkań Profil Lista zablokowanych NNS NIP Nostr Connect + Status DVM + Żądania treści DVM + Treść odpowiedzi DVM + Żądanie użytkownika DVM + Odpowiedź użytkownika DVM OTS Płatność dla Listy osób @@ -1485,14 +1562,101 @@ Sprawdź listę obserwowanych Znaleziono %1$d kont(a) Wybrano: %1$d + Rozwiązane przez Namecoin Teraz obserwujesz %1$d kont(a) Twój kanał jest gotowy. Pomiń + Szukaj innego + Obserwuj %1$d konta Pobierz więcej Kontynuuj Na razie pomiń + Rozwiązywanie %1$s… + Pobieranie listy obserwowanych… Nie znaleziono obserwowanych Obserwujesz %1$d kont(a)… "Wprowadź profil znajomego lub lidera społeczności. Możesz użyć ich npub-u, adresu NIP-05 lub nazwy jak alicja@domena.pl lub id/alicja dla zweryfikowanych przez blockchain tożsamości." Wybierz Wszystkie + Czas działania %1$d%% + Ustawienia Namecoin + Synchronizacja Transmitera + Synchronizacja Transmitera + Opublikuj ponownie swoje wpisy na wszystkich znanych transmiterach, aby zaktualizować transmitery odbiorcze, nadawcze i wiadomości prywatnych. Wymagane połączenie Wi-Fi — może to spowodować zużycie dużej ilości danych. + Otwórz transmiter synchronizacji… + Jak to działa + To narzędzie skanuje wszystkie transmitery, z którymi zetknęła się Twoja aplikacja, i przekierowuje zdarzenia do właściwych miejsc docelowych: + Pobierz wszystkie wydarzenia, których jesteś autorem, i wyślij je do transmiterów nadawczych. + Pobierz wszystkie wydarzenia, w których jesteś wymieniony, i prześlij je do swoich transmiterów odbiorczych. + Pobierz wszystkie bezpośrednie wiadomości adresowane do Ciebie i wyślij je do swoich transmiterów DM. + ⚠️ Wygląda na to, że korzystasz z połączenia komórkowego lub z limitem transferu danych. Ta operacja może wymagać przesłania bardzo dużej ilości danych. Przed rozpoczęciem podłącz się do sieci Wi-Fi. + Włączyć transmisje danych komórkowych? + Rozpocznij synchronizację + Uruchom mimo to (transmisja danych komórkowych) + Pauza + Wznów + Zacznij od początku + Anuluj + Transmitery: %1$d / %2$d + Wydarzenia rozdystrybuowane: %1$d nowych z %2$d wysłanych i %3$d otrzymanych + Synchronizacja wstrzymana + Ukończono %1$d z %2$d transmiterów — %3$d wydarzeń rozdystrybuowane to tej pory. Naciśnij „Wznów”, aby kontynuować. + Synchronizacja zakończona + Przesłano %1$d wydarzeń do transmiterów docelowych spośród %2$d. + %1$d wydarzeń zaakceptowanych jako nowe przez docelowe transmitery. + Zakończono w %1$d sek. + Błąd synchronizacji + Wysyłanie do + Skrzynka nadawcza + Skrzynka odbiorcza + DMs + Trwa sprawdzanie (%1$d transmiterów) + Ukończono (%1$d transmiterów) + wysłano %1$s + odebrano %1$s + nowa %1$s + brak wydarzeń + Eksplorator Bitcoin (OTS) + wydarzenia + DMs + profile + ustawienia transmiterów + Ostatnio widziano %1$s temu + <%1$s + Łączenie + Pobieranie + Błąd + Zakończone + + Oznacz jako Przeczytane + Akcje wpisu + Akcje profilu + Akcje multimediów + Odtwarzanie + Akcje pakietu + Akcje listy + Akcje zakładki + Akcje grupy + Dodaj do zakładek + Dodaj uczestnika + Zarządzanie listą + Akcje linku + Eksport + Certyfikat + Ważny + Nieważny + Zaakceptowany + Odrzucony + Weryfikacja + Zweryfikowany + Unieważniony + Ważny od %1$s + Ważny do %1$s + Wniosek o certyfikat + Wniosek o certyfikację wydarzenia + Rekomendacja certyfikatora + Zalecane dla typów: + Poziom biegłości certyfikatora + Biegłość w sprawdzaniu typów: %1$s + Certyfikat dla + Żądanie certyfikatu do diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7a20de62c0..810d910b2f 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -246,6 +246,7 @@ "Erro ao carregar respostas" Tente novamente Ainda não há notificações. + Enquete aberta Feed está vazio Atualizar criado @@ -634,6 +635,10 @@ %1$s sats De %1$s por %1$s + Responder + Marcar como lida + Novas mensagens + Novos zaps Notificar: Entrar na conversa ID do usuário ou grupo @@ -669,6 +674,7 @@ Enviar para o Relay A quantidade em bytes que foi enviada para este relé, incluindo filtros e eventos A quantidade em bytes que foi recebida deste relé, incluindo filtros e eventos + Eventos armazenados Ocorreu um erro ao tentar obter informações do relay de %1$s Proprietário Usado por @@ -923,6 +929,8 @@ Certifique-se de que a aplicação assinante autorizou esta transação Nenhuma carteira encontrada para pagar uma fatura Lightning (Erro: %1$s). Instale uma carteira Lightning para usar zaps Nenhuma carteira encontrada para pagar uma fatura Lightning. Instale uma carteira Lightning para usar zaps + Não é possível abrir links Blossom + Nenhum aplicativo Blossom foi encontrado. Instale um aplicativo Blossom local para visualizar este arquivo Palavras Ocultas Ocultar nova palavra ou frase Foto de Perfil @@ -1026,6 +1034,12 @@ Não foi possível preparar informações do cabeçalho: %1$s Compressão cancelada Compressão falhou ao retornar um arquivo + Criptografar arquivos + Criptografar arquivos antes do upload para privacidade. Alguns servidores podem não aceitar arquivos criptografados em contas gratuitas. + Falha no upload criptografado + Muitos servidores não aceitam arquivos criptografados em contas gratuitas. Você pode tentar novamente sem criptografia. + Tentar novamente sem criptografia + Aviso: Sem criptografia, qualquer pessoa com o link do arquivo pode ver o conteúdo. Qualidade de Mídia Selecione Baixa qualidade para comprimir sua mídia para um arquivo menor com menor qualidade, Alta qualidade para comprimir para um arquivo maior com maior qualidade ou \"Sem compressão\" para carregar a mídia sem compressão. Baixa @@ -1034,6 +1048,13 @@ Sem compressão Usar codec H.265/HEVC Melhor qualidade em arquivos menores, mas nem todos os dispositivos suportam reprodução em H.265. + Remover metadados privados + Tenta remover metadados privados de arquivos de mídia compatíveis antes do envio + Não foi possível remover os metadados + Este formato de arquivo não suporta a remoção de metadados. Informações privadas como localização e dados do dispositivo podem estar incluídas. Enviar mesmo assim? + Enviar mesmo assim + Não foi possível remover metadados privados da mídia. Envio cancelado. + Envio cancelado Editar rascunho Entrar com Código QR Rota @@ -1045,6 +1066,32 @@ Global Vídeos Curtos Xadrez + Carteira + Saldo + Enviar + Receber + Transações + Nenhuma carteira conectada + Configure uma conexão Nostr Wallet Connect (NWC) nas configurações de zap para usar a carteira. + Configurar Carteira + sats + Cole uma fatura BOLT-11 + Pagar + Pagamento bem-sucedido + Enviando pagamento… + Valor (sats) + Descrição (opcional) + Criar Fatura + Criando fatura… + Copiar Fatura + Nenhuma transação ainda + Carregando… + Recebido + Enviado + Atualizar + Todos + Zaps + Outros Filtros de Segurança Importar Seguidos Novo Post @@ -1092,6 +1139,13 @@ Cancelar Divisão Zap Adicionar aviso de conteúdo Remover aviso de conteúdo + Adicionar data de expiração + Remover data de expiração + Data de expiração + A publicação será ocultada pelos clientes após esta data (NIP-40) + Selecionar data e hora de expiração + Expira em %1$s + Hora de expiração Mostrar npub como um código QR Mostrar nprofile como QR code Endereço inválido @@ -1105,6 +1159,7 @@ Opções boas são:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (gratuito) Insira entre 1–3 relés para servir como sua caixa de entrada privada. Relés de Caixa de Entrada de DM devem aceitar qualquer mensagem de qualquer pessoa, mas permitir apenas você a baixá-las. Configurar agora + Relays de entrada de DM não encontrados. As mensagens não podem ser entregues até que a lista de relays seja configurada. Relés de Pesquisa Configurar seus relés de Pesquisa Criar uma lista de relés especificamente projetada para pesquisa e marcação de usuários melhorará esses resultados. @@ -1161,6 +1216,9 @@ Relays bloqueados Relays bloqueados O Amethyst nunca se conectará a esses relays + Exportar configurações de relay + Exportar como texto + Exportar como ZIP (JSON) Zap os desenvolvedores! Sua doação nos ajuda a fazer a diferença. Cada sat conta! Doar agora @@ -1253,6 +1311,10 @@ Minhas listas Usuários Selecione uma lista para filtrar o feed + Feeds + Hashtags + Comunidades + Listas Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1266,6 +1328,19 @@ Pesquisar hashtag: #%1$s Não Traduzir de Os idiomas mostrados aqui não serão traduzidos. Selecione um idioma para removê-lo e traduzi-lo novamente. + Traduzir para + Escolha o idioma para o qual o conteúdo será traduzido. + Preferências de exibição de idioma + Para cada par de idiomas traduzido, escolha qual idioma exibir primeiro. + %1$s → %2$s + Buscar idiomas + Adicionar idioma + Adicionar par de idiomas + Idioma de origem + Idioma de destino + Mostrar %1$s primeiro + Nenhuma preferência de exibição de idioma ainda. Elas são criadas automaticamente quando ocorrem traduções, ou você pode adicioná-las manualmente. + Excluir preferência Pausar Reproduzir Abrir menu suspenso @@ -1275,6 +1350,7 @@ Relatório de falha encontrado Gostaria de enviar o relatório de falha recente para o Amethyst em uma DM? Nenhuma informação pessoal será compartilhada Enviar + Esta mensagem desaparecerá em %1$s Esta mensagem desaparecerá em %1$d dias Selecionar assinador Já está na lista @@ -1503,4 +1579,84 @@ Selecionar tudo %1$d%% de disponibilidade Configurações do Namecoin + Sincronização de Relays + Sincronização de Relays + Republique seus eventos em todos os relays conhecidos para manter seus relays de saída, entrada e DM atualizados. Requer Wi-Fi — pode consumir muitos dados. + Abrir sincronização de relays… + O que isso faz + Esta ferramenta verifica todos os relays que seu app conhece e redistribui seus eventos para os destinos corretos: + Baixar todos os eventos que você criou e enviá-los para seus relays de saída. + Baixar todos os eventos que mencionam você e enviá-los para seus relays de entrada. + Baixar todas as mensagens diretas endereçadas a você e enviá-las para seus relays de DM. + ⚠ Você parece estar em uma conexão limitada ou móvel. Esta operação pode transferir uma quantidade muito grande de dados. Conecte-se ao Wi-Fi antes de iniciar. + Usar dados móveis? + Iniciar sincronização + Iniciar mesmo assim (dados móveis) + Pausar + Retomar + Recomeçar + Cancelar + Relés: %1$d / %2$d + Eventos redistribuídos: %1$d novos de %2$d enviados e %3$d recebidos + Sincronização pausada + %1$d de %2$d relays concluídos — %3$d eventos redistribuídos até agora. Toque em Retomar para continuar. + Sincronização concluída + %1$d eventos encaminhados para relays de destino de %2$d recebidos. + %1$d eventos aceitos como novos pelos relays de destino. + Concluído em %1$d segundos. + Erro de sincronização + Enviando para + Saída + Entrada + DMs + Verificando (%1$d relays) + Concluído (%1$d relays) + env. %1$s + receb. %1$s + novos %1$s + nenhum evento + Explorador Bitcoin (OTS) + eventos + DMs + perfils + configurações de Relay + Visto pela última vez há %1$s + <%1$s + Conectando + Baixando + Erro + Concluído + + Marcar como lida + Ações da nota + Ações do perfil + Ações de mídia + Reprodução + Ações do pacote + Ações da lista + Ações de favorito + Ações do grupo + Adicionar favorito + Adicionar membro + Gerenciamento de lista + Ações do link + Exportar + Atestação + Válida + Inválida + Aceita + Rejeitada + Verificando + Verificada + Revogada + Válida a partir de %1$s + Válida até %1$s + Solicitação de atestação + Solicitando atestação para um evento + Recomendação do atestante + Recomendado para tipos: %1$s + Competência do atestante + Competente na verificação de tipos: %1$s + Atesta + Solicita atestação para diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index e461d5d2a0..44cf8be4c8 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -15,6 +15,9 @@ Спам Выдача себя за другое лицо Незаконные действия + Другие + Домогательство + Насилие Неизвестно Иконка релея Неизвестный автор @@ -29,6 +32,7 @@ Сообщить о выдаче себя за другого Сообщить о запрещённом контенте Сообщить о незаконных действиях + Вирус Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы ответить Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы продвигать записи Вы используете публичный ключ, они - только для чтения. Войдите с приватным ключом, чтобы лайкать посты @@ -38,10 +42,15 @@ Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность отписаться Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность скрыть слово или предложение Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность показать слово или предложение + Вы используете публичный ключ. Войдите приватным ключом, чтобы редактировать + Подпись не найдена Запы Просмотры Продвинуть + изменено + изменить #%1$s Цитата + Предложить изменение Новая сумма в sat Добавить "ответ на " @@ -63,8 +72,11 @@ Большое спасибо! Сумма в sat Отправить + Мое скрытое сообщение + Видимый префикс "Не удалось создать предпросмотр для %1$s : %2$s" "Предпросмотр для %1$s" + Статья Новый канал Название канала Моя новая группа @@ -72,21 +84,26 @@ Описание "О нас.. " Что нового? + Написать сообщение… Отправить Сохранить Создать + Переименовать Отменить Не удалось загрузить фото Адрес релея Записи Байт + Ошибка Ошибки Домашняя лента Лента личных сообщений Лента чатов Глобальная лента Поиск + Добавить пользователя Добавить релей + Имя Отображаемое имя Моё отображаемое имя Никнейм @@ -415,4 +432,5 @@ Разное Другое + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index e4e1ee3bf7..1952d8e510 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -136,6 +136,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Bajti Napaka Napake + Delež uspešnih povezav z relejem Število napak pri povezovanju v tej seji Domače vsebine Vsebine privatnih sporočil @@ -161,6 +162,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zaimki Lightning naslov (LUD-16) Zastarel lightning naslov (LUD-06) + Shrani v telefon Shrani v galerijo Slika shranjena v foto galerijo telefona Prenos videa se je začel… @@ -169,6 +171,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Video shranjen v video galerijo telefona Neuspešno shranjevanje videa Naloži sliko + Naloži datoteko Zajemi sliko Posnemi video Posnemi sporočilo @@ -432,6 +435,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Wallet Connect skrivnost Prikaži skrivni ključ nsec / hex privatni ključ + Povezan + Ni povezan + Napredno: ročni vnos podatkov o povezavi + Zneski za hitre zape + Prikaže se ob pritisku na gumb za zappe. Tapnite znesek, da ga odstranite. Če pustite prazno, se bo ob vsakem zappu odprlo okno za vnos poljubnega zneska. + Zap zasebnost + Določa, kako je prikazana vaša identiteta, ko pošljete zap. + Poveži denarnico + Pogled v vsebino releja Prispevaj vsoto v sat Pošlji anketo Zahtevana polja: @@ -454,6 +466,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zap vsota Dovoljen je le en glas na uporabnika pri tem tipu ankete "Iskanje dogodka %1$s" + Pošlji zap Dodaj javno sporočilo Dodaj privatno sporočilo Dodaj sporočilo k fakturi @@ -636,6 +649,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$s sat Od %1$s za %1$s + Odgovori + Označi kot prebrano + Novo sporočilo + Novi zapi Obvesti: Pridruži se pogovoru ID uporabnika ali skupine @@ -644,6 +661,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pridruži se Danes Opozorilo o vsebini + Opozorilo: %1$s Ta objava vsebuje občutljivo vsebino, ki jo lahko nekateri smatrajo za žaljivo ali vznemirjajočo Vedno skrij občutljivo vsebino Vedno prikaži občutljivo vsebino @@ -670,8 +688,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Piši v rele Količina bajtov, ki je bila poslana temu releju, vključno s filtri in dogodki Količina bajtov, ki je bila prejeta od tega releja, vključno s filtri in dogodki + dogode shranjen Prišlo je do napake pri pridobivanju informacij o releju iz %1$s Lastnik + V uporabi pri Servisni ključ V teku %1$s V teku %1$s (%2$s) @@ -808,6 +828,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pošiljatelji nezaželjenih vsebin Utišano. Klikni za vklop zvoka Zvok je prižgan. Klikni da ga utišaš + Skoči nazaj za %d sekund + Skoči nazprej za %d sekund + Slika v sliki Išči po lokalnih in zunanjih arhivskih zapisih Nostr naslov je preverjen Preverjanje Nostr naslova ni uspelo @@ -827,6 +850,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nalaganje lokacije Brez dovoljenj za lokacijo Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo + Razlog (neobvezen) Nova funkcija Za aktivacijo tega načina mora Amethyst poslati sporočilo NIP-17 (GiftWrapped, šifrirana neposredna in skupinska sporočila). NIP-17 je nov in večina Nostr odjemalcev ga še ni implementirala. Prepričajte se, da prejemnik uporablja združljiv Nostr odjemalec. Aktiviraj @@ -865,6 +889,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kdaj naložiti slike Kopiraj Stack Kopiraj v odložišče + Kopiraj nprofil v odložišče Kopiraj npub v odložišče Deli ali shrani Kopiraj URL v odložišče @@ -919,6 +944,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prepričajte se, da je aplikacija za podpisovanje odobrila to transakcijo Denarnica za plačilo z \"lightning\" ni najdena (Napaka: %1$s). Prosimo, namestite \"lightning\" denarnico da boste lahko Zap-ali ostale Denarnica za plačilo z \"lightning\" ni najdena. Prosimo, namestite \"lightning\" denarnico da boste lahko Zap-ali ostale + Ne morem odpreti Blossom povezav + Aplikacije Blossom niso bile najdene. Za ogled te datoteke namestite lokalno aplikacijo Blossom. Skrite besede Skrij novo besedo ali stavek Slika profila @@ -1022,6 +1049,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ni bilo mogoče pripraviti informacij glave: %1$s Stiskanje preklicano Stiskanje ni vrnilo datoteke + Šifriraj datoteke + Za večjo zasebnost datoteke pred nalaganjem šifrirajte. Nekateri strežniki pri brezplačnih računih morda ne sprejemajo šifriranih datotek. + Šifrirano nalaganje ni uspelo + Številni strežniki pri brezplačnih računih ne sprejemajo šifriranih datotek. Poskusite lahko znova brez šifriranja. + Poskusite znova brez šifriranja. + Pozor: Če datoteka ni šifrirana, je vsebina dostopna vsem, ki imajo povezavo. Kakovost medija Izberite Nizko kakovost, da stisnete svoj medij v manjšo datoteko z nižjo kakovostjo, Visoko kakovost, da stisnete v večjo datoteko z višjo kakovostjo, ali Nestisnjeno, da naložite medij brez stiskanja. Nizka @@ -1030,6 +1063,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nestisnjeno Uporabi H.265/HEVC Codec Boljša kakovost pri manjših velikostih datotek, vendar predvajanja H.265 ne podpirajo vse naprave. + Odstrani zasebne metapodatke + Poskusi odstraniti zasebne metapodatke iz podprtih datotek pred nalaganjem. + Metapodatkov ni možno odstraniti + Ta oblika zapisa ne podpira odstranjevanja metapodatkov. Datoteka lahko vsebuje zasebne podatke, kot sta lokacija in podatki o napravi. Želite vseeno naložiti? + Vseeno naloži + Zasebnih metapodatkov ni bilo mogoče odstraniti. Nalaganje je preklicano. + Nalaganje je preklicano. Uredi osnutek Vpiši se z QR kodo Pot @@ -1041,7 +1081,34 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Globalno Kratki mediji Šah + Denarnica + Bilanca + Pošlji + Prejmi + Transakcije + Denarnica ni povezana + Za uporabo denarnice v nastavitvah zapov vzpostavite povezavo Nostr Wallet Connect (NWC). + Nastavi denarnico + sats + prilepi BOLT-11 fakturo + Plačaj + Plačilo uspešno + Pošiljam plačilo… + Vsota (sats) + Opis (Neobvezno) + Ustvari fakturo + Ustvarjam fakturo… + Kopiraj fakturo + Ni transakcij + Nalagam… + Prejeto + Poslano + Osveži + Vse + Zapi + Brez zapov Varnostni filtri + Uvozi sledilce Nova objava Novi kratki mediji: slike ali posnetki Novo zapisek skupnosti @@ -1054,6 +1121,21 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Všečkaj Zap Spremeni hitre reakcije + Vrstica odzivnih ikon + Nastavite prikaza gumbov za odzive, njihov vrstni red in prikaz števcev. + Omogočeno + Prikaži števec + Prerazporedi + Odgovori + Odgovori na ta zapisek + Potisni + Ponovno objavi ali citiraj to objavo + Všečkaj + Odzovi se na to objavo z emojijem + Zap + Pošlji avtorju plačilo prek Lightninga + Deli + Deli to objavo z drugimi Slika profila %1$s Rele %1$s Razširi seznam relejev @@ -1072,7 +1154,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prekliči Zap razdelitev Dodaj opozorilo o vsebini Odstrani opozorilo o vsebini + Dodaj datum poteka + Odstrani datum poteka + Datum poteka + Nostr odjemalci bodo po tem datumu skrili objavo (NIP-40) + Izberite datum in čas poteka + Se izteče čez %1$s + Ura poteka Prikaži npub kot QR kodo + Prikaži nprofil kot QR kodo Neveljaven naslov Amethyst je prejel zahtevo da odpre URI, vendar je bil ta URI neveljaven: %1$s Releji predala zasebnih sporočil @@ -1085,6 +1175,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. Nastavi zdaj + Releji za prejeto pošto (ZS) niso bili najdeni. Sporočil ni mogoče dostaviti, dokler uporabnik ne nastavi seznama svojih relejev. Iskalni releji Nastavi iskalne releje Ustvarjanje seznama relejev, posebej zasnovanega za iskanje in označevanje uporabnikov, bo izboljšalo te rezultate. @@ -1141,6 +1232,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Blokirani releji Blokirani releji Amethyst se ne bo nikoli povezal na te releje + Izvozi nastavitve releja + Izvozi kot tekst + Izvozi kot ZIP (JSON) Zap-ni ustvarjalce! Vaša donacija nam pomaga narediti spremembo. Vsak satoši šteje! Doniraj sedaj @@ -1233,6 +1327,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Moji seznami Uporabniki Izberite seznam za filtriranje vsebin + Vsebina + Ključniki + Skupnosti + Seznami Odjava ob zaklepu naprave Zasebno sporočilo Javno sporočilo @@ -1246,6 +1344,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Išči ključnik: #%1$s Ne prevedi iz Tu prikazani jeziki ne bodo prevedeni. Izberite jezik, da ga odstranite in ga znova prevedete. + Prevedi v + Izberi jezik v katerega želite prevesti vsebino. + Nastavitve prikaza jezika + Za vsak preveden jezikovni par izberite, kateri jezik naj bo prikazan prvi. + %1$s → %2$s + Išči jezike + Dodaj jezik + Dodaj jazikovni par + Izvorni jezik + Ciljni jezik + Prikaži najprej %1$s + Prednostni prikaz jezikov še ni nastavljen. Nastavitve se ustvarijo samodejno ob prevajanju, lahko pa jih dodate ročno. + Izbriši nastavitev Premor Predvajaj Odpri spustni meni @@ -1255,6 +1366,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Najdeno je poročilo o napaki Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani. Pošlji + To sporočilo bo izginilo čez %1$s To sporočilo bo izginilo čez %1$d dni Izberi podpisnika Že na seznamu @@ -1275,15 +1387,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Novo ime paketa Opis paketa Nov opis paketa - Oddajaj seznam - Oddajaj paket + Razpošlji seznam + Razpošlji paket Izbriši seznam Izbriši paket Tipi Ponovni poskus ni uspel Kliknite za ogled podrobnosti %1$s poslano - Rezultat oddajanja + Razpošlji rezultate Razpošlji (%1$d) Strni Razširi @@ -1297,7 +1409,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ogled Zavrni %1$d/%2$d - Oddajanje + Oddajam Oddajam %1$s Oddajam %1$d dogodkov... Poslano %1$d dogodkov @@ -1328,4 +1440,207 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem in %1$d ostalih Odstrani %1$s + Izhodni releji + Aplikacije + Priporočila aplikacij + Nastavitve uporabnika + Zvočna glava + Zvočni zapis + Nagradne značke + Definicija značke + Značke profila + Blokirani releji + Blossom strežniki + Blossom avtentikacija + Oddajni releji + Seznam zaznamkov + Dnevni termin + Koledar + Sestanek + Potrditev termina + Šahovske igre + Šahovska avtentikacija + Priljubljeni releji + Šahovski izzivi + Sprejmi partijo + Šahovska poteza + Konec partije + Predlagaj remi + Definicija kanala + Skrij sporočilo v kanalu + Seznam kanalov + Sporočilo v kanalu + Metapodatki kanala + Utišani uporabniki kanala + Datoteka ZS + ZS sporočilo + Releji ZS + Oglasi + Komentarji + Opis skupnosti + Seznam skupnosti + Objava skupnosti + Seznam sledenih + Izbrisano + Osnutki + Zbirke emojijev + Pregled zbirk emojijev + Minljivi klepet + Minljive klepetalnice + Glave datotek + Galerija profila + Datotečni strežniki + Blob podatki + Blob glava + Zdravstveni podatki + Paketi za sledenje + Ponovno preposlani (16) + Sledeni geohash-i + GiftWraps + Git zadeva + Git popravek + Git repozitorij + Git odgovor + Ciljni znesek zapov + Sledeni ključniki + Osvetlitve + Http Auth + Indeksiraj seznam relejev + Pustolovski prolog + Pustolovska scena + Pustolovsko branje + Poimenovani zaznamki + Pogovori v živo + Prenosi v živo + Zapi + NWC zahtevek + NWC odgovor + Zasebni zapi + Zap zahtevek + Blogi + Sejna soba + Prisotnost v sobi + Srečevalnica + Profil + Seznam utišanih + NNS + NIP + Nostr Connect + DVM status + DVM zahtevek vsebine + DVM odgovor na vsebino + DVM zahtevek uporabnika + DVM odgovor uporabnika + OTS + Plačaj + Seznam oseb + Fotografije + Bucike + Zap sklad + Glasovanje + Odziv na glasovanje + NIP-04 ZS + Zasebni releji + Proxy releji + Javno sporočilo + Odzivne ikone + Kartica kontakta + Autorizacija releja + Odkrivanje relejev + Obvestilo nadzornika relejev + Nabor relejev + Prijave + Ponovne objave + Brisanje uporabnika + Pečat + Iskalni releji + Status uporabnika + Zapiski + Urejeni + Torenti + Komentarji torentov + Zaupanja vredni releji + Zaupanja vredni ponudniki + Videoposnetki (odgovor) + Kratki posnetki (odgovor) + Videoposnetki + Kratki videoposnetki + Zvočno sporočilo + Zvočni odgovori + Wiki + Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate. + Uvozi seznam sledenih + Izberite uporabnike, ki jim želite slediti + Profil, iz katerega želite uvoziti + Iskanje, npub1…, janez@primer.si + Podpira npub, nprofile, NIP-05, hex in namecoin (.bit, d/, id/) + Poišči seznam sledenih + Daj napitnino + Najdeno %1$d računov + Izbranih %1$d + Razrešeno prek Namecoina + Zdaj sledite %1$d računom + Vaš vsebina objav je pripravljen. + Preskoči + Poišči še koga + Sledi %1$d računom + Dodaten uvoz + Nadaljuj + Preskoči za zdaj + Razreševanje %1$s… + Pridobivanje seznama sledenih… + Ni najdenih sledilcev + Sledenje %1$d računom… + "Vnesite profil prijatelja ali vodje skupnosti. Uporabite lahko njihov npub, naslov NIP-05 ali ime Namecoin (npr. janez@primer.bit ali id/janez) za identitete, preverjene prek verige blokov." + Izberi vse + %1$d%% časa delovanja + Namecoin nastavitve + Sinhronizacija relejev + Sinhronizacija relejev + Ponovno objavite svoje dogodke na vseh znanih relejih, da posodobite svoje izhodne, vhodne in releje ZS. Zahtevana je povezava Wi-Fi — postopek lahko porabi veliko podatkov. + Odpri sinhronizacijo relejev… + Kaj to naredi + To orodje pregleda vse releje, ki jih je zaznala vaša aplikacija, in vaše dogodke prerazporedi na ustrezne cilje: + Prenesite vse dogodke katere ste ustvarili, in jih pošljite v vaše izhodne releje. + Prenesite vse dogodke, v katerih ste omenjeni, in jih pošljite v vaše vhodne releje. + Prenesite vsa zasebna sporočila, naslovljena na vas, in jih pošljite v svoje releje za zasebna sporočila. + ⚠ Kaže, da uporabljate omejeno ali mobilno povezavo. Ta postopek lahko prenese zelo veliko količino podatkov. Pred začetkom se povežite v omrežje Wi-Fi. + Uporabi mobilne podatke? + Začni sinhronizacijo + Vseeno začni (mobilni podatki) + Premor + Nadaljuj + Začni znova + Prekini + Releji: %1$d / %2$d + Prerazporejeni dogodki: %1$d novih od %2$d poslanih in %3$d prejetih + Premor sinhronizacije + Zaključenih %1$d od %2$d relejev — do zdaj prerazporejenih %3$d dogodkov. Dotaknite se »Nadaljuj« za nadaljevanje. + Sinhronizacija zaključena + Naprej posredovanih %1$d od %2$d prejetih dogodkov do ciljnih relejev. + Število dogodkov, ki jih je ciljni rele sprejel kot nove: %1$d. + Zaključeno v %1$d sekundah + Napaka pri sinhronizaciji + Pošiljam k + Odhodni + Vhodni + ZS + Trenutno preverjam (%1$d relejev) + Finished (%1$d relays) + poslano %1$s + sprejeto %1$s + nov %1$s + ni dogodkov + Bitcoin Raziskovalec (OTS) + dogodki + ZS + profili + Nastavitve releja + Nazadnje viden pred %1$s + <%1$s + Povezujem + Prenašam + Napaka + Zaključeno + diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 0f3a3ed066..c05bfb1e6e 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -34,4 +34,5 @@ Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове Нема подешавања износа Зап. Дуго притисните да бисте променили + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 64f7432515..1850192058 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -246,6 +246,7 @@ "Det gick inte att läsa in svar: " Försök igen Inga aviseringar ännu. + Öppen omröstning Flöde är tomt. Uppdatera Skapad @@ -633,6 +634,10 @@ %1$s sats Från %1$s till %1$s + Svara + Markera som läst + Nya meddelanden + Nya zaps Meddela: Gå med i konversation Användare eller grupp ID @@ -668,6 +673,7 @@ Skriv till Relay Mängden data i byte som skickades till detta relä, inklusive filter och händelser Mängden data i byte som mottogs från detta relä, inklusive filter och händelser + Lagrade händelser Ett fel inträffade vid försök att hämta information från Relay %1$s Ägare Används av @@ -922,6 +928,8 @@ Kontrollera att signeringsprogrammet har godkänt denna transaktion Inga plånböcker hittades för att betala en blixtfaktura (Fel: %1$s). Installera en blixtpengaplånbok för att använda zaps Inga plånböcker hittades för att betala en blixtfaktura. Installera en blixtpengaplånbok för att använda zaps + Kan inte öppna Blossom-länkar + Inga Blossom-appar hittades. Installera en lokal Blossom-app för att visa den här filen Dolda ord Dölj nytt ord eller mening Profilbild @@ -1025,6 +1033,12 @@ Kunde inte förbereda header information: %1$s Komprimering avbruten Komprimering misslyckades att returnera en fil + Kryptera filer + Kryptera filer innan uppladdning för integritet. Vissa servrar kanske inte accepterar krypterade filer på gratiskonton. + Krypterad uppladdning misslyckades + Många servrar accepterar inte krypterade filer på gratiskonton. Du kan försöka igen utan kryptering. + Försök igen utan kryptering + Varning: Utan kryptering kan vem som helst med fillänken se innehållet. Mediakvalitet Välj Låg kvalitet för att komprimera ditt media till en mindre fil med lägre kvalitet, Hög kvalitet för att komprimera till en större fil med högre kvalitet eller Okomprimerad för att ladda upp media utan kompression. Låg @@ -1033,6 +1047,13 @@ Okomprimerad Använd H.265/HEVC-codec Bättre kvalitet med mindre filstorlek, men inte alla enheter stöder H.265-uppspelning. + Ta bort privat metadata + Försöker ta bort privat metadata från mediefiler som stöds innan uppladdning + Metadata kunde inte tas bort + Detta filformat stöder inte borttagning av metadata. Privat information som plats och enhetsinformation kan ingå. Ladda upp ändå? + Ladda upp ändå + Kunde inte ta bort privat metadata från media. Uppladdning avbruten. + Uppladdning avbruten Redigera utkast Logga in med QR-kod Rutt @@ -1044,6 +1065,32 @@ Globalt Kortfilmer Schack + Plånbok + Saldo + Skicka + Ta emot + Transaktioner + Ingen plånbok ansluten + Konfigurera en Nostr Wallet Connect (NWC)-anslutning i dina zap-inställningar för att använda plånboken. + Konfigurera plånbok + sats + Klistra in en BOLT-11-faktura + Betala + Betalning lyckades + Skickar betalning… + Belopp (sats) + Beskrivning (valfritt) + Skapa faktura + Skapar faktura… + Kopiera faktura + Inga transaktioner ännu + Laddar… + Mottagen + Skickad + Uppdatera + Alla + Zaps + Övriga Säkerhetsfilter Importera följare Nytt inlägg @@ -1091,6 +1138,13 @@ Avbryt Zap-split Lägg till varning för innehåll Ta bort varning för innehåll + Lägg till utgångsdatum + Ta bort utgångsdatum + Utgångsdatum + Inlägget döljs av klienter efter detta datum (NIP-40) + Välj utgångsdatum och utgångstid + Går ut om %1$s + Utgångstid Visa npub som en QR-kod Visa nprofile som QR-kod Ogiltig adress @@ -1104,6 +1158,7 @@ Bra alternativ är:\n - auth.nostr1.com (gratis)\n - inbox.nostr.wine (betalad)\n - relay.0xchat.com (gratis) Sätt in mellan 1–3 reläer för att fungera som din privata inkorg. DM Inkorg reläer bör acceptera alla meddelanden från vem som helst, men endast tillåta dig att ladda ner dem. Ställ in nu + DM-inkorgsreläer hittades inte. Meddelanden kan inte levereras förrän de konfigurerar sin relälista. Sökreläer Ställ in dina sökreläer Att skapa en relälista speciellt utformad för sökning och användartaggning kommer att förbättra dessa resultat. @@ -1160,6 +1215,9 @@ Blockerade reläer Blockerade reläer Amethyst kommer aldrig att ansluta till dessa reläer + Exportera relay-inställningar + Exportera som text + Exportera som ZIP (JSON) Zappa utvecklarna! Din donation hjälper oss att göra skillnad. Varje sat räknas! Donera nu @@ -1252,6 +1310,10 @@ Mina listor Användare Välj en lista för att filtrera flödet + Flöden + Hashtaggar + Gemenskaper + Listor Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1265,6 +1327,19 @@ Sök hashtag: #%1$s Översätt inte från Språk som visas här kommer inte att översättas. Välj ett språk för att ta bort det och få det översatt igen. + Översätt till + Välj det språk du vill översätta innehållet till. + Språkvisningsinställningar + Välj för varje översatt språkpar vilket språk som ska visas först. + %1$s → %2$s + Sök språk + Lägg till språk + Lägg till språkpar + Källspråk + Målspråk + Visa %1$s först + Inga språkvisningsinställningar än. Dessa skapas automatiskt när översättningar sker, eller kan läggas till manuellt. + Ta bort inställning Pausa Spela Öppna rullgardinsmeny @@ -1274,6 +1349,7 @@ Kraschrapport hittad Vill du skicka den senaste kraschrapporten till Amethyst i ett DM? Ingen personlig information kommer att delas Skicka + Detta meddelande försvinner om %1$s Detta meddelande försvinner om %1$d dagar Välj signatör Redan i listan @@ -1502,4 +1578,84 @@ Välj alla %1$d%% drifttid Namecoin-inställningar + Relä-synkronisering + Relä-synkronisering + Publicera om dina händelser på alla kända reläer för att hålla dina utkorgs-, inkorgs- och DM-reläer uppdaterade. Kräver Wi-Fi — detta kan använda mycket data. + Öppna relä-synkronisering… + Vad detta gör + Det här verktyget söker igenom alla reläer som din app har sett och omfördelar dina händelser till rätt destinationer: + Ladda ner alla händelser du skapat och skicka dem till dina utkorgsreläer. + Ladda ner alla händelser som nämner dig och skicka dem till dina inkorgsreläer. + Ladda ner alla direktmeddelanden adresserade till dig och skicka dem till dina DM-reläer. + ⚠ Du verkar vara på en mätad eller mobil anslutning. Denna åtgärd kan överföra mycket stora mängder data. Anslut till Wi-Fi innan du startar. + Använda mobildata? + Starta synkronisering + Starta ändå (mobildata) + Pausa + Återuppta + Börja om + Avbryt + Reläer: %1$d / %2$d + Omfördelade händelser: %1$d nya av %2$d skickade och %3$d mottagna + Synkronisering pausad + %1$d av %2$d reläer klara — %3$d händelser omfördelade hittills. Tryck på Återuppta för att fortsätta. + Synkronisering klar + Vidarebefordrade %1$d händelser till destinationsreläer av %2$d mottagna. + %1$d händelser accepterade som nya av destinationsreläer. + Slutfört på %1$d sekunder. + Synkroniseringsfel + Skickar till + Utkorg + Inkorg + DM + Kontrollerar (%1$d reläer) + Slutfört (%1$d reläer) + skick. %1$s + mott. %1$s + nya %1$s + inga händelser + Bitcoin Explorer (OTS) + händelser + DMs + profiler + relä inställningar + Senast sedd för %1$s sedan + <%1$s + Ansluter + Laddar ner + Fel + Slutfört + + Markera som läst + Anteckningsåtgärder + Profilåtgärder + Medieåtgärder + Uppspelning + Paketåtgärder + Liståtgärder + Bokmärkesåtgärder + Gruppåtgärder + Lägg till bokmärke + Lägg till medlem + Listhantering + Länkåtgärder + Exportera + Attestering + Giltig + Ogiltig + Accepterad + Avvisad + Verifierar + Verifierad + Återkallad + Giltig från %1$s + Giltig till %1$s + Attesteringsförfrågan + Begär attestering för en händelse + Attestantens rekommendation + Rekommenderad för typer: %1$s + Attestantens kompetens + Kompetent att verifiera typer: %1$s + Intygar + Begär attestering till diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index eede00f35d..0e3f92eeef 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -444,4 +444,5 @@ Ulimwenguni Tafuta + diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index e79aecaf2c..090879e2a2 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -370,4 +370,5 @@ இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 337dec3eb9..562618425f 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -815,4 +815,5 @@ แลือกลิสต์เพื่อโชว์ ออกจากระบบ + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 7fde8f9e54..307dff61dd 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -168,4 +168,5 @@ Tümünü okundu olarak işaretle Hata + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 9e18b6bfcb..14d221f281 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -500,4 +500,5 @@ Не вдалося завантажити завантажені медіафайли з сервера Не вдалося підготувати локальний файл для завантаження: %1$s + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index ae64d7d1f1..b2f187423e 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -69,4 +69,5 @@ "bu kanalda: " Profil banneri + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 5b4876f370..fc34437331 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -12,4 +12,5 @@ Lưu ý cho người nhận Cảm ơn rất nhiều! + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 6d7eafd38e..0d45efe82b 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -246,6 +246,7 @@ "加载回复出错:" 重试 暂无通知 + 开放投票 信息流为空。 刷新 创建 @@ -638,6 +639,10 @@ %1$s聪 来自 %1$s 为 %1$s + 回复 + 标记为已读 + 新信息 + 新打闪 通知: 加入对话 用户或群组 ID @@ -673,6 +678,7 @@ 写入到继电器 向该中继发送事件和过滤请求所使用的数据量 从该中继接收事件和过滤响应所使用的数据量 + 已保存的事件 尝试从 %1$s 获取中继器信息时出错 机主 使用者 @@ -1033,6 +1039,12 @@ 无法准备头部信息:%1$s 压缩已取消 压缩返回的文件失败 + 加密文件 + 上传前为了隐私加密文件。有些服务器可能不接受免费账户的加密文件。 + 加密上传失败 + 许多服务器不接受免费账户的加密文件。您可以不加密重试。 + 不加密重试 + 警告:没有加密,任何有文件链接的人都可以看到内容。 媒体质量 选择「低质量」来将你的媒体文件压缩到较小体积,或者选择「高质量」来将你的媒体文件压缩到较大体积。 低质量 @@ -1041,6 +1053,13 @@ 未压缩 使用 H.265/HEVC 编解码器 文件较小而质量更佳,但不是所有设备都支持 H.265 播放。 + 删除私密元数据 + 尝试在上传之前从支持的媒体文件中删除私密元数据 + 无法删除元数据 + 此文件格式不支持删除元数据。可以包含位置和设备信息等私人信息。仍然要上传吗? + 仍然上传 + 从媒体删除私密元数据失败。上传取消。 + 上传已取消 编辑草稿 使用二维码登录 路径 @@ -1052,6 +1071,32 @@ 全球 短篇 国际象棋 + 钱包 + 余额 + 发送 + 接收 + 交易 + 未连接钱包 + 在打闪设置中设置Nostr Wallet Connect (NWC) 连接以使用钱包。 + 设置钱包 + + 粘贴 BOLT-11 发票 + 付款 + 支付成功 + 正在发送付款… + 聪金额 + 描述(可选) + 创建发票 + 正在创建发票… + 复制发票 + 尚无交易 + 正在加载… + 已收到 + 已发送 + 刷新 + 全部 + 打闪 + 非打闪 安全滤镜 导入关注 新帖子 @@ -1099,6 +1144,13 @@ 取消打闪拆分 添加内容警告 移除内容警告 + 添加过期日期 + 删除过期日期 + 过期日期 + 帖子将在此日期后被客户端隐藏 (NIP-40) + 选择到期日期和时间 + 在 %1$s 过期 + 到期时间 将 npub 显示为二维码 以二维码显示 nprofile 地址无效 @@ -1112,6 +1164,7 @@ 示例:\n - auth.nostr1.com (免费)\n - inbox.nostr.wine (付费)\n - revisy.0xchat.com (免费) 设置 1 ~ 3 个私人收件箱中继。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。 立即设置 + 未找到私信收件箱中继。配置中继列表前无法传送消息。 搜索中继 设置你的搜索中继 通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。 @@ -1168,6 +1221,9 @@ 中继黑名单 中继黑名单 应用永远不会连接的中继 + 导出中继设置 + 导出为文本 + 导出为 ZIP (JSON) 打闪开发人员! 你的捐赠帮助我们做出不同的贡献。每个聪都很重要! 立即捐款 @@ -1260,6 +1316,10 @@ 我的列表 用户 选择一个用于过滤订阅源的列表 + + 话题标签 + 社区 + 列表 当设备锁定时注销 私信 公开消息 @@ -1295,6 +1355,7 @@ 找到了崩溃报告 要用私信将最近的崩溃报告发送给 Amethyst 吗?不会分享个人信息 发送它 + 此消息将在 %1$s 后消失 此消息将在 %1$d 天内消失 选择签名者 已经在列表中 @@ -1523,4 +1584,84 @@ 全选 %1$d%% 运行时间 Namecoin 设置 + 中继同步 + 中继同步 + 在所有已知的中继重新发布您的事件,以保持您的发件箱、收件箱和私信中继是最新的。 需要 Wi-Fi - 这可能使用大量数据。 + 打开中继同步… + 这是做什么 + 此工具扫描您的应用已看到的每一个中继并将您的事件重新分发到正确的目的地: + 下载您编写的所有事件并发送到您的发件箱中继中。 + 下载所有提到您的事件并发送到您的收件箱中继中。 + 下载发送给您的所有私信并发送到您的私信中继。 + ⚠ 您似乎在按流量计费或移动网络连接上。此操作可能传输大量数据。启动前请连接到 Wi-Fi。 + 使用移动数据? + 开始同步 + 仍要启动(移动数据) + 暂停 + 恢复 + 重来 + 取消 + 中继: %1$d / %2$d + 已重新分发事件:%1$d 个新事件,共 %2$d 个已发送事件和 %3$d 个已接收事件 + 同步已暂停 + 完成了 %2$d 个中继中的 %1$d 个中继 — 迄今为止重新分发了 %3$d 个事件。轻按“恢复“继续。 + 同步完成 + 转发了 %1$d 个事件到目标中继,共接收到 %2$d 个事件。 + %1$d 个事件被目标中继接受为新事件。 + 在 %1$d 秒内完成。 + 同步错误 + 发送到 + 发件箱 + 收件箱 + 私信 + 当前正在检查 (%1$d 个中继) + 已完成 (%1$d 个中继) + 已发送 %1$s + 已收到 %1$s + 新增 %1$s + 没有事件 + 比特币资源管理器 (OTS) + 事件 + 私信 + 个人资料 + 中继设置 + 上次看见在 %1$s 秒前 + <%1$s + 连接中 + 下载中 + 错误 + 已完成 + + 标记为已读 + 笔记操作 + 配置文件操作 + 媒体操作 + 播放 + 包操作 + 列表操作 + 书签操作 + 群操作 + 添加书签 + 添加成员 + 列表管理 + 链接操作 + 导出 + 证明 + 有效 + 无效 + 已接受 + 已拒绝 + 验证中 + 已验证 + 已撤销 + 有效期从 %1$s + 有效期到 %1$s + 证明请求 + 请求某个事件的证明 + 证明人建议 + 推荐的类型: + 证明人熟练度 + 熟练验证类型:%1$s + 证明 + 请求证明 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 9c99f2f57e..8dbf75e362 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -240,4 +240,5 @@ 聰數量 "“正在查找事件%1$s”" + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 913f33afd9..2e60173154 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -763,4 +763,5 @@ 正在等待 DVM 確認付款或返回結果 添加 NIP-96 服務器 + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 73d6b6bf15..4e1c1d20e1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -258,6 +258,7 @@ "Error loading replies: " Try again No notifications yet. + Open Poll Feed is empty. Refresh created @@ -732,6 +733,11 @@ From %1$s for %1$s + Reply + Mark Read + New messages + New zaps + Notify: Join Conversation @@ -776,6 +782,7 @@ Write to Relay The amount in bytes that was sent to this relay, including filters and events The amount in bytes that was received from this relay, including filters and events + Events stored An error occurred trying to get relay information from %1$s Owner Used By @@ -1215,6 +1222,13 @@ Compression Cancelled Compression failed to return a file + Encrypt files + Encrypt files before uploading for privacy. Some servers may not accept encrypted files on free accounts. + Encrypted upload failed + Many servers do not accept encrypted files on free accounts. You can retry without encryption. + Retry without encryption + Warning: Without encryption, anyone with the file link can see the content. + Media Quality Select Low quality to compress your media to a smaller file with less quality, High quality to compress to a larger file with higher quality or Uncompressed to upload the media without compression. Low @@ -1223,6 +1237,14 @@ Uncompressed Use H.265/HEVC Codec Better quality at smaller file sizes but not all devices support H.265 playback. + Remove private metadata + Attempts to strip private metadata from supported media files before uploading + + Metadata could not be removed + This file format does not support metadata stripping. Private information such as location and device info may be included. Upload anyway? + Upload anyway + Failed to strip private metadata from media. Upload cancelled. + Upload cancelled Edit draft @@ -1236,6 +1258,32 @@ Global Shorts Chess + Wallet + Balance + Send + Receive + Transactions + No wallet connected + Set up a Nostr Wallet Connect (NWC) connection in your zap settings to use the wallet. + Set Up Wallet + sats + Paste a BOLT-11 invoice + Pay + Payment successful + Sending payment… + Amount (sats) + Description (optional) + Create Invoice + Creating invoice… + Copy Invoice + No transactions yet + Loading… + Received + Sent + Refresh + All + Zaps + Non-Zaps Security Filters Import Follows @@ -1325,6 +1373,7 @@ Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free) Insert between 1–3 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them. Set up now + DM inbox relays not found. Messages cannot be delivered until they configure their relay list. Search Relays Set up your Search relays @@ -1505,7 +1554,11 @@ My Lists/Sets My Lists Users - Select a list to filter the feed + Select an option to filter the feed + Feeds + Hashtags + Communities + Lists Log off on device lock Private Message @@ -1785,5 +1838,94 @@ Select All %1$d%% uptime Namecoin Settings + Relay Sync + Relay Sync + Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data. + Open Relay Sync… + What this does + This tool scans every relay your app has seen and redistributes your events to the correct destinations: + Download all events you authored and send them to your outbox relays. + Download all events that mention you and send them to your inbox relays. + Download all direct messages addressed to you and send them to your DM relays. + ⚠ You appear to be on a metered or mobile connection. This operation can transfer a very large amount of data. Connect to Wi-Fi before starting. + Use Mobile Data? + Start Sync + Start Anyway (mobile data) + Pause + Resume + Start Over + Cancel + Relays: %1$d / %2$d + Events redistributed: %1$d new out of %2$d sent and %3$d received + Sync Paused + Completed %1$d of %2$d relays — %3$d events redistributed so far. Tap Resume to continue. + Sync complete + Forwarded %1$d events to destination relays out of %2$d received. + %1$d events accepted as new by destination relays. + Completed in %1$d seconds. + Sync error + Sending To + Outbox + Inbox + DMs + Currently Checking (%1$d relays) + Finished (%1$d relays) + sent %1$s + recv %1$s + new %1$s + no events Bitcoin Explorer (OTS) + events + DMs + profiles + relay settings + Last seen %1$s ago + + <%1$s + Connecting + Downloading + Error + Completed + + + Mark as Read + Note Actions + Profile Actions + Media Actions + Playback + Pack Actions + List Actions + Bookmark Actions + Group Actions + Add Bookmark + Add Member + List Management + Link Actions + Export + Attestation + Valid + Invalid + Accepted + Rejected + Verifying + Verified + Revoked + Valid from %1$s + Valid to %1$s + Attestation Request + Requesting attestation for an event + Attestor Recommendation + Recommended for kinds: + Attestor Proficiency + Proficient in verifying kinds: %1$s + Attests to + Requests attestation to + + Date Range + Since + Until + Now + All time + Last sync: %1$s + Since Last Sync diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index a48f700ac7..697583552c 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -178,9 +178,9 @@ private fun TranslationMessage( buildAnnotatedString { appendLink(stringRes(R.string.translations_auto), textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } append(" ${stringRes(R.string.translations_translated_from)} ") - appendLink(Locale(source).displayName, textColor) { onChangeWhatToShow(true) } + appendLink(Locale.forLanguageTag(source).displayName, textColor) { onChangeWhatToShow(true) } append(" ${stringRes(R.string.translations_to)} ") - appendLink(Locale(target).displayName, textColor) { onChangeWhatToShow(false) } + appendLink(Locale.forLanguageTag(target).displayName, textColor) { onChangeWhatToShow(false) } }, style = LocalTextStyle.current.copy( @@ -213,7 +213,7 @@ private fun TranslationMessage( Text( stringRes( R.string.translations_never_translate_from_lang, - Locale(source).displayName, + Locale.forLanguageTag(source).displayName, ), ) } @@ -242,7 +242,7 @@ private fun TranslationMessage( Text( stringRes( R.string.translations_show_in_lang_first, - Locale(source).displayName, + Locale.forLanguageTag(source).displayName, ), ) } @@ -272,7 +272,7 @@ private fun TranslationMessage( Text( stringRes( R.string.translations_show_in_lang_first, - Locale(target).displayName, + Locale.forLanguageTag(target).displayName, ), ) } @@ -292,7 +292,7 @@ private fun TranslationMessage( DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.translateToContains(lang)) { + if (accountViewModel.account.settings.translateToContains(lang.language)) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -314,7 +314,7 @@ private fun TranslationMessage( }, onClick = { langSettingsPopupExpanded = false - accountViewModel.updateTranslateTo(lang) + accountViewModel.updateTranslateTo(lang.language) }, ) } diff --git a/amethyst/src/test/java/android/util/Log.java b/amethyst/src/test/java/android/util/Log.java index af85d0b47e..245a440a12 100644 --- a/amethyst/src/test/java/android/util/Log.java +++ b/amethyst/src/test/java/android/util/Log.java @@ -1,6 +1,10 @@ package android.util; public class Log { + public static Boolean isLoggable(String tag, Integer msg) { + return true; + } + public static int d(String tag, String msg) { System.out.println("DEBUG: " + tag + ": " + msg); return 0; diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt index ff01ba1dc3..6694997de6 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt @@ -51,7 +51,7 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { benchmarkRule.measureRepeated { val db = runWithMeasurementDisabled { - EventStore(context, null) + EventStore(null) } firstThousandEvents.forEach { event -> try { @@ -81,7 +81,8 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { benchmarkRule.measureRepeated { val db = runWithMeasurementDisabled { - val db = EventStore(context, null) + val db = + EventStore(null) toBeDeletedEvents.forEach { event -> try { db.insert(event) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt index 26e2bc8d51..2a1764ddbd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt @@ -49,14 +49,14 @@ class LargeDBQueryingBenchmark : BaseLargeCacheBenchmark() { val allEvents = getEventDB().distinctBy { it.id }.sortedBy { it.createdAt } } - lateinit var db: EventStore + lateinit var db: com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore @Before fun setup() { val context = ApplicationProvider.getApplicationContext() context.deleteDatabase("allEvents.db") - db = EventStore(context, "allEvents.db") + db = EventStore("allEvents.db") allEvents.forEach { event -> try { db.insert(event) diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 1d6ce95e1b..c55f9e4099 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -49,12 +49,12 @@ kotlin { implementation(project(":quartz")) // Compose Multiplatform - implementation(compose.ui) - implementation(compose.foundation) - implementation(compose.runtime) - implementation(compose.material3) - implementation(compose.materialIconsExtended) - implementation(compose.components.uiToolingPreview) + implementation(libs.jetbrains.compose.ui) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.material.icons.extended) + implementation(libs.jetbrains.compose.ui.tooling.preview) // Lifecycle ViewModel (KMP since 2.8.0) implementation(libs.androidx.lifecycle.viewmodel.compose) @@ -71,7 +71,7 @@ kotlin { api(libs.kotlinx.collections.immutable) // Compose Multiplatform Resources - implementation(compose.components.resources) + implementation(libs.jetbrains.compose.components.resources) } } @@ -94,7 +94,7 @@ kotlin { dependencies { // Desktop-specific Compose implementation(compose.desktop.currentOs) - implementation(compose.uiTooling) + implementation(libs.jetbrains.compose.ui.tooling) // Secure key storage via OS keychain (macOS/Windows/Linux) implementation(libs.java.keyring) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 2dba6f7601..c04a06f361 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -237,7 +237,7 @@ fun LiveChessGameScreen( ) { // Game info - use currentPosition.activeColor for turn display GameInfoHeader( - gameId = gameState.gameId, + gameId = gameState.startEventId, opponentName = opponentName, playerColor = gameState.playerColor, currentTurn = currentPosition.activeColor, @@ -463,7 +463,7 @@ private fun GameInfoHeader( // Show turn or game result when (gameStatus) { is GameStatus.Finished -> { - val result = (gameStatus as GameStatus.Finished).result + val result = gameStatus.result val resultText = when { result == GameResult.DRAW -> "Draw" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt index f9a1b51c3a..0f5cb96931 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt @@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material3.Icon @@ -82,7 +82,7 @@ fun MoveNavigator( enabled = currentMove > 0, ) { Icon( - Icons.Default.KeyboardArrowLeft, + Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous move", tint = if (currentMove > 0) { @@ -106,7 +106,7 @@ fun MoveNavigator( enabled = currentMove < totalMoves, ) { Icon( - Icons.Default.KeyboardArrowRight, + Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next move", tint = if (currentMove < totalMoves) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt index 674a38219f..6725ef332f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt @@ -213,6 +213,6 @@ object NostrConnectLoginUseCase { private fun generateSecret(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) - return bytes.joinToString("") { "%02x".format(it) } + return bytes.toHexKey() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt index ea46a6db8d..e640624d3c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt @@ -32,8 +32,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt index e4e0aa72d1..be8d13a795 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt index 1a831f0b38..e1de172b11 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt index 5daa578c59..53c4ec730a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt index d290cee854..9e0295efe1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt index 7ea0d4104d..2630d212e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt index 7362b1ac21..d28818a2d2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -28,8 +28,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt index 41166aadb0..bec0a2ff69 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt index 242adac53c..7b96119215 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt index 50b96eee90..8f8174d98b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt index ee6b91f121..511507d62e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt index dab15b1f6e..f4f989545b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index ccbd67b1e4..3c47a957d7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt index ddb1fe5558..a8aa8bd358 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt index 3cff5f596f..338eef3642 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt index 7b3631fc7c..3924aaa01b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt index 89684d1c98..1a32595294 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.graphics.StrokeCap.Companion.Butt import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt index 583ebfd724..326474d8a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt index 1bcf1a6b74..52a15572b9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt index fd4eb5fb06..d41cc91b37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt index 533fc64f72..b3775729f0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt index 32d7268384..3d2025d7e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt index f3e0726d63..5d99988b47 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt index bab4cda6f5..34c17c3b9d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt index 40156d93cf..345dd88af1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt index a56999f41d..73ee2a4d2b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path -import org.jetbrains.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt index ba3d835675..252b342653 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index a42ce31b01..d412167334 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -24,11 +24,12 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.DualCase @@ -108,6 +109,9 @@ interface IAccount { /** Send a NIP-17 gift-wrapped direct message */ suspend fun sendNip17PrivateMessage(template: EventTemplate) + /** Send a NIP-17 gift-wrapped encrypted file header */ + suspend fun sendNip17EncryptedFile(template: EventTemplate) + /** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */ suspend fun sendGiftWraps(wraps: List) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index a66d32babe..98e327fe42 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -48,10 +48,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -1027,7 +1027,7 @@ public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): Li return dest } -public inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { +public fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { if (this is Collection && isEmpty()) return emptyList() val dest = ArrayList() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index a5c197bb65..6a493dd04a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.robohash.parts.accessory0Seven import com.vitorpamplona.amethyst.commons.robohash.parts.accessory1Nose @@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.commons.robohash.parts.mouth9Closed import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 -import org.jetbrains.compose.ui.tooling.preview.Preview val Black = SolidColor(Color.Black) val Gray = SolidColor(Color(0xFF6d6e70)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt index f2261f6fde..e98d7c67d8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt @@ -29,10 +29,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt index 23c05187af..349a44766e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt @@ -27,9 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt index c02c969f46..0faf7f8398 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightBrown @@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.robohash.LightGray import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.MediumGray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt index 230f701c2f..ae1054843f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt index 0dbbc39b09..cb093dfbb4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt index fcff3c8ba8..d376618300 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt @@ -27,9 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt index 4adcd72bc0..f07e5875df 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt index 1b50a772f5..992e24088e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt index c5c13ff25d..ad57bc5ec1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt index c403b90546..52597a40d8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt index 6f3335c7c4..dadf2805fd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt index 3d2219eabc..515a8c5ca1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt index 635c438e5b..dc56fa1ef4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt @@ -27,11 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt index 0200c6737e..20f50c631b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt index 046f6c26ca..e0218819e3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt index 2c58e6d837..3742241bfa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt new file mode 100644 index 0000000000..88702439c3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.chess.RelaySyncState +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +enum class ChangeSource { + TEXT, + FORM, + INIT, +} + +@OptIn(FlowPreview::class) +class AdvancedSearchBarState( + private val scope: CoroutineScope, + private val debounceMs: Long = 300L, +) { + private val _query = MutableStateFlow(SearchQuery.EMPTY) + val query: StateFlow = _query.asStateFlow() + + private var _changeSource: ChangeSource = ChangeSource.INIT + val changeSource get() = _changeSource + + private val _rawText = MutableStateFlow("") + val rawText: StateFlow = _rawText.asStateFlow() + + val displayText: StateFlow = + combine(_query, _rawText) { query, raw -> + if (_changeSource == ChangeSource.TEXT) { + raw + } else { + QuerySerializer.serialize(query) + } + }.stateIn(scope, SharingStarted.Eagerly, "") + + val debouncedQuery: StateFlow = + _query + .debounce(debounceMs) + .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY) + + // People search results (from cache + relay) + private val _peopleResults = MutableStateFlow>(persistentListOf()) + val peopleResults: StateFlow> = _peopleResults.asStateFlow() + + // Note/event results (from relay subscriptions) + private val _noteResults = MutableStateFlow>(persistentListOf()) + val noteResults: StateFlow> = _noteResults.asStateFlow() + + // Sort orders + private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT) + val eventSortOrder: StateFlow = _eventSortOrder.asStateFlow() + + private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE) + val peopleSortOrder: StateFlow = _peopleSortOrder.asStateFlow() + + // Derived sorted results + val sortedNoteResults: StateFlow> = + combine(_noteResults, _eventSortOrder, _rawText) { notes, order, text -> + SearchResultSorter.sortEvents(notes, order, text).toImmutableList() + }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + val sortedPeopleResults: StateFlow> = + combine(_peopleResults, _peopleSortOrder) { people, order -> + SearchResultSorter.sortPeople(people, order).toImmutableList() + }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + private val activeSubIds = MutableStateFlow>(emptySet()) + val isSearching: StateFlow = + activeSubIds + .map { it.isNotEmpty() } + .stateIn(scope, SharingStarted.Eagerly, false) + + private val eventDeduplicator = EventDeduplicator() + + // Expanded panel state + private val _panelExpanded = MutableStateFlow(false) + val panelExpanded: StateFlow = _panelExpanded.asStateFlow() + + // Per-relay sync status + private val _relayStates = MutableStateFlow>(persistentListOf()) + val relayStates: StateFlow> = _relayStates.asStateFlow() + + // Text bar input + fun updateFromText(rawText: String) { + _changeSource = ChangeSource.TEXT + _rawText.value = rawText + _query.value = QueryParser.parse(rawText) + } + + // Form panel inputs + fun updateKinds(kinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(kinds = kinds.toImmutableList()) + } + + fun updatePseudoKinds(pseudoKinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(pseudoKinds = pseudoKinds.toImmutableList()) + } + + fun addAuthor(hexOrName: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + val hex = + com.vitorpamplona.quartz.nip19Bech32 + .decodePublicKeyAsHexOrNull(hexOrName) + if (hex != null) { + if (hex !in current.authors) { + _query.value = current.copy(authors = (current.authors + hex).toImmutableList()) + } + } else { + if (hexOrName !in current.authorNames) { + _query.value = current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList()) + } + } + } + + fun removeAuthor(hex: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + _query.value = + current.copy( + authors = current.authors.filter { it != hex }.toImmutableList(), + authorNames = current.authorNames.filter { it != hex }.toImmutableList(), + ) + } + + fun updateDateRange( + since: Long?, + until: Long?, + ) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(since = since, until = until) + } + + fun addHashtag(tag: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + val cleaned = tag.removePrefix("#") + if (cleaned !in current.hashtags) { + _query.value = current.copy(hashtags = (current.hashtags + cleaned).toImmutableList()) + } + } + + fun removeHashtag(tag: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + _query.value = current.copy(hashtags = current.hashtags.filter { it != tag }.toImmutableList()) + } + + fun addExcludeTerm(term: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + if (term !in current.excludeTerms) { + _query.value = current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList()) + } + } + + fun removeExcludeTerm(term: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + _query.value = current.copy(excludeTerms = current.excludeTerms.filter { it != term }.toImmutableList()) + } + + fun updateLanguage(lang: String?) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(language = lang) + } + + fun initRelayStates(relays: Set) { + _relayStates.value = + relays + .map { + RelaySyncState( + url = it.url, + displayName = it.displayUrl(), + status = RelaySyncStatus.WAITING, + ) + }.toImmutableList() + } + + fun updateRelayState( + relayUrl: String, + status: RelaySyncStatus, + eventsDelta: Int = 0, + ) { + _relayStates.update { states -> + states + .map { + if (it.url == relayUrl) { + it.copy(status = status, eventsReceived = it.eventsReceived + eventsDelta) + } else { + it + } + }.toImmutableList() + } + } + + fun timeoutWaitingRelays() { + _relayStates.update { states -> + states + .map { + if (it.status == RelaySyncStatus.WAITING || it.status == RelaySyncStatus.CONNECTING) { + it.copy(status = RelaySyncStatus.FAILED) + } else { + it + } + }.toImmutableList() + } + activeSubIds.value = emptySet() + } + + fun togglePanel() { + _panelExpanded.value = !_panelExpanded.value + } + + fun updateEventSortOrder(order: SearchSortOrder) { + _eventSortOrder.value = order + } + + fun updatePeopleSortOrder(order: SearchSortOrder) { + _peopleSortOrder.value = order + } + + fun clearSearch() { + _changeSource = ChangeSource.INIT + _rawText.value = "" + _query.value = SearchQuery.EMPTY + _peopleResults.value = persistentListOf() + _noteResults.value = persistentListOf() + _relayStates.value = persistentListOf() + _eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT + _peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE + activeSubIds.value = emptySet() + eventDeduplicator.clear() + } + + // Results management (called from subscription callbacks) + fun startSearching(subId: String) { + activeSubIds.update { it + subId } + } + + fun stopSearching(subId: String) { + activeSubIds.update { it - subId } + } + + fun trackRelayEvent( + relayUrl: String, + eventId: String, + ): Boolean { + val isNew = eventDeduplicator.tryAdd(eventId) + if (isNew) { + updateRelayState(relayUrl, RelaySyncStatus.RECEIVING, eventsDelta = 1) + } + return isNew + } + + fun clearResults() { + _peopleResults.value = persistentListOf() + _noteResults.value = persistentListOf() + eventDeduplicator.clear() + } + + fun addPeopleResult(user: User) { + val current = _peopleResults.value + if (current.none { it.pubkeyHex == user.pubkeyHex }) { + _peopleResults.value = (current + user).toImmutableList() + } + } + + fun addNoteResults(events: List) { + if (events.isNotEmpty()) { + val current = _noteResults.value + _noteResults.value = (current + events).toImmutableList() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt new file mode 100644 index 0000000000..e7b1d053bf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +object DateUtils { + fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + + fun dateToUnix( + year: Int, + month: Int, + day: Int, + ): Long { + var totalDays = 0L + + for (y in 1970 until year) { + totalDays += if (isLeapYear(y)) 366 else 365 + } + + val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + if (isLeapYear(year)) daysInMonth[2] = 29 + for (m in 1 until month) { + totalDays += daysInMonth[m] + } + + totalDays += (day - 1) + + return totalDays * 86400L + } + + fun timestampToDate(timestamp: Long): String { + var remaining = timestamp + var year = 1970 + while (true) { + val daysInYear = if (isLeapYear(year)) 366L else 365L + val secondsInYear = daysInYear * 86400L + if (remaining < secondsInYear) break + remaining -= secondsInYear + year++ + } + + val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + if (isLeapYear(year)) daysInMonth[2] = 29 + + var dayOfYear = (remaining / 86400).toInt() + 1 + var month = 1 + while (month <= 12 && dayOfYear > daysInMonth[month]) { + dayOfYear -= daysInMonth[month] + month++ + } + + return "$year-${month.toString().padStart(2, '0')}-${dayOfYear.toString().padStart(2, '0')}" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt new file mode 100644 index 0000000000..8a843955ae --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +class EventDeduplicator { + private val lock = Any() + private val seenIds = mutableSetOf() + + fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) } + + fun contains(id: String): Boolean = synchronized(lock) { id in seenIds } + + fun clear() = synchronized(lock) { seenIds.clear() } + + val size: Int get() = synchronized(lock) { seenIds.size } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt new file mode 100644 index 0000000000..e914be2941 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt @@ -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.commons.search + +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +data class ContentPreset( + val kinds: List = emptyList(), + val pseudoKind: String? = null, +) { + /** Check if this preset is active given current query state. */ + fun isSelected( + queryKinds: List, + queryPseudoKinds: List, + ): Boolean = + if (pseudoKind != null) { + pseudoKind in queryPseudoKinds + } else { + kinds.isNotEmpty() && queryKinds.containsAll(kinds) + } +} + +object KindRegistry { + val aliases: Map> = + mapOf( + "note" to listOf(TextNoteEvent.KIND), + "article" to listOf(LongTextNoteEvent.KIND), + "repost" to listOf(RepostEvent.KIND), + "profile" to listOf(MetadataEvent.KIND), + "channel" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), + "live" to listOf(LiveActivitiesEvent.KIND), + "community" to listOf(CommunityDefinitionEvent.KIND), + "wiki" to listOf(WikiNoteEvent.KIND), + "classified" to listOf(ClassifiedsEvent.KIND), + "highlight" to listOf(HighlightEvent.KIND), + ) + + val pseudoKinds: Set = setOf("reply", "media") + + val presets: Map = + mapOf( + "Notes" to ContentPreset(kinds = listOf(TextNoteEvent.KIND)), + "Articles" to ContentPreset(kinds = listOf(LongTextNoteEvent.KIND)), + "Media" to ContentPreset(pseudoKind = "media"), + "Channels" to ContentPreset(kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND)), + "Communities" to ContentPreset(kinds = listOf(CommunityDefinitionEvent.KIND)), + "Wiki" to ContentPreset(kinds = listOf(WikiNoteEvent.KIND)), + ) + + fun resolve(alias: String): List? = aliases[alias.lowercase()] + + fun isPseudoKind(alias: String): Boolean = alias.lowercase() in pseudoKinds + + fun nameFor(kind: Int): String? = aliases.entries.find { kind in it.value }?.key +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt new file mode 100644 index 0000000000..073e9cc41f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList + +sealed interface Token { + data class Operator( + val name: String, + val value: String, + val raw: String, + ) : Token + + data class Text( + val value: String, + ) : Token + + data object Or : Token + + data class Quoted( + val value: String, + val raw: String, + ) : Token + + data class Negation( + val term: String, + ) : Token + + data class Hashtag( + val tag: String, + ) : Token +} + +object QueryParser { + private val KNOWN_OPERATORS = setOf("from", "kind", "since", "until", "lang", "domain") + + fun parse(input: String): SearchQuery { + if (input.isBlank()) return SearchQuery.EMPTY + val tokens = tokenize(input) + return buildQuery(tokens) + } + + internal fun tokenize(input: String): List { + val tokens = mutableListOf() + var i = 0 + val len = input.length + + while (i < len) { + // Skip whitespace + if (input[i].isWhitespace()) { + i++ + continue + } + + // Quoted phrase + if (input[i] == '"') { + val start = i + i++ // skip opening quote + val sb = StringBuilder() + while (i < len && input[i] != '"') { + sb.append(input[i]) + i++ + } + if (i < len) i++ // skip closing quote + val value = sb.toString() + tokens.add(Token.Quoted(value, input.substring(start, i))) + continue + } + + // Negation + if (input[i] == '-' && i + 1 < len && !input[i + 1].isWhitespace()) { + i++ // skip - + val word = readWord(input, i) + i += word.length + if (word.isNotEmpty()) { + tokens.add(Token.Negation(word)) + } + continue + } + + // Hashtag + if (input[i] == '#' && i + 1 < len && !input[i + 1].isWhitespace()) { + i++ // skip # + val tag = readWord(input, i) + i += tag.length + if (tag.isNotEmpty()) { + tokens.add(Token.Hashtag(tag)) + } + continue + } + + // Read a word (may be operator:value, OR, or plain text) + val word = readWord(input, i) + i += word.length + + if (word.isEmpty()) { + i++ + continue + } + + // Check for OR keyword + if (word == "OR") { + tokens.add(Token.Or) + continue + } + + // Check for operator pattern (word:value) + val colonIdx = word.indexOf(':') + if (colonIdx > 0) { + val opName = word.substring(0, colonIdx).lowercase() + val opValue = word.substring(colonIdx + 1) + if (opName in KNOWN_OPERATORS && opValue.isNotEmpty()) { + tokens.add(Token.Operator(opName, opValue, word)) + continue + } + // Malformed operator (no value or unknown) → treat as text + } + + tokens.add(Token.Text(word)) + } + + return tokens + } + + private fun readWord( + input: String, + start: Int, + ): String { + var i = start + while (i < input.length && !input[i].isWhitespace()) { + i++ + } + return input.substring(start, i) + } + + private fun buildQuery(tokens: List): SearchQuery { + val authors = mutableListOf() + val authorNames = mutableListOf() + val kinds = mutableListOf() + val hashtags = mutableListOf() + val excludeTerms = mutableListOf() + val pseudoKinds = mutableListOf() + val textParts = mutableListOf() + val orTerms = mutableListOf() + var since: Long? = null + var until: Long? = null + var language: String? = null + var domain: String? = null + + // Collect OR groups: text terms separated by OR + var i = 0 + while (i < tokens.size) { + when (val token = tokens[i]) { + is Token.Operator -> { + when (token.name) { + "from" -> { + val hex = decodePublicKeyAsHexOrNull(token.value) + if (hex != null) { + authors.add(hex) + } else { + authorNames.add(token.value) + } + } + + "kind" -> { + if (KindRegistry.isPseudoKind(token.value)) { + pseudoKinds.add(token.value.lowercase()) + } else { + val resolved = KindRegistry.resolve(token.value) + if (resolved != null) { + kinds.addAll(resolved) + } else { + token.value.toIntOrNull()?.let { kinds.add(it) } + ?: textParts.add(token.raw) + } + } + } + + "since" -> { + val ts = parseDateToTimestamp(token.value) + if (ts != null) { + since = ts + } else { + textParts.add(token.raw) + } + } + + "until" -> { + val ts = parseDateToTimestamp(token.value) + if (ts != null) { + until = ts + } else { + textParts.add(token.raw) + } + } + + "lang" -> { + language = token.value.lowercase() + } + + "domain" -> { + domain = token.value.lowercase() + } + } + } + + is Token.Text -> { + // Check if this is part of an OR chain + if (i + 2 < tokens.size && tokens[i + 1] is Token.Or && tokens[i + 2] is Token.Text) { + // Start of OR chain: collect all terms + orTerms.add(token.value) + i++ // skip to OR + while (i < tokens.size && tokens[i] is Token.Or && i + 1 < tokens.size && tokens[i + 1] is Token.Text) { + i++ // skip OR + orTerms.add((tokens[i] as Token.Text).value) + i++ // skip text + } + continue + } else { + textParts.add(token.value) + } + } + + is Token.Quoted -> { + textParts.add(token.raw) + } + + is Token.Negation -> { + excludeTerms.add(token.term) + } + + is Token.Hashtag -> { + hashtags.add(token.tag) + } + + is Token.Or -> { + // Orphaned OR (no adjacent text terms) → treat as text + textParts.add("OR") + } + } + i++ + } + + // Cap OR terms at 3 + val cappedOrTerms = orTerms.take(3) + + return SearchQuery( + text = textParts.joinToString(" "), + authors = authors.distinct().toImmutableList(), + authorNames = authorNames.distinct().toImmutableList(), + kinds = kinds.distinct().toImmutableList(), + since = since, + until = until, + hashtags = hashtags.distinct().toImmutableList(), + excludeTerms = excludeTerms.distinct().toImmutableList(), + language = language, + domain = domain, + orTerms = cappedOrTerms.toPersistentList(), + pseudoKinds = pseudoKinds.distinct().toImmutableList(), + ) + } + + fun parseDateToTimestamp(dateStr: String): Long? { + // ISO 8601 formats: YYYY, YYYY-MM, YYYY-MM-DD + return try { + val parts = dateStr.split("-") + when (parts.size) { + 1 -> { + val year = parts[0].toIntOrNull() ?: return null + if (year < 1970 || year > 2100) return null + dateToUnix(year, 1, 1) + } + + 2 -> { + val year = parts[0].toIntOrNull() ?: return null + val month = parts[1].toIntOrNull() ?: return null + if (year < 1970 || year > 2100 || month < 1 || month > 12) return null + dateToUnix(year, month, 1) + } + + 3 -> { + val year = parts[0].toIntOrNull() ?: return null + val month = parts[1].toIntOrNull() ?: return null + val day = parts[2].toIntOrNull() ?: return null + if (year < 1970 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null + dateToUnix(year, month, day) + } + + else -> { + null + } + } + } catch (_: Exception) { + null + } + } + + private fun dateToUnix( + year: Int, + month: Int, + day: Int, + ): Long = DateUtils.dateToUnix(year, month, day) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt new file mode 100644 index 0000000000..3cd27a0178 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt @@ -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.commons.search + +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +object QuerySerializer { + fun serialize(query: SearchQuery): String { + if (query.isEmpty) return "" + + val parts = mutableListOf() + + // Operators first + query.authors.forEach { hex -> + val npub = + try { + NPub.create(hex) + } catch (_: Exception) { + null + } + parts.add("from:${npub ?: hex}") + } + query.authorNames.forEach { name -> + parts.add("from:$name") + } + query.kinds.forEach { kind -> + val name = KindRegistry.nameFor(kind) + parts.add("kind:${name ?: kind}") + } + query.pseudoKinds.forEach { pseudo -> + parts.add("kind:$pseudo") + } + query.since?.let { ts -> + parts.add("since:${timestampToDate(ts)}") + } + query.until?.let { ts -> + parts.add("until:${timestampToDate(ts)}") + } + query.language?.let { lang -> + parts.add("lang:$lang") + } + query.domain?.let { dom -> + parts.add("domain:$dom") + } + + // Hashtags + query.hashtags.forEach { tag -> + parts.add("#$tag") + } + + // Free text + if (query.text.isNotBlank()) { + parts.add(query.text) + } + + // OR terms + if (query.orTerms.isNotEmpty()) { + parts.add(query.orTerms.joinToString(" OR ")) + } + + // Exclusions last + query.excludeTerms.forEach { term -> + parts.add("-$term") + } + + return parts.joinToString(" ") + } + + fun timestampToDate(timestamp: Long): String = DateUtils.timestampToDate(timestamp) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt new file mode 100644 index 0000000000..20302fba73 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +data class SavedSearch( + val id: String, + val label: String, + val query: SearchQuery, + val createdAt: Long, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt new file mode 100644 index 0000000000..fff17c1df5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class SearchQuery( + val text: String = "", + val authors: ImmutableList = persistentListOf(), + val authorNames: ImmutableList = persistentListOf(), + val kinds: ImmutableList = persistentListOf(), + val since: Long? = null, + val until: Long? = null, + val hashtags: ImmutableList = persistentListOf(), + val excludeTerms: ImmutableList = persistentListOf(), + val language: String? = null, + val domain: String? = null, + val orTerms: ImmutableList = persistentListOf(), + val pseudoKinds: ImmutableList = persistentListOf(), +) { + val isEmpty + get() = + text.isBlank() && + authors.isEmpty() && + authorNames.isEmpty() && + kinds.isEmpty() && + since == null && + until == null && + hashtags.isEmpty() && + orTerms.isEmpty() && + excludeTerms.isEmpty() && + pseudoKinds.isEmpty() && + language == null && + domain == null + + companion object { + val EMPTY = SearchQuery() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt index dc77eba392..21cd3a0a24 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.search -import com.vitorpamplona.amethyst.commons.model.User - /** * Represents a parsed search result from Bech32/hex input. * Shared between Android and Desktop for consistent search behavior. @@ -35,13 +33,6 @@ sealed class SearchResult { val displayId: String, ) : SearchResult() - /** - * User from local cache with full metadata. - */ - data class CachedUserResult( - val user: User, - ) : SearchResult() - /** * Note lookup from note1 or nevent. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt new file mode 100644 index 0000000000..6cff2d8cf4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.quartz.nip01Core.core.Event + +object SearchResultFilter { + fun filter( + events: List, + query: SearchQuery, + ): List { + var result = events + + // Dedup by event ID + result = result.distinctBy { it.id } + + // Exclusion terms (client-side) + if (query.excludeTerms.isNotEmpty()) { + result = + result.filter { event -> + query.excludeTerms.none { term -> + event.content.contains(term, ignoreCase = true) + } + } + } + + // Pseudo-kind: reply (kind 1 with e tag) + if ("reply" in query.pseudoKinds) { + result = result.filter { event -> isReply(event) } + } + + // Pseudo-kind: media (kind 1 with imeta tag or image URLs) + if ("media" in query.pseudoKinds) { + result = result.filter { event -> isMedia(event) } + } + + // Sort by createdAt descending + return result.sortedByDescending { it.createdAt } + } + + fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" } + + fun isMedia(event: Event): Boolean { + if (event.kind != 1) return false + // Check for imeta tag + if (event.tags.any { it.size >= 2 && it[0] == "imeta" }) return true + // Check for image/video URLs in content + return IMAGE_URL_PATTERN.containsMatchIn(event.content) + } + + private val IMAGE_URL_PATTERN = + Regex( + """https?://\S+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)""", + RegexOption.IGNORE_CASE, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt new file mode 100644 index 0000000000..bc1ea81770 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.currentTimeSeconds + +object SearchResultSorter { + fun sortEvents( + events: List, + order: SearchSortOrder, + searchText: String, + ): List = + when (order) { + SearchSortOrder.NEWEST -> { + events.sortedByDescending { it.createdAt } + } + + SearchSortOrder.OLDEST -> { + events.sortedBy { it.createdAt } + } + + SearchSortOrder.RELEVANCE -> { + if (searchText.isBlank()) { + events.sortedByDescending { it.createdAt } + } else { + events.sortedByDescending { scoreEvent(it, searchText) } + } + } + + else -> { + events + } + } + + fun sortPeople( + people: List, + order: SearchSortOrder, + ): List = + when (order) { + SearchSortOrder.NAME_AZ -> people.sortedBy { it.toBestDisplayName().lowercase() } + SearchSortOrder.NAME_ZA -> people.sortedByDescending { it.toBestDisplayName().lowercase() } + else -> people + } + + fun scoreEvent( + event: Event, + searchText: String, + ): Double { + val query = searchText.trim().lowercase() + if (query.isEmpty()) return event.createdAt.toDouble() + + var score = 0.0 + val content = event.content.lowercase() + val tokens = query.split("\\s+".toRegex()) + + // Exact phrase match in content + if (content.contains(query)) { + score += 10.0 + } + + // Per-token scoring + for (token in tokens) { + if (token.isEmpty()) continue + val wordBoundary = "\\b${Regex.escape(token)}\\b".toRegex() + if (wordBoundary.containsMatchIn(content)) { + score += 5.0 + } else if (content.contains(token)) { + score += 2.0 + } + } + + // Article title boost + if (event is LongTextNoteEvent) { + val title = event.title()?.lowercase() + if (title != null) { + if (title.contains(query)) { + score += 15.0 + } + for (token in tokens) { + if (token.isEmpty()) continue + if (title.contains(token)) { + score += 3.0 + } + } + } + } + + // Recency tiebreaker (normalized 0..1) + val now = currentTimeSeconds() + val age = (now - event.createdAt).coerceAtLeast(1) + val maxAge = 365L * 24 * 3600 // 1 year + score += (1.0 - (age.toDouble() / maxAge).coerceIn(0.0, 1.0)) + + return score + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt similarity index 69% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt index b53f3482ed..1b43507870 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt @@ -18,24 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.amethyst.commons.search -import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +enum class SearchSortOrder( + val label: String, +) { + NEWEST("Newest"), + OLDEST("Oldest"), + RELEVANCE("Relevance"), + NAME_AZ("A → Z"), + NAME_ZA("Z → A"), + ; -// REQUEST OBJECTS -abstract class Request( - var method: String? = null, -) : OptimizedSerializable - -// PayInvoice Call -class PayInvoiceParams( - var invoice: String? = null, -) - -class PayInvoiceMethod( - var params: PayInvoiceParams? = null, -) : Request("pay_invoice") { companion object { - fun create(bolt11: String): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11)) + val EVENT_OPTIONS = listOf(NEWEST, OLDEST, RELEVANCE) + val PEOPLE_OPTIONS = listOf(NAME_AZ, NAME_ZA) + val DEFAULT_EVENT = NEWEST + val DEFAULT_PEOPLE = NAME_AZ } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt index 7c295ef918..9a3f43f22c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState @@ -69,14 +70,10 @@ fun BunkerHeartbeatIndicator( is SignerConnectionState.Disconnected -> { "Bunker disconnected" } - - else -> { - "" - } } TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { PlainTooltip { Text(tooltipText) } }, state = tooltipState, modifier = modifier, @@ -110,8 +107,6 @@ fun BunkerHeartbeatIndicator( modifier = Modifier.size(20.dp), ) } - - else -> {} } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index 9b9cc7f066..f9b1086bb5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -28,13 +28,11 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -62,9 +60,6 @@ class ChatNewMessageState( private val _message = MutableStateFlow(TextFieldValue("")) val message: StateFlow = _message.asStateFlow() - private val _nip17 = MutableStateFlow(false) - val nip17: StateFlow = _nip17.asStateFlow() - private val _replyTo = MutableStateFlow(null) val replyTo: StateFlow = _replyTo.asStateFlow() @@ -74,38 +69,37 @@ class ChatNewMessageState( private val _room = MutableStateFlow(null) val room: StateFlow = _room.asStateFlow() - /** Whether NIP-17 is required (group chat with >1 recipient) */ - private val _requiresNip17 = MutableStateFlow(false) - val requiresNip17: StateFlow = _requiresNip17.asStateFlow() + /** Whether any recipients are missing DM relay lists, preventing message delivery */ + private val _recipientsMissingDmRelays = MutableStateFlow(false) + val recipientsMissingDmRelays: StateFlow = _recipientsMissingDmRelays.asStateFlow() - /** Whether a message can be sent (non-blank text + room set) */ + /** Whether a message can be sent (non-blank text + room set + all recipients have DM relays) */ val canSend: Boolean - get() = _message.value.text.isNotBlank() && _room.value != null + get() = _message.value.text.isNotBlank() && _room.value != null && !_recipientsMissingDmRelays.value /** - * Load a chatroom. Sets the room key, formats toUsers display, - * and auto-detects NIP-17 requirement (group chats require NIP-17). + * Load a chatroom. Sets the room key and checks recipient DM relay availability. */ fun load(roomKey: ChatroomKey) { _room.value = roomKey - updateNip17FromRoom() + updateRecipientRelayStatus() } /** - * Auto-detect NIP-17 based on room: - * - Group chats (>1 recipient) always require NIP-17 - * - Single recipient: NIP-17 off by default (can be toggled) + * Check if all recipients have DM relay lists. + * Messages can only be sent via NIP-17, so recipients must have + * either a DM inbox relay list (kind 10050) or NIP-65 inbox relays. */ - fun updateNip17FromRoom() { + fun updateRecipientRelayStatus() { val currentRoom = _room.value if (currentRoom != null) { - _requiresNip17.value = currentRoom.users.size > 1 - if (_requiresNip17.value) { - _nip17.value = true - } + _recipientsMissingDmRelays.value = + currentRoom.users.any { hexKey -> + val user = cache.getOrCreateUser(hexKey) as? User + user?.dmInboxRelays().isNullOrEmpty() + } } else { - _requiresNip17.value = false - _nip17.value = false + _recipientsMissingDmRelays.value = false } } @@ -126,27 +120,7 @@ class ChatNewMessageState( } /** - * Toggle NIP-04/NIP-17 mode. - * If NIP-17 is required (group chat), stays on NIP-17. - */ - fun toggleNip17() { - if (_requiresNip17.value) { - _nip17.value = true - } else { - _nip17.value = !_nip17.value - } - } - - /** - * Enable NIP-17 (e.g., when recipient has DM relay list). - */ - fun enableNip17() { - _nip17.value = true - } - - /** - * Send the current message. Builds the appropriate event template - * (NIP-04 or NIP-17) and delegates to IAccount for signing/broadcasting. + * Send the current message as NIP-17. NIP-04 is deprecated for sending. * * @return true if send was initiated, false if preconditions not met */ @@ -154,12 +128,9 @@ class ChatNewMessageState( val currentRoom = _room.value ?: return false val messageText = _message.value.text if (messageText.isBlank()) return false + if (_recipientsMissingDmRelays.value) return false - if (_nip17.value || currentRoom.users.size > 1 || _replyTo.value?.event is NIP17Group) { - sendNip17(currentRoom, messageText) - } else { - sendNip04(currentRoom, messageText) - } + sendNip17(currentRoom, messageText) return true } @@ -193,23 +164,6 @@ class ChatNewMessageState( account.sendNip17PrivateMessage(template) } - private suspend fun sendNip04( - room: ChatroomKey, - messageText: String, - ) { - val toUser = (cache.getOrCreateUser(room.users.first()) as? User)?.toPTag() ?: return - - val template = - PrivateDmEvent.build( - toUser = toUser, - message = messageText, - replyingTo = _replyTo.value?.toEventHint(), - signer = account.signer, - ) - - account.sendNip04PrivateMessage(template) - } - /** * Clear all composition state after sending or cancelling. */ diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt new file mode 100644 index 0000000000..5a03ab61c8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KindRegistryTest { + @Test + fun resolveNote() { + assertEquals(listOf(1), KindRegistry.resolve("note")) + } + + @Test + fun resolveArticle() { + assertEquals(listOf(30023), KindRegistry.resolve("article")) + } + + @Test + fun resolveChannel() { + val kinds = KindRegistry.resolve("channel")!! + assertTrue(40 in kinds) + assertTrue(41 in kinds) + } + + @Test + fun resolveCaseInsensitive() { + assertEquals(KindRegistry.resolve("NOTE"), KindRegistry.resolve("note")) + } + + @Test + fun resolveUnknown() { + assertNull(KindRegistry.resolve("unknown")) + } + + @Test + fun isPseudoKindReply() { + assertTrue(KindRegistry.isPseudoKind("reply")) + assertTrue(KindRegistry.isPseudoKind("Reply")) + } + + @Test + fun isPseudoKindMedia() { + assertTrue(KindRegistry.isPseudoKind("media")) + } + + @Test + fun isNotPseudoKind() { + assertFalse(KindRegistry.isPseudoKind("note")) + assertFalse(KindRegistry.isPseudoKind("article")) + } + + @Test + fun nameForKind1() { + assertEquals("note", KindRegistry.nameFor(1)) + } + + @Test + fun nameForKind30023() { + assertEquals("article", KindRegistry.nameFor(30023)) + } + + @Test + fun nameForUnknownKind() { + assertNull(KindRegistry.nameFor(99999)) + } + + @Test + fun allAliasesResolve() { + KindRegistry.aliases.forEach { (alias, kinds) -> + assertEquals(kinds, KindRegistry.resolve(alias)) + } + } + + @Test + fun presetsContainExpectedEntries() { + assertTrue(KindRegistry.presets.containsKey("Notes")) + assertTrue(KindRegistry.presets.containsKey("Articles")) + assertTrue(KindRegistry.presets.containsKey("Media")) + assertTrue(KindRegistry.presets.containsKey("Channels")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt new file mode 100644 index 0000000000..df5c3af77c --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QueryParserTest { + @Test + fun emptyInput() { + val q = QueryParser.parse("") + assertTrue(q.isEmpty) + assertEquals(SearchQuery.EMPTY, q) + } + + @Test + fun whitespaceOnly() { + val q = QueryParser.parse(" ") + assertTrue(q.isEmpty) + } + + @Test + fun plainText() { + val q = QueryParser.parse("bitcoin lightning") + assertEquals("bitcoin lightning", q.text) + assertTrue(q.authors.isEmpty()) + assertTrue(q.kinds.isEmpty()) + } + + @Test + fun kindOperatorAlias() { + val q = QueryParser.parse("kind:note") + assertEquals(listOf(1), q.kinds.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun kindOperatorNumeric() { + val q = QueryParser.parse("kind:30023") + assertEquals(listOf(30023), q.kinds.toList()) + } + + @Test + fun kindOperatorArticle() { + val q = QueryParser.parse("kind:article") + assertEquals(listOf(30023), q.kinds.toList()) + } + + @Test + fun kindOperatorInvalid() { + val q = QueryParser.parse("kind:invalid") + // Unresolvable kind → treated as text + assertEquals("kind:invalid", q.text) + assertTrue(q.kinds.isEmpty()) + } + + @Test + fun pseudoKindReply() { + val q = QueryParser.parse("kind:reply") + assertTrue(q.kinds.isEmpty()) + assertEquals(listOf("reply"), q.pseudoKinds.toList()) + } + + @Test + fun pseudoKindMedia() { + val q = QueryParser.parse("kind:media") + assertTrue(q.kinds.isEmpty()) + assertEquals(listOf("media"), q.pseudoKinds.toList()) + } + + @Test + fun multipleKinds() { + val q = QueryParser.parse("kind:note kind:article") + assertEquals(listOf(1, 30023), q.kinds.toList()) + } + + @Test + fun sinceDate() { + val q = QueryParser.parse("since:2025-01-01") + // 2025-01-01 00:00:00 UTC + assertEquals(1735689600L, q.since) + } + + @Test + fun sinceDateYearOnly() { + val q = QueryParser.parse("since:2025") + // 2025-01-01 00:00:00 UTC + assertEquals(1735689600L, q.since) + } + + @Test + fun sinceDateYearMonth() { + val q = QueryParser.parse("since:2025-06") + // 2025-06-01 00:00:00 UTC + val q2 = QueryParser.parse("since:2025-06-01") + assertEquals(q2.since, q.since) + } + + @Test + fun sinceInvalidDate() { + val q = QueryParser.parse("since:not-a-date") + assertNull(q.since) + assertEquals("since:not-a-date", q.text) + } + + @Test + fun untilDate() { + val q = QueryParser.parse("until:2025-12-31") + assertNull(q.since) + assertTrue(q.until != null && q.until > 0) + } + + @Test + fun hashtag() { + val q = QueryParser.parse("#bitcoin") + assertEquals(listOf("bitcoin"), q.hashtags.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun multipleHashtags() { + val q = QueryParser.parse("#bitcoin #nostr") + assertEquals(listOf("bitcoin", "nostr"), q.hashtags.toList()) + } + + @Test + fun negationTerm() { + val q = QueryParser.parse("-spam") + assertEquals(listOf("spam"), q.excludeTerms.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun multipleNegations() { + val q = QueryParser.parse("-spam -scam") + assertEquals(listOf("spam", "scam"), q.excludeTerms.toList()) + } + + @Test + fun quotedPhrase() { + val q = QueryParser.parse("\"exact phrase\"") + assertEquals("\"exact phrase\"", q.text) + } + + @Test + fun orTerms() { + val q = QueryParser.parse("bitcoin OR lightning") + assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun orTermsWithOperators() { + val q = QueryParser.parse("from:vitor bitcoin OR lightning kind:note") + assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList()) + assertEquals(listOf(1), q.kinds.toList()) + // from:vitor → authorNames since it's not a valid npub + assertEquals(listOf("vitor"), q.authorNames.toList()) + } + + @Test + fun orTermsCappedAtThree() { + val q = QueryParser.parse("a OR b OR c OR d OR e") + assertEquals(3, q.orTerms.size) + assertEquals(listOf("a", "b", "c"), q.orTerms.toList()) + } + + @Test + fun orphanedOr() { + val q = QueryParser.parse("OR") + assertEquals("OR", q.text) + assertTrue(q.orTerms.isEmpty()) + } + + @Test + fun languageOperator() { + val q = QueryParser.parse("lang:en bitcoin") + assertEquals("en", q.language) + assertEquals("bitcoin", q.text) + } + + @Test + fun domainOperator() { + val q = QueryParser.parse("domain:nostr.com bitcoin") + assertEquals("nostr.com", q.domain) + assertEquals("bitcoin", q.text) + } + + @Test + fun combinedQuery() { + val q = QueryParser.parse("kind:note since:2025-01-01 #bitcoin -spam lightning") + assertEquals(listOf(1), q.kinds.toList()) + assertEquals(1735689600L, q.since) + assertEquals(listOf("bitcoin"), q.hashtags.toList()) + assertEquals(listOf("spam"), q.excludeTerms.toList()) + assertEquals("lightning", q.text) + } + + @Test + fun caseInsensitiveOperators() { + val q = QueryParser.parse("FROM:vitor KIND:Note") + assertEquals(listOf("vitor"), q.authorNames.toList()) + assertEquals(listOf(1), q.kinds.toList()) + } + + @Test + fun operatorWithNoValue() { + val q = QueryParser.parse("from:") + // Malformed → treated as text + assertEquals("from:", q.text) + assertTrue(q.authors.isEmpty()) + } + + @Test + fun fromWithAuthorName() { + val q = QueryParser.parse("from:vitor") + assertEquals(listOf("vitor"), q.authorNames.toList()) + assertTrue(q.authors.isEmpty()) + } + + @Test + fun multipleFromAuthors() { + val q = QueryParser.parse("from:alice from:bob") + assertEquals(listOf("alice", "bob"), q.authorNames.toList()) + } + + @Test + fun duplicateAuthorsDeduped() { + val q = QueryParser.parse("from:vitor from:vitor") + assertEquals(1, q.authorNames.size) + } + + @Test + fun parseDateToTimestamp_validDates() { + // 1970-01-01 = 0 + assertEquals(0L, QueryParser.parseDateToTimestamp("1970-01-01")) + // 2000-01-01 + assertEquals(946684800L, QueryParser.parseDateToTimestamp("2000-01-01")) + } + + @Test + fun parseDateToTimestamp_invalidDates() { + assertNull(QueryParser.parseDateToTimestamp("not-a-date")) + assertNull(QueryParser.parseDateToTimestamp("1800-01-01")) + assertNull(QueryParser.parseDateToTimestamp("2025-13-01")) + assertNull(QueryParser.parseDateToTimestamp("2025-01-32")) + } + + @Test + fun unicodeInFreeText() { + val q = QueryParser.parse("bitcoin 日本語 🚀") + assertFalse(q.isEmpty) + assertTrue(q.text.contains("日本語")) + assertTrue(q.text.contains("🚀")) + } + + @Test + fun veryLongQuery() { + val longText = "word ".repeat(100).trim() + val q = QueryParser.parse(longText) + assertFalse(q.isEmpty) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt new file mode 100644 index 0000000000..0534e50138 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt @@ -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.commons.search + +import kotlinx.collections.immutable.persistentListOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QuerySerializerTest { + @Test + fun emptyQuery() { + assertEquals("", QuerySerializer.serialize(SearchQuery.EMPTY)) + } + + @Test + fun textOnly() { + val q = SearchQuery(text = "bitcoin") + assertEquals("bitcoin", QuerySerializer.serialize(q)) + } + + @Test + fun kindOnly() { + val q = SearchQuery(kinds = persistentListOf(1)) + assertEquals("kind:note", QuerySerializer.serialize(q)) + } + + @Test + fun kindUnknown() { + val q = SearchQuery(kinds = persistentListOf(99999)) + assertEquals("kind:99999", QuerySerializer.serialize(q)) + } + + @Test + fun multipleKinds() { + val q = SearchQuery(kinds = persistentListOf(1, 30023)) + assertEquals("kind:note kind:article", QuerySerializer.serialize(q)) + } + + @Test + fun pseudoKinds() { + val q = SearchQuery(pseudoKinds = persistentListOf("reply", "media")) + assertEquals("kind:reply kind:media", QuerySerializer.serialize(q)) + } + + @Test + fun sinceDate() { + val q = SearchQuery(since = 1735689600L) // 2025-01-01 + assertEquals("since:2025-01-01", QuerySerializer.serialize(q)) + } + + @Test + fun untilDate() { + val q = SearchQuery(until = 1735689600L) + assertEquals("until:2025-01-01", QuerySerializer.serialize(q)) + } + + @Test + fun hashtags() { + val q = SearchQuery(hashtags = persistentListOf("bitcoin", "nostr")) + assertEquals("#bitcoin #nostr", QuerySerializer.serialize(q)) + } + + @Test + fun excludeTerms() { + val q = SearchQuery(excludeTerms = persistentListOf("spam", "scam")) + assertEquals("-spam -scam", QuerySerializer.serialize(q)) + } + + @Test + fun orTerms() { + val q = SearchQuery(orTerms = persistentListOf("bitcoin", "lightning")) + assertEquals("bitcoin OR lightning", QuerySerializer.serialize(q)) + } + + @Test + fun language() { + val q = SearchQuery(language = "en", text = "bitcoin") + assertEquals("lang:en bitcoin", QuerySerializer.serialize(q)) + } + + @Test + fun domain() { + val q = SearchQuery(domain = "nostr.com", text = "hello") + assertEquals("domain:nostr.com hello", QuerySerializer.serialize(q)) + } + + @Test + fun authorNames() { + val q = SearchQuery(authorNames = persistentListOf("vitor")) + assertEquals("from:vitor", QuerySerializer.serialize(q)) + } + + @Test + fun combinedQuery() { + val q = + SearchQuery( + authorNames = persistentListOf("vitor"), + kinds = persistentListOf(1), + since = 1735689600L, + hashtags = persistentListOf("bitcoin"), + text = "lightning", + excludeTerms = persistentListOf("spam"), + ) + val result = QuerySerializer.serialize(q) + assertTrue(result.contains("from:vitor")) + assertTrue(result.contains("kind:note")) + assertTrue(result.contains("since:2025-01-01")) + assertTrue(result.contains("#bitcoin")) + assertTrue(result.contains("lightning")) + assertTrue(result.contains("-spam")) + } + + @Test + fun orderingOperatorsFirst() { + val q = + SearchQuery( + authorNames = persistentListOf("alice"), + kinds = persistentListOf(1), + hashtags = persistentListOf("nostr"), + text = "hello", + excludeTerms = persistentListOf("bad"), + ) + val result = QuerySerializer.serialize(q) + val fromIdx = result.indexOf("from:") + val kindIdx = result.indexOf("kind:") + val hashIdx = result.indexOf("#nostr") + val textIdx = result.indexOf("hello") + val excludeIdx = result.indexOf("-bad") + assertTrue(fromIdx < kindIdx) + assertTrue(kindIdx < hashIdx) + assertTrue(hashIdx < textIdx) + assertTrue(textIdx < excludeIdx) + } + + @Test + fun timestampToDateEpoch() { + assertEquals("1970-01-01", QuerySerializer.timestampToDate(0L)) + } + + @Test + fun timestampToDate2025() { + assertEquals("2025-01-01", QuerySerializer.timestampToDate(1735689600L)) + } + + @Test + fun roundtrip() { + // Parse a complex query, serialize, parse again — should produce equivalent SearchQuery + val input = "kind:note since:2025-01-01 #bitcoin lightning -spam" + val q1 = QueryParser.parse(input) + val serialized = QuerySerializer.serialize(q1) + val q2 = QueryParser.parse(serialized) + assertEquals(q1.kinds, q2.kinds) + assertEquals(q1.since, q2.since) + assertEquals(q1.hashtags, q2.hashtags) + assertEquals(q1.excludeTerms, q2.excludeTerms) + assertEquals(q1.text, q2.text) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt new file mode 100644 index 0000000000..bdf3b3cf6e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SearchResultSorterTest { + private fun event( + id: String, + createdAt: Long, + content: String = "", + kind: Int = 1, + ) = Event( + id = id, + pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", + createdAt = createdAt, + kind = kind, + tags = emptyArray(), + content = content, + sig = "sig", + ) + + private fun article( + id: String, + createdAt: Long, + content: String = "", + title: String? = null, + ): LongTextNoteEvent { + val tags = + if (title != null) { + arrayOf(arrayOf("title", title)) + } else { + emptyArray() + } + return LongTextNoteEvent( + id = id, + pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", + createdAt = createdAt, + tags = tags, + content = content, + sig = "sig", + ) + } + + private fun user( + hex: String, + displayName: String, + ): User { + val u = User(hex, Note("r1-$hex"), Note("r2-$hex")) + val meta = UserMetadata().apply { this.displayName = displayName } + val metaEvent = + MetadataEvent( + id = "meta-$hex", + pubKey = hex, + createdAt = 0L, + tags = emptyArray(), + content = "{}", + sig = "sig", + ) + u.updateUserInfo(meta, metaEvent) + return u + } + + // --- Event sorting --- + + @Test + fun newestSortsDescending() { + val events = listOf(event("a", 100), event("b", 300), event("c", 200)) + val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.NEWEST, "") + assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) + } + + @Test + fun oldestSortsAscending() { + val events = listOf(event("a", 300), event("b", 100), event("c", 200)) + val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.OLDEST, "") + assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) + } + + @Test + fun relevanceEmptyQueryFallsBackToRecency() { + val events = listOf(event("a", 100), event("b", 300), event("c", 200)) + val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.RELEVANCE, "") + assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) + } + + @Test + fun relevanceExactMatchBeatsPartial() { + val exact = event("exact", 100, content = "bitcoin is great") + val partial = event("partial", 100, content = "bit of something") + val sorted = SearchResultSorter.sortEvents(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin") + assertEquals("exact", sorted.first().id) + } + + @Test + fun relevanceWordBoundaryBeatsSubstring() { + val boundary = event("boundary", 100, content = "I love bitcoin and lightning") + val substring = event("substr", 100, content = "bitcoinery is not a word") + val sorted = SearchResultSorter.sortEvents(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin") + assertEquals("boundary", sorted.first().id) + } + + @Test + fun relevanceArticleTitleBoost() { + val withTitle = article("titled", 100, content = "some content", title = "Bitcoin Guide") + val withoutTitle = event("notitle", 100, content = "bitcoin bitcoin bitcoin") + val sorted = SearchResultSorter.sortEvents(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin") + assertEquals("titled", sorted.first().id) + } + + @Test + fun relevanceMultipleTokensAddUp() { + val multi = event("multi", 100, content = "bitcoin and lightning network") + val single = event("single", 100, content = "bitcoin only here") + val sorted = + SearchResultSorter.sortEvents( + listOf(single, multi), + SearchSortOrder.RELEVANCE, + "bitcoin lightning", + ) + assertEquals("multi", sorted.first().id) + } + + // --- People sorting --- + + @Test + fun nameAzSortsAlphabetically() { + val people = + listOf( + user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"), + user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"), + user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"), + ) + val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ) + assertEquals(listOf("Alice", "Bob", "Charlie"), sorted.map { it.toBestDisplayName() }) + } + + @Test + fun nameZaSortsReverseAlphabetically() { + val people = + listOf( + user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"), + user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"), + user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"), + ) + val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_ZA) + assertEquals(listOf("Charlie", "Bob", "Alice"), sorted.map { it.toBestDisplayName() }) + } + + @Test + fun nameSortIsCaseInsensitive() { + val people = + listOf( + user("aa00000000000000000000000000000000000000000000000000000000000000", "alice"), + user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"), + ) + val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ) + assertEquals("alice", sorted.first().toBestDisplayName()) + } + + // --- Score function --- + + @Test + fun scoreExactPhraseHigherThanTokens() { + val exactEvent = event("e", 100, content = "bitcoin lightning network") + val tokenEvent = event("t", 100, content = "lightning and bitcoin elsewhere network") + val exactScore = SearchResultSorter.scoreEvent(exactEvent, "bitcoin lightning") + val tokenScore = SearchResultSorter.scoreEvent(tokenEvent, "bitcoin lightning") + assertTrue(exactScore > tokenScore) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt index 96233860b6..a133912b3a 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.services.nwc import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import java.util.concurrent.ConcurrentHashMap /** diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 85c1c5607e..f17e8090ea 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) + id("ir.mahozad.vlc-setup") version "0.1.0" } sourceSets { @@ -23,9 +24,9 @@ kotlin { dependencies { implementation(compose.desktop.currentOs) - implementation(compose.material3) - implementation(compose.materialIconsExtended) - implementation(compose.components.resources) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.material.icons.extended) + implementation(libs.jetbrains.compose.components.resources) // Quartz Nostr library (will use JVM target) implementation(project(":quartz")) @@ -46,8 +47,20 @@ dependencies { // JSON implementation(libs.jackson.module.kotlin) + // Image loading (Coil3 — explicit because commons uses implementation, not api) + implementation(libs.coil.compose) + implementation(libs.coil.okhttp) + implementation(libs.coil.svg) + + // Video playback + implementation(libs.vlcj) + + // EXIF stripping (lossless) + implementation(libs.commons.imaging) + // Collections implementation(libs.kotlinx.collections.immutable) + implementation(libs.androidx.collection) // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps implementation("org.slf4j:slf4j-nop:2.0.16") @@ -65,8 +78,10 @@ dependencies { compose.desktop { application { mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED" nativeDistributions { + appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Amethyst" @@ -91,3 +106,12 @@ compose.desktop { } } } + +vlcSetup { + vlcVersion.set("3.0.21") + shouldCompressVlcFiles.set(true) + shouldIncludeAllVlcFiles.set(true) + pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc")) + pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc")) + pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc")) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 86b7e9d134..f06acf2e71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -68,4 +68,17 @@ object DesktopPreferences { set(value) { prefs.put(KEY_LAYOUT_MODE, value) } + + private const val KEY_BLOSSOM_SERVERS = "blossom_servers" + private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" + + var blossomServers: List + get() { + val raw = prefs.get(KEY_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVER) + return if (raw.isBlank()) emptyList() else raw.split(",") + } + set(value) = prefs.put(KEY_BLOSSOM_SERVERS, value.joinToString(",")) + + val preferredBlossomServer: String + get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 30470359f5..3d106a9e4e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -48,6 +50,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -74,6 +77,8 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup +import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen @@ -87,8 +92,12 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout +import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow +import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen +import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard +import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope @@ -137,7 +146,17 @@ sealed class DesktopScreen { data object Settings : DesktopScreen() } -fun main() = +fun main() { + DesktopImageLoaderSetup.setup() + Runtime.getRuntime().addShutdownHook( + Thread { + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .shutdown() + VlcjPlayerPool.shutdown() + }, + ) + // Pre-init VLC on background thread so first play is fast + Thread { VlcjPlayerPool.init() }.start() application { val windowState = rememberWindowState( @@ -363,27 +382,35 @@ fun main() = } } - App( - layoutMode = layoutMode, - deckState = deckState, - accountManager = accountManager, - showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, - onShowComposeDialog = { showComposeDialog = true }, - onShowReplyDialog = { event -> - replyToNote = event - showComposeDialog = true - }, - onDismissComposeDialog = { - showComposeDialog = false - replyToNote = null - }, - onDismissAddColumnDialog = { showAddColumnDialog = false }, - onShowAddColumnDialog = { showAddColumnDialog = true }, - replyToNote = replyToNote, - ) + val immersiveFullscreenState = remember { mutableStateOf(false) } + CompositionLocalProvider( + LocalWindowState provides windowState, + LocalAwtWindow provides window, + LocalIsImmersiveFullscreen provides immersiveFullscreenState, + ) { + App( + layoutMode = layoutMode, + deckState = deckState, + accountManager = accountManager, + showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, + onShowComposeDialog = { showComposeDialog = true }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + onDismissComposeDialog = { + showComposeDialog = false + replyToNote = null + }, + onDismissAddColumnDialog = { showAddColumnDialog = false }, + onShowAddColumnDialog = { showAddColumnDialog = true }, + replyToNote = replyToNote, + ) + } } } +} @Composable fun App( @@ -622,22 +649,31 @@ fun MainContent( ) } + is com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -> { + val innerNote = localCache.getOrCreateNote(innerEvent.id) + val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (innerNote.event == null) { + innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + } + iAccount.chatroomList.addMessage( + innerEvent.chatroomKey(iAccount.pubKey), + innerNote, + ) + } + is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> { val reactionNote = localCache.getOrCreateNote(innerEvent.id) val reactionAuthor = localCache.getOrCreateUser(innerEvent.pubKey) if (reactionNote.event == null) { reactionNote.loadEvent(innerEvent, reactionAuthor, emptyList()) } - // Attach reaction to the target message note innerEvent.originalPost().forEach { targetId -> val targetNote = localCache.getNoteIfExists(targetId) targetNote?.addReaction(reactionNote) } } - else -> { - println("Unhandled NIP-17 inner event: ${innerEvent.kind}") - } + else -> {} } } } @@ -665,60 +701,76 @@ fun MainContent( } } + val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current + Box(Modifier.fillMaxSize()) { - Row(Modifier.fillMaxSize()) { - when (layoutMode) { - LayoutMode.SINGLE_PANE -> { - SinglePaneLayout( - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - modifier = Modifier.weight(1f), - ) + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxSize().weight(1f)) { + when (layoutMode) { + LayoutMode.SINGLE_PANE -> { + SinglePaneLayout( + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.weight(1f), + ) + } + + LayoutMode.DECK -> { + if (!isImmersive) { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + ) + + VerticalDivider() + } + + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } } + } // end Row - LayoutMode.DECK -> { - DeckSidebar( - onAddColumn = onShowAddColumnDialog, - onOpenSettings = { - if (deckState.hasColumnOfType(DeckColumnType.Settings)) { - deckState.focusExistingColumn(DeckColumnType.Settings) - } else { - deckState.addColumn(DeckColumnType.Settings) - } - }, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - ) + // Persistent media control bar + com.vitorpamplona.amethyst.desktop.ui.media + .NowPlayingBar() + } // end Column - VerticalDivider() - - DeckLayout( - deckState = deckState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - modifier = Modifier.weight(1f), - ) - } - } - } + // Global fullscreen video overlay + com.vitorpamplona.amethyst.desktop.ui.media + .GlobalFullscreenOverlay() // Snackbar for zap feedback SnackbarHost( @@ -781,7 +833,9 @@ fun RelaySettingsScreen( accountManager.loadNwcConnection() } - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { Text( "Settings", style = MaterialTheme.typography.headlineMedium, @@ -872,6 +926,15 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) + // Media Server Settings + MediaServerSettings( + initialServers = DesktopPreferences.blossomServers, + onServersChanged = { DesktopPreferences.blossomServers = it }, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + // Developer Settings Section (only in debug mode) if (DebugConfig.isDebugMode) { com.vitorpamplona.amethyst.desktop.ui diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt new file mode 100644 index 0000000000..9c3e3b01ad --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +import com.vitorpamplona.amethyst.commons.search.QueryParser +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.commons.search.SavedSearch +import com.vitorpamplona.amethyst.commons.search.SearchQuery +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +object SearchHistoryStore { + private val prefs: Preferences = Preferences.userNodeForPackage(SearchHistoryStore::class.java) + + private const val KEY_HISTORY = "search_history" + private const val KEY_SAVED = "saved_searches" + private const val SEPARATOR = "\n" + private const val SAVED_SEPARATOR = "\t" + private const val MAX_HISTORY = 20 + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + + private val _savedSearches = MutableStateFlow>(emptyList()) + val savedSearches: StateFlow> = _savedSearches.asStateFlow() + + init { + _history.value = loadHistory() + _savedSearches.value = loadSaved() + } + + fun addToHistory(query: SearchQuery) { + if (query.isEmpty) return + val serialized = QuerySerializer.serialize(query) + val current = _history.value.toMutableList() + current.removeAll { QuerySerializer.serialize(it) == serialized } + current.add(0, query) + if (current.size > MAX_HISTORY) { + current.subList(MAX_HISTORY, current.size).clear() + } + _history.value = current.toList() + persistHistory(current) + } + + fun clearHistory() { + _history.value = emptyList() + prefs.remove(KEY_HISTORY) + } + + fun saveSearch( + query: SearchQuery, + label: String, + ) { + if (query.isEmpty) return + val saved = + SavedSearch( + id = System.currentTimeMillis().toString(), + label = label, + query = query, + createdAt = System.currentTimeMillis() / 1000, + ) + val current = _savedSearches.value + saved + _savedSearches.value = current + persistSaved(current) + } + + fun deleteSavedSearch(id: String) { + val current = _savedSearches.value.filter { it.id != id } + _savedSearches.value = current + persistSaved(current) + } + + private fun loadHistory(): List { + val raw = prefs.get(KEY_HISTORY, "") + if (raw.isBlank()) return emptyList() + return raw + .split(SEPARATOR) + .filter { it.isNotBlank() } + .mapNotNull { line -> + val parsed = QueryParser.parse(line) + if (parsed.isEmpty) null else parsed + } + } + + private fun persistHistory(queries: List) { + val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) } + prefs.put(KEY_HISTORY, raw) + } + + private fun loadSaved(): List { + val raw = prefs.get(KEY_SAVED, "") + if (raw.isBlank()) return emptyList() + return raw + .split(SEPARATOR) + .filter { it.isNotBlank() } + .mapNotNull { line -> + val parts = line.split(SAVED_SEPARATOR) + if (parts.size < 4) return@mapNotNull null + val id = parts[0] + val label = parts[1] + val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null + val queryText = parts[3] + val query = QueryParser.parse(queryText) + if (query.isEmpty) return@mapNotNull null + SavedSearch(id = id, label = label, query = query, createdAt = createdAt) + } + } + + private fun persistSaved(searches: List) { + val raw = + searches.joinToString(SEPARATOR) { s -> + listOf(s.id, s.label, s.createdAt.toString(), QuerySerializer.serialize(s.query)) + .joinToString(SAVED_SEPARATOR) + } + prefs.put(KEY_SAVED, raw) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index c51526c62b..b41bda8de6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -33,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index daf10b553a..2668756b86 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add @@ -183,7 +184,7 @@ fun ChessScreen( Row(verticalAlignment = Alignment.CenterVertically) { if (selectedGameId != null) { IconButton(onClick = { viewModel.selectGame(null) }) { - Icon(Icons.Default.ArrowBack, "Back to list") + Icon(Icons.AutoMirrored.Default.ArrowBack, "Back to list") } Spacer(Modifier.width(8.dp)) } @@ -344,8 +345,11 @@ private fun ChessLobby( listState: LazyListState = rememberLazyListState(), ) { val hasContent = - activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || - publicGames.isNotEmpty() || challenges.isNotEmpty() || completedGames.isNotEmpty() + activeGames.isNotEmpty() || + spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || + challenges.isNotEmpty() || + completedGames.isNotEmpty() if (!hasContent) { // Empty state diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e0f5086d1b..5e3108b2ef 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -33,11 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -86,9 +87,9 @@ class DesktopIAccount( override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = object : IPrivateZapsDecryptionCache { - override fun cachedPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + override fun cachedPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null - override suspend fun decryptPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + override suspend fun decryptPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null } override fun userProfile(): User = localCache.getOrCreateUser(pubKey) @@ -108,7 +109,7 @@ class DesktopIAccount( override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { if (!isWriteable()) return - val signedEvent = signer.sign(eventTemplate) + val signedEvent = signer.sign(eventTemplate) val recipient = signedEvent.verifiedRecipientPubKey() // Optimistic local add so the message appears immediately @@ -156,6 +157,37 @@ class DesktopIAccount( scope.launch { dmSendTracker.sendBatch(batch) } } + override suspend fun sendNip17EncryptedFile(template: EventTemplate) { + if (!isWriteable()) return + + val result = NIP17Factory().createEncryptedFileNIP17(template, signer) + + // Optimistic local add + val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent + addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey)) + + // Collect wraps with target relays and send + val batch = + result.wraps.map { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = + if (recipientKey != null) { + val dmRelays = + localCache + .getOrCreateUser(recipientKey) + .dmInboxRelays() + ?.toSet() + dmRelays?.ifEmpty { null } + ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + wrap to targetRelays + } + + scope.launch { dmSendTracker.sendBatch(batch) } + } + override suspend fun sendGiftWraps(wraps: List) { val batch = wraps.map { wrap -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt index 5266f9621a..e31000e7e4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt @@ -45,6 +45,7 @@ object DefaultRelays { "wss://nos.lol", "wss://relay.snort.social", "wss://nostr.wine", + "wss://relay.noswhere.com", "wss://relay.primal.net", ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt index fc9119db86..d535cd7ae2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt @@ -27,12 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt new file mode 100644 index 0000000000..6e534d0683 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.Uri +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage +import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage +import com.vitorpamplona.amethyst.commons.richtext.Base64Image +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import java.awt.image.BufferedImage + +@Stable +class DesktopBase64Fetcher( + private val data: Uri, +) : Fetcher { + override suspend fun fetch(): FetchResult? = + runCatching { + val platformImage = Base64Image.toPlatformImage(data.toString()) + val bufferedImage = platformImage.toBufferedImage() + val bitmap = bufferedImageToSkiaBitmap(bufferedImage) + ImageFetchResult( + image = bitmap.asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + }.getOrNull() + + object Factory : Fetcher.Factory { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher? = + if (data.scheme == "data") { + DesktopBase64Fetcher(data) + } else { + null + } + } + + object BKeyer : Keyer { + override fun key( + data: Uri, + options: Options, + ): String? = + if (data.scheme == "data") { + sha256(data.toString().toByteArray()).toHexKey() + } else { + null + } + } +} + +internal fun bufferedImageToSkiaBitmap(bi: BufferedImage): Bitmap { + val w = bi.width + val h = bi.height + val pixels = IntArray(w * h) + bi.getRGB(0, 0, w, h, pixels, 0, w) + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL)) + bitmap.installPixels(convertArgbToBgra(pixels)) + bitmap.setImmutable() + return bitmap +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt new file mode 100644 index 0000000000..1e47288996 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder +import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage + +data class BlurhashWrapper( + val blurhash: String, +) + +@Stable +class DesktopBlurHashFetcher( + private val data: BlurhashWrapper, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val hash = data.blurhash + val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null + val bufferedImage = platformImage.toBufferedImage() + val bitmap = bufferedImageToSkiaBitmap(bufferedImage) + + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: BlurhashWrapper, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = DesktopBlurHashFetcher(data) + } + + object BKeyer : Keyer { + override fun key( + data: BlurhashWrapper, + options: Options, + ): String = data.blurhash + } +} + +internal fun convertArgbToBgra(pixels: IntArray): ByteArray { + val bytes = ByteArray(pixels.size * 4) + for (i in pixels.indices) { + val argb = pixels[i] + val a = (argb shr 24) and 0xFF + val r = (argb shr 16) and 0xFF + val g = (argb shr 8) and 0xFF + val b = argb and 0xFF + val offset = i * 4 + bytes[offset] = b.toByte() + bytes[offset + 1] = g.toByte() + bytes[offset + 2] = r.toByte() + bytes[offset + 3] = a.toByte() + } + return bytes +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt new file mode 100644 index 0000000000..ebaad3df6e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.size.Precision +import coil3.svg.SvgDecoder +import okio.Path.Companion.toOkioPath +import java.io.File + +object DesktopImageLoaderSetup { + @OptIn(DelicateCoilApi::class) + fun setup() { + SingletonImageLoader.setUnsafe(createImageLoader()) + } + + fun createImageLoader(): ImageLoader = + ImageLoader + .Builder(PlatformContext.INSTANCE) + .memoryCache { newMemoryCache() } + .diskCache { newDiskCache() } + .precision(Precision.INEXACT) + .components { + add(SvgDecoder.Factory()) + add(SkiaGifDecoder.Factory()) + add(DesktopBase64Fetcher.Factory) + add(DesktopBlurHashFetcher.Factory) + add(DesktopBase64Fetcher.BKeyer) + add(DesktopBlurHashFetcher.BKeyer) + }.build() + + private fun newMemoryCache(): MemoryCache { + val maxMemory = Runtime.getRuntime().maxMemory() + val cacheSize = (maxMemory * 0.15).toLong().coerceAtMost(256L * 1024 * 1024) + return MemoryCache + .Builder() + .maxSizeBytes(cacheSize) + .strongReferencesEnabled(false) + .build() + } + + private fun newDiskCache(): DiskCache = + DiskCache + .Builder() + .directory(cacheDir().resolve("AmethystDesktop/image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) + .build() + + private fun cacheDir(): File { + val os = System.getProperty("os.name").lowercase() + return when { + "mac" in os -> { + File(System.getProperty("user.home"), "Library/Caches") + } + + "win" in os -> { + File( + System.getenv("LOCALAPPDATA") + ?: System.getProperty("user.home"), + ) + } + + else -> { + File( + System.getenv("XDG_CACHE_HOME") + ?: "${System.getProperty("user.home")}/.cache", + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt new file mode 100644 index 0000000000..185b0731a4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DecodeResult +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Codec +import org.jetbrains.skia.Data + +class SkiaGifDecoder( + private val source: ImageSource, +) : Decoder { + override suspend fun decode(): DecodeResult { + val bytes = source.source().use { it.readByteArray() } + val data = Data.makeFromBytes(bytes) + val codec = Codec.makeFromData(data) + val bitmap = Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, 0) + bitmap.setImmutable() + return DecodeResult( + image = bitmap.asImage(), + isSampled = false, + ) + } + + class Factory : Decoder.Factory { + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader, + ): Decoder? { + val mimeType = result.mimeType ?: return null + if (mimeType != "image/gif") return null + return SkiaGifDecoder(result.source) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt new file mode 100644 index 0000000000..63765f0dd4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy +import java.io.File + +/** + * Discovers bundled VLC libraries on Windows and Linux. + * Reads the Compose application resources directory and looks for a vlc/ subdirectory. + */ +class BundledVlcDiscoverer : NativeDiscoveryStrategy { + override fun supported(): Boolean { + val os = System.getProperty("os.name").lowercase() + return "mac" !in os + } + + override fun discover(): String { + val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return "" + val vlcDir = File(resourcesDir, "vlc") + return if (vlcDir.isDirectory) vlcDir.absolutePath else "" + } + + override fun onFound(path: String): Boolean = true + + override fun onSetPluginPath(path: String): Boolean = true +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt new file mode 100644 index 0000000000..4365bd881d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles decryption of media files for NIP-17 DMs. + * Uses AESGCM cipher from quartz (commonMain). + * Caches decrypted bytes in memory to avoid re-downloading on recomposition. + */ +object EncryptedMediaService { + private val httpClient = OkHttpClient() + private val cache = ConcurrentHashMap() + private const val MAX_CACHE_ENTRIES = 20 + + /** + * Download and decrypt an encrypted file from a URL. + * Results are cached by URL to avoid re-downloading on scroll/recomposition. + * Returns the decrypted bytes. + */ + suspend fun downloadAndDecrypt( + url: String, + keyBytes: ByteArray, + nonce: ByteArray, + ): ByteArray { + cache[url]?.let { return it } + + return withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + val encryptedBytes = + response.use { + if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}") + it.body.bytes() + } + + val cipher = AESGCM(keyBytes, nonce) + val decrypted = cipher.decrypt(encryptedBytes) + + // Evict oldest entries if cache is full + if (cache.size >= MAX_CACHE_ENTRIES) { + cache.keys.firstOrNull()?.let { cache.remove(it) } + } + cache[url] = decrypted + + decrypted + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt new file mode 100644 index 0000000000..dc59b159cd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -0,0 +1,497 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import com.vitorpamplona.amethyst.desktop.ui.media.MediaType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import org.jetbrains.skia.Image as SkiaImage + +data class MediaPlaybackState( + val url: String? = null, + val type: MediaType = MediaType.VIDEO, + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val position: Float = 0f, + val duration: Long = 0L, + val currentTime: Long = 0L, + val aspectRatio: Float = 16f / 9f, + val volume: Int = 100, + val isMuted: Boolean = false, +) + +object GlobalMediaPlayer { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Video state + private val _videoFrame = MutableStateFlow(null) + val videoFrame: StateFlow = _videoFrame.asStateFlow() + + private val _videoState = MutableStateFlow(MediaPlaybackState()) + val videoState: StateFlow = _videoState.asStateFlow() + + // Audio state + private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO)) + val audioState: StateFlow = _audioState.asStateFlow() + + // Fullscreen + private val _isFullscreen = MutableStateFlow(false) + val isFullscreen: StateFlow = _isFullscreen.asStateFlow() + + // VLC players — kept alive between plays + private var videoPlayer: EmbeddedMediaPlayer? = null + private var audioPlayer: MediaPlayer? = null + + // Skia bitmap for video rendering + private var skBitmap: Bitmap? = null + private var pixelBytes: ByteArray? = null + + // Position polling job + private var videoPollingJob: Job? = null + private var audioPollingJob: Job? = null + + fun playVideo( + url: String, + seekPosition: Float = 0f, + ) { + // If already playing this URL, just seek + val current = _videoState.value + if (current.url == url && videoPlayer != null) { + if (seekPosition > 0f) { + videoPlayer?.controls()?.setPosition(seekPosition) + } + if (!current.isPlaying) { + videoPlayer?.controls()?.play() + } + return + } + + // Stop current video if different URL + if (current.url != null && current.url != url) { + videoPlayer?.controls()?.stop() + } + + _videoState.value = + MediaPlaybackState( + url = url, + type = MediaType.VIDEO, + isBuffering = true, + ) + + scope.launch(Dispatchers.IO) { + if (!VlcjPlayerPool.init()) { + _videoState.value = _videoState.value.copy(isBuffering = false) + return@launch + } + + val player = + videoPlayer ?: VlcjPlayerPool.acquire() ?: run { + _videoState.value = _videoState.value.copy(isBuffering = false) + return@launch + } + + // Only set up surface on first acquisition + if (videoPlayer == null) { + setupVideoSurface(player) + setupVideoEventListener(player) + videoPlayer = player + } + + var didSeek = seekPosition <= 0f + + // Temporary listener for initial seek + if (!didSeek) { + val seekListener = + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + if (!didSeek) { + didSeek = true + mediaPlayer.controls().setPosition(seekPosition) + mediaPlayer.events().removeMediaPlayerEventListener(this) + } + } + } + player.events().addMediaPlayerEventListener(seekListener) + } + + val vol = _videoState.value.volume + player.media().play(url, ":start-volume=$vol") + startVideoPolling() + } + } + + fun playAudio(url: String) { + val current = _audioState.value + if (current.url == url && audioPlayer != null) { + if (!current.isPlaying) { + audioPlayer?.controls()?.play() + } + return + } + + if (current.url != null && current.url != url) { + audioPlayer?.controls()?.stop() + } + + _audioState.value = + MediaPlaybackState( + url = url, + type = MediaType.AUDIO, + isBuffering = true, + ) + + scope.launch(Dispatchers.IO) { + val player = + audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run { + _audioState.value = _audioState.value.copy(isBuffering = false) + return@launch + } + + if (audioPlayer == null) { + setupAudioEventListener(player) + audioPlayer = player + } + + val vol = _audioState.value.volume + player.media().play(url, ":start-volume=$vol") + startAudioPolling() + } + } + + fun toggleVideoPlayPause() { + val player = videoPlayer ?: return + val state = _videoState.value + if (state.url == null) return + + if (state.isPlaying) { + player.controls().pause() + } else { + if (state.position <= 0f && !player.status().isPlaying) { + state.url.let { player.media().play(it) } + } else { + player.controls().play() + } + } + } + + fun toggleAudioPlayPause() { + val player = audioPlayer ?: return + val state = _audioState.value + if (state.url == null) return + + if (state.isPlaying) { + player.controls().pause() + } else { + if (state.position <= 0f && !player.status().isPlaying) { + state.url.let { player.media().play(it) } + } else { + player.controls().play() + } + } + } + + fun seekVideo(position: Float) { + videoPlayer?.controls()?.setPosition(position) + } + + fun seekAudio(position: Float) { + audioPlayer?.controls()?.setPosition(position) + } + + fun setVideoVolume(volume: Int) { + videoPlayer?.audio()?.setVolume(volume) + _videoState.value = _videoState.value.copy(volume = volume) + } + + fun setAudioVolume(volume: Int) { + audioPlayer?.audio()?.setVolume(volume) + _audioState.value = _audioState.value.copy(volume = volume) + } + + fun toggleVideoMute() { + val muted = !_videoState.value.isMuted + videoPlayer?.audio()?.isMute = muted + _videoState.value = _videoState.value.copy(isMuted = muted) + } + + fun toggleAudioMute() { + val muted = !_audioState.value.isMuted + audioPlayer?.audio()?.isMute = muted + _audioState.value = _audioState.value.copy(isMuted = muted) + } + + fun stopVideo() { + videoPollingJob?.cancel() + videoPollingJob = null + videoPlayer?.controls()?.stop() + _videoState.value = MediaPlaybackState() + _videoFrame.value = null + _isFullscreen.value = false + } + + fun stopAudio() { + audioPollingJob?.cancel() + audioPollingJob = null + audioPlayer?.controls()?.stop() + _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) + } + + fun toggleFullscreen() { + _isFullscreen.value = !_isFullscreen.value + } + + fun exitFullscreen() { + _isFullscreen.value = false + } + + fun shutdown() { + videoPollingJob?.cancel() + audioPollingJob?.cancel() + + videoPlayer?.let { p -> + try { + p.controls().stop() + } catch (_: Exception) { + } + VlcjPlayerPool.release(p) + } + videoPlayer = null + + audioPlayer?.let { p -> + try { + p.controls().stop() + } catch (_: Exception) { + } + VlcjPlayerPool.releaseAudioPlayer(p) + } + audioPlayer = null + + _videoState.value = MediaPlaybackState() + _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) + _videoFrame.value = null + _isFullscreen.value = false + + scope.cancel() + } + + private fun setupVideoSurface(player: EmbeddedMediaPlayer) { + val bufferFormatCallback = + object : BufferFormatCallback { + override fun getBufferFormat( + sourceWidth: Int, + sourceHeight: Int, + ): BufferFormat { + if (sourceHeight > 0) { + _videoState.value = + _videoState.value.copy( + aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(), + ) + } + val bmp = Bitmap() + bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL)) + skBitmap = bmp + pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) {} + } + + val renderCallback = + RenderCallback { _, nativeBuffers, _ -> + val bmp = skBitmap ?: return@RenderCallback + val bytes = pixelBytes ?: return@RenderCallback + val buffer = nativeBuffers[0] + buffer.rewind() + buffer.get(bytes) + bmp.installPixels(bytes) + _videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() + } + + val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) + player.videoSurface().set(surface) + } + + private fun setupVideoEventListener(player: EmbeddedMediaPlayer) { + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + val state = _videoState.value + _videoState.value = + state.copy( + isPlaying = true, + isBuffering = false, + duration = mediaPlayer.status().length(), + ) + } + + override fun paused(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isPlaying = false) + } + + override fun stopped(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false) + } + + override fun buffering( + mediaPlayer: MediaPlayer, + newCache: Float, + ) { + _videoState.value = _videoState.value.copy(isBuffering = newCache < 100f) + } + + override fun positionChanged( + mediaPlayer: MediaPlayer, + newPosition: Float, + ) { + _videoState.value = + _videoState.value.copy( + position = newPosition, + currentTime = (newPosition * _videoState.value.duration).toLong(), + ) + } + + override fun finished(mediaPlayer: MediaPlayer) { + _videoState.value = + _videoState.value.copy( + isPlaying = false, + isBuffering = false, + position = 0f, + currentTime = 0L, + ) + } + + override fun error(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isBuffering = false) + println("VLC: playback error for ${_videoState.value.url}") + } + }, + ) + } + + private fun setupAudioEventListener(player: MediaPlayer) { + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + _audioState.value = + _audioState.value.copy( + isPlaying = true, + isBuffering = false, + duration = mediaPlayer.status().length(), + ) + } + + override fun paused(mediaPlayer: MediaPlayer) { + _audioState.value = _audioState.value.copy(isPlaying = false) + } + + override fun stopped(mediaPlayer: MediaPlayer) { + _audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false) + } + + override fun positionChanged( + mediaPlayer: MediaPlayer, + newPosition: Float, + ) { + _audioState.value = + _audioState.value.copy( + position = newPosition, + currentTime = (newPosition * _audioState.value.duration).toLong(), + ) + } + + override fun finished(mediaPlayer: MediaPlayer) { + _audioState.value = + _audioState.value.copy( + isPlaying = false, + position = 0f, + currentTime = 0L, + ) + } + }, + ) + } + + private fun startVideoPolling() { + videoPollingJob?.cancel() + videoPollingJob = + scope.launch { + while (true) { + delay(500) + val player = videoPlayer ?: break + val state = _videoState.value + if (state.isPlaying) { + try { + _videoState.value = + state.copy( + position = player.status().position(), + currentTime = player.status().time(), + ) + } catch (_: Exception) { + } + } + } + } + } + + private fun startAudioPolling() { + audioPollingJob?.cancel() + audioPollingJob = + scope.launch { + while (true) { + delay(500) + val player = audioPlayer ?: break + val state = _audioState.value + if (state.isPlaying) { + try { + _audioState.value = + state.copy( + position = player.status().position(), + currentTime = player.status().time(), + ) + } catch (_: Exception) { + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt new file mode 100644 index 0000000000..8e92dc92ee --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import com.sun.jna.NativeLibrary +import uk.co.caprica.vlcj.binding.lib.LibC +import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil +import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy +import java.io.File + +/** + * Discovers bundled VLC libraries on macOS. + * Must force-load libvlccore before libvlc to avoid link errors. + */ +class MacOsVlcDiscoverer : + BaseNativeDiscoveryStrategy( + arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"), + arrayOf("%s/plugins"), + ) { + override fun supported(): Boolean { + val os = System.getProperty("os.name").lowercase() + return "mac" in os + } + + override fun discoveryDirectories(): List { + val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return emptyList() + val vlcDir = File(resourcesDir, "vlc") + return if (vlcDir.isDirectory) listOf(vlcDir.absolutePath) else emptyList() + } + + override fun onFound(path: String): Boolean { + NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path) + NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName()) + return true + } + + override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt new file mode 100644 index 0000000000..bfa1b32954 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt @@ -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.desktop.service.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +object ServerHealthCheck { + private val httpClient = + OkHttpClient + .Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + + enum class ServerStatus { + ONLINE, + OFFLINE, + UNKNOWN, + } + + /** + * Check if a Blossom server is reachable via HEAD request. + */ + suspend fun check(serverUrl: String): ServerStatus = + withContext(Dispatchers.IO) { + try { + val url = serverUrl.removeSuffix("/") + val request = + Request + .Builder() + .url(url) + .head() + .build() + val response = httpClient.newCall(request).execute() + response.use { + if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE + } + } catch (_: Exception) { + ServerStatus.OFFLINE + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt new file mode 100644 index 0000000000..6ecaabe158 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.jetbrains.skia.Image as SkiaImage + +object VideoThumbnailCache { + private val cache = ConcurrentHashMap() + private val pending = ConcurrentHashMap() + + fun getCached(url: String): ImageBitmap? = cache[url] + + suspend fun getThumbnail(url: String): ImageBitmap? { + cache[url]?.let { return it } + if (pending.putIfAbsent(url, true) != null) return null + + return withContext(Dispatchers.IO) { + try { + extractFirstFrame(url)?.also { cache[url] = it } + } finally { + pending.remove(url) + } + } + } + + private fun extractFirstFrame(url: String): ImageBitmap? { + if (!VlcjPlayerPool.init()) { + println("VLC thumbnail: init failed for $url") + return null + } + val player = VlcjPlayerPool.acquireForThumbnail() + if (player == null) { + println("VLC thumbnail: pool exhausted for $url") + return null + } + + var result: ImageBitmap? = null + val latch = CountDownLatch(1) + + val bufferFormatCallback = + object : BufferFormatCallback { + override fun getBufferFormat( + sourceWidth: Int, + sourceHeight: Int, + ): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight) + + override fun allocatedBuffers(buffers: Array) {} + } + + val renderCallback = + RenderCallback { _, nativeBuffers, bufferFormat -> + if (result != null) return@RenderCallback + try { + if (nativeBuffers.isEmpty()) return@RenderCallback + val w = bufferFormat.width + val h = bufferFormat.height + if (w <= 0 || h <= 0) return@RenderCallback + val bmp = Bitmap() + bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL)) + val bytes = ByteArray(w * h * 4) + val buffer = nativeBuffers[0] + buffer.rewind() + buffer.get(bytes) + bmp.installPixels(bytes) + result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() + latch.countDown() + } catch (e: Exception) { + println("VLC thumbnail: render error for $url — ${e.message}") + latch.countDown() + } + } + + val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) + if (surface == null) { + println("VLC thumbnail: surface creation failed for $url") + VlcjPlayerPool.release(player) + return null + } + + player.videoSurface().set(surface) + player.audio().setVolume(0) + player.audio().isMute = true + + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun error(mediaPlayer: MediaPlayer) { + println("VLC thumbnail: playback error for $url") + latch.countDown() + } + }, + ) + + player.media().play(url) + + // Wait up to 8 seconds for first frame (network videos can be slow) + latch.await(8, TimeUnit.SECONDS) + + if (result == null) { + println("VLC thumbnail: timed out or failed for $url") + } + + VlcjPlayerPool.release(player) + return result + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt new file mode 100644 index 0000000000..18e08c306d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import uk.co.caprica.vlcj.factory.MediaPlayerFactory +import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Manages a pool of VLCJ media players to avoid costly create/destroy cycles. + * Keeps strong references to prevent GC crashes from native callbacks. + * + * IMPORTANT: Never let player instances be garbage collected while native + * callbacks are active — this causes JVM segfaults. + */ +object VlcjPlayerPool { + private val available = AtomicBoolean(false) + private val initAttempted = AtomicBoolean(false) + private val initLatch = CountDownLatch(1) + private var factory: MediaPlayerFactory? = null + + // Video player pool (for actual playback) + private val allPlayers = mutableListOf() + private val idlePlayers = ConcurrentLinkedQueue() + private const val MAX_POOL_SIZE = 1 + + // Thumbnail player pool (separate so thumbnails don't compete with playback) + private val allThumbPlayers = mutableListOf() + private val idleThumbPlayers = ConcurrentLinkedQueue() + private const val MAX_THUMB_POOL_SIZE = 2 + + // Audio player pool (shared factory with --no-video) + private var audioFactory: MediaPlayerFactory? = null + private val allAudioPlayers = mutableListOf() + private val idleAudioPlayers = ConcurrentLinkedQueue() + private const val MAX_AUDIO_POOL_SIZE = 1 + + /** + * Initialize the pool. Thread-safe — only runs once. + * Returns false if VLC is not installed. + */ + fun init(): Boolean { + if (available.get()) return true + + // Only one thread performs init; others wait + if (!initAttempted.compareAndSet(false, true)) { + initLatch.await(10, TimeUnit.SECONDS) + return available.get() + } + + return try { + // Try bundled VLC first, then fall through to system VLC + val discovery = + try { + val nd = + NativeDiscovery( + BundledVlcDiscoverer(), + MacOsVlcDiscoverer(), + ) + val found = nd.discover() + if (found) { + println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}") + } else { + println("VLC: bundled discovery failed, falling back to system VLC") + } + found + } catch (e: Throwable) { + println("VLC: bundled discovery threw ${e.message}") + false + } + if (!discovery) { + // Try default system discovery + val systemDiscovery = NativeDiscovery().discover() + println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}") + } + val f = MediaPlayerFactory("--no-xlib") + factory = f + available.set(true) + println("VLC: MediaPlayerFactory created successfully") + true + } catch (e: Throwable) { + println("VLC: init failed — ${e.message}") + available.set(false) + false + } finally { + initLatch.countDown() + } + } + + fun isAvailable(): Boolean = available.get() + + /** + * Create a callback video surface using the factory's API. + */ + fun createVideoSurface( + bufferFormatCallback: BufferFormatCallback, + renderCallback: RenderCallback, + ): VideoSurface? { + val f = factory ?: return null + return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true) + } + + /** + * Acquire a video player from the pool or create a new one. + * Returns null if VLC is not available or pool is at capacity. + */ + fun acquire(): EmbeddedMediaPlayer? { + if (!available.get()) return null + val f = factory ?: return null + + synchronized(allPlayers) { + idlePlayers.poll()?.let { return it } + if (allPlayers.size >= MAX_POOL_SIZE) return null + return try { + val player = f.mediaPlayers().newEmbeddedMediaPlayer() + allPlayers.add(player) + player + } catch (_: Exception) { + null + } + } + } + + /** + * Acquire a player dedicated to thumbnail extraction. + * Separate pool so thumbnails don't compete with playback. + */ + fun acquireForThumbnail(): EmbeddedMediaPlayer? { + if (!available.get()) return null + val f = factory ?: return null + + synchronized(allThumbPlayers) { + idleThumbPlayers.poll()?.let { return it } + if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) { + // Fall back to main pool if thumb pool is full + return acquire() + } + return try { + val player = f.mediaPlayers().newEmbeddedMediaPlayer() + allThumbPlayers.add(player) + player + } catch (_: Exception) { + null + } + } + } + + /** + * Acquire an audio-only player from the pool. + * Uses a separate factory with --no-video for efficiency. + */ + fun acquireAudioPlayer(): MediaPlayer? { + if (!init()) return null + + synchronized(allAudioPlayers) { + idleAudioPlayers.poll()?.let { return it } + if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null + + val af = + audioFactory ?: try { + MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it } + } catch (_: Throwable) { + return null + } + + return try { + val player = af.mediaPlayers().newMediaPlayer() + allAudioPlayers.add(player) + player + } catch (_: Exception) { + null + } + } + } + + /** + * Return a video player to the pool for reuse. + */ + fun release(player: EmbeddedMediaPlayer) { + try { + player.controls().stop() + // Return to correct pool + synchronized(allThumbPlayers) { + if (player in allThumbPlayers) { + idleThumbPlayers.offer(player) + return + } + } + idlePlayers.offer(player) + } catch (_: Exception) { + // Player may already be disposed + } + } + + /** + * Return an audio player to the pool for reuse. + */ + fun releaseAudioPlayer(player: MediaPlayer) { + try { + player.controls().stop() + idleAudioPlayers.offer(player) + } catch (_: Exception) { + // Player may already be disposed + } + } + + /** + * Shut down the entire pool. Call on app exit. + */ + fun shutdown() { + synchronized(allPlayers) { + idlePlayers.clear() + for (player in allPlayers) { + try { + player.controls().stop() + player.release() + } catch (_: Exception) { + // Ignore + } + } + allPlayers.clear() + } + synchronized(allThumbPlayers) { + idleThumbPlayers.clear() + for (player in allThumbPlayers) { + try { + player.controls().stop() + player.release() + } catch (_: Exception) { + // Ignore + } + } + allThumbPlayers.clear() + } + synchronized(allAudioPlayers) { + idleAudioPlayers.clear() + for (player in allAudioPlayers) { + try { + player.controls().stop() + player.release() + } catch (_: Exception) { + // Ignore + } + } + allAudioPlayers.clear() + } + try { + factory?.release() + } catch (_: Exception) { + // Ignore + } + try { + audioFactory?.release() + } catch (_: Exception) { + // Ignore + } + factory = null + audioFactory = null + available.set(false) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt new file mode 100644 index 0000000000..b932144972 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import java.util.Base64 + +object DesktopBlossomAuth { + suspend fun createUploadAuth( + hash: HexKey, + size: Long, + alt: String, + signer: NostrSigner, + ): String { + val event = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) + return encodeAuthHeader(event) + } + + fun encodeAuthHeader(event: BlossomAuthorizationEvent): String { + val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) + return "Nostr $b64" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt new file mode 100644 index 0000000000..43842b38af --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSink +import okio.source +import java.io.File + +class DesktopBlossomClient( + private val okHttpClient: OkHttpClient = OkHttpClient(), +) { + suspend fun upload( + file: File, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = + withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBody = + object : RequestBody() { + override fun contentType() = contentType.toMediaType() + + override fun contentLength() = file.length() + + override fun writeTo(sink: BufferedSink) { + file.inputStream().source().use(sink::writeAll) + } + } + + val requestBuilder = + Request + .Builder() + .url(apiUrl) + .put(requestBody) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } + } + + /** + * Upload raw bytes (e.g. encrypted blobs) to a Blossom server. + */ + suspend fun upload( + bytes: ByteArray, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = + withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + + val requestBuilder = + Request + .Builder() + .url(apiUrl) + .put(requestBody) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt new file mode 100644 index 0000000000..c197441d78 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import org.apache.commons.imaging.Imaging +import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter +import java.io.ByteArrayOutputStream +import java.io.File + +object DesktopMediaCompressor { + fun stripExif(file: File): File { + if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) { + return file + } + + return try { + val bytes = file.readBytes() + // Check if it has EXIF data + val metadata = Imaging.getMetadata(bytes) + if (metadata == null) return file + + val baos = ByteArrayOutputStream() + ExifRewriter().removeExifMetadata(bytes, baos) + val stripped = File.createTempFile("stripped_", ".jpg") + stripped.writeBytes(baos.toByteArray()) + stripped.deleteOnExit() + stripped + } catch (_: Exception) { + file + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt new file mode 100644 index 0000000000..d43b3cfd36 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File +import javax.imageio.ImageIO + +data class MediaMetadata( + val sha256: String, + val size: Long, + val mimeType: String, + val width: Int? = null, + val height: Int? = null, + val blurhash: String? = null, +) + +object DesktopMediaMetadata { + fun compute(file: File): MediaMetadata { + val bytes = file.readBytes() + val hash = sha256(bytes).toHexKey() + val mimeType = guessMimeType(file) + var width: Int? = null + var height: Int? = null + var blurhash: String? = null + + if (mimeType.startsWith("image/")) { + try { + val image = ImageIO.read(file) + if (image != null) { + width = image.width + height = image.height + blurhash = image.toPlatformImage().toBlurhash() + } + } catch (_: Exception) { + } + } + + return MediaMetadata( + sha256 = hash, + size = bytes.size.toLong(), + mimeType = mimeType, + width = width, + height = height, + blurhash = blurhash, + ) + } + + fun guessMimeType(file: File): String { + val ext = file.extension.lowercase() + return when (ext) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "svg" -> "image/svg+xml" + "avif" -> "image/avif" + "mp4" -> "video/mp4" + "webm" -> "video/webm" + "mov" -> "video/quicktime" + "mp3" -> "audio/mpeg" + "ogg" -> "audio/ogg" + "wav" -> "audio/wav" + "flac" -> "audio/flac" + else -> "application/octet-stream" + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt new file mode 100644 index 0000000000..e22f00abc0 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File + +data class UploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, +) + +data class EncryptedUploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, + val encryptedHash: String, + val encryptedSize: Int, +) + +class DesktopUploadOrchestrator( + private val client: DesktopBlossomClient = DesktopBlossomClient(), +) { + suspend fun upload( + file: File, + alt: String?, + serverBaseUrl: String, + signer: NostrSigner, + stripExif: Boolean = true, + ): UploadResult { + // 1. Strip EXIF if requested (JPEG only) + val processedFile = + if (stripExif) { + DesktopMediaCompressor.stripExif(file) + } else { + file + } + + // 2. Compute metadata (hash, dimensions, blurhash) + val metadata = DesktopMediaMetadata.compute(processedFile) + + // 3. Create auth header + val authHeader = + DesktopBlossomAuth.createUploadAuth( + hash = metadata.sha256, + size = metadata.size, + alt = alt ?: "Uploading ${file.name}", + signer = signer, + ) + + // 4. Upload + val result = + client.upload( + file = processedFile, + contentType = metadata.mimeType, + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + // 5. Clean up temp file if we stripped EXIF + if (processedFile != file) { + processedFile.delete() + } + + return UploadResult(blossom = result, metadata = metadata) + } + + /** + * Upload a file encrypted with AES-GCM for NIP-17 DM file sharing. + * Computes pre-encryption metadata (dimensions, blurhash), encrypts bytes, + * then uploads the encrypted blob to Blossom. + */ + suspend fun uploadEncrypted( + file: File, + cipher: AESGCM, + serverBaseUrl: String, + signer: NostrSigner, + ): EncryptedUploadResult { + // 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) + val metadata = DesktopMediaMetadata.compute(file) + + // 2. Read file bytes and encrypt + val plaintext = file.readBytes() + val encrypted = cipher.encrypt(plaintext) + + // 3. Compute SHA256 of ENCRYPTED blob (not plaintext) + val encryptedHash = sha256(encrypted).toHexKey() + val encryptedSize = encrypted.size + + // 4. Create Blossom auth with encrypted hash and size + val authHeader = + DesktopBlossomAuth.createUploadAuth( + hash = encryptedHash, + size = encryptedSize.toLong(), + alt = "Encrypted upload", + signer = signer, + ) + + // 5. Upload encrypted blob as opaque binary + val result = + client.upload( + bytes = encrypted, + contentType = "application/octet-stream", + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + return EncryptedUploadResult( + blossom = result, + metadata = metadata, + encryptedHash = encryptedHash, + encryptedSize = encryptedSize, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt new file mode 100644 index 0000000000..e79244ecf5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class UploadState( + val isUploading: Boolean = false, + val fileName: String? = null, + val error: String? = null, + val result: UploadResult? = null, +) + +class DesktopUploadTracker { + private val _state = MutableStateFlow(UploadState()) + val state = _state.asStateFlow() + + fun startUpload(fileName: String) { + _state.value = UploadState(isUploading = true, fileName = fileName) + } + + fun onSuccess(result: UploadResult) { + _state.value = UploadState(isUploading = false, result = result) + } + + fun onError(error: String) { + _state.value = UploadState(isUploading = false, error = error) + } + + fun reset() { + _state.value = UploadState() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index d4d3bac207..a7d4a633a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -145,6 +145,7 @@ fun createSearchPeopleSubscription( limit: Int = 50, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, + onClosed: (NormalizedRelayUrl, String, List?) -> Unit = { _, _, _ -> }, ): SubscriptionConfig? { if (searchQuery.isBlank()) return null @@ -154,6 +155,7 @@ fun createSearchPeopleSubscription( relays = relays, onEvent = onEvent, onEose = onEose, + onClosed = onClosed, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt new file mode 100644 index 0000000000..c918ea775d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.subscriptions + +import com.vitorpamplona.amethyst.commons.search.SearchQuery +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +object SearchFilterFactory { + // Default kind groups (ported from Android SearchPostsByText) + private val defaultKindGroup1 = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + ZapPollEvent.KIND, + ChannelCreateEvent.KIND, + ) + + private val defaultKindGroup2 = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PublicMessageEvent.KIND, + NNSEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + + private val defaultKindGroup3 = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + FollowListEvent.KIND, + NipTextEvent.KIND, + PollEvent.KIND, + PollResponseEvent.KIND, + ) + + fun createFilters( + query: SearchQuery, + limit: Int = 100, + ): List { + if (query.isEmpty) return emptyList() + + val searchString = buildSearchString(query) + val tags = buildTags(query) + val authors = query.authors.takeIf { it.isNotEmpty() } + + if (query.kinds.isNotEmpty()) { + // User specified kinds — single filter (no group splitting needed) + return listOf( + Filter( + kinds = query.kinds.toList(), + search = searchString, + authors = authors, + tags = tags, + since = query.since, + until = query.until, + limit = limit, + ), + ) + } + + // No kinds specified — use default 3-group search (Android parity) + return listOf(defaultKindGroup1, defaultKindGroup2, defaultKindGroup3).map { kindGroup -> + Filter( + kinds = kindGroup, + search = searchString, + authors = authors, + tags = tags, + since = query.since, + until = query.until, + limit = limit, + ) + } + } + + private fun buildSearchString(query: SearchQuery): String? { + val parts = mutableListOf() + + // Free text (exclude negation terms — those are client-side only) + if (query.text.isNotBlank()) { + parts.add(query.text) + } + + // NIP-50 inline extensions + query.language?.let { parts.add("language:$it") } + query.domain?.let { parts.add("domain:$it") } + + return parts.joinToString(" ").takeIf { it.isNotBlank() } + } + + private fun buildTags(query: SearchQuery): Map>? { + if (query.hashtags.isEmpty()) return null + return mapOf("t" to query.hashtags.toList()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt index 8aecc2f2d0..f7ce8727d5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt @@ -46,6 +46,7 @@ data class SubscriptionConfig( val relays: Set, val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, val onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, + val onClosed: (NormalizedRelayUrl, String, List?) -> Unit = { _, _, _ -> }, ) /** @@ -95,6 +96,14 @@ fun rememberSubscription( ) { cfg.onEose(relay, forFilters) } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + cfg.onClosed(relay, message, forFilters) + } }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index c4f1570c6d..f08682e569 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -289,7 +289,9 @@ fun BookmarksScreen( ) { NoteCard( note = event.toNoteDisplayData(localCache), + localCache = localCache, onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, ) NoteActionsRow( event = event, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index dcf903fa77..0c955558ee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme @@ -35,36 +38,117 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction +import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator +import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker +import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult +import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler +import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker +import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DnDConstants +import java.awt.dnd.DropTargetDropEvent +import java.io.File +private val MEDIA_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") + +private val IMAGE_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif") + +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ComposeNoteDialog( onDismiss: () -> Unit, relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, - replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null, + replyTo: Event? = null, ) { var content by remember { mutableStateOf("") } var isPosting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val attachedFiles = remember { mutableStateListOf() } + val uploadTracker = remember { DesktopUploadTracker() } + val uploadState by uploadTracker.state.collectAsState() + val orchestrator = remember { DesktopUploadOrchestrator() } + var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } + var postAsPicture by remember { mutableStateOf(false) } + + // Drag-and-drop state + var isDragOver by remember { mutableStateOf(false) } + val dropTarget = + remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + isDragOver = false + val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false + dropEvent.acceptDrop(DnDConstants.ACTION_COPY) + val transferable = dropEvent.transferable + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + @Suppress("UNCHECKED_CAST") + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + attachedFiles.addAll(files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }) + dropEvent.dropComplete(true) + return true + } + dropEvent.dropComplete(false) + return false + } + + override fun onStarted(event: DragAndDropEvent) { + isDragOver = true + } + + override fun onEnded(event: DragAndDropEvent) { + isDragOver = false + } + } + } Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) { Card( - modifier = Modifier.width(600.dp).padding(16.dp), + modifier = + Modifier + .width(600.dp) + .padding(16.dp) + .dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget) + .then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + } else { + Modifier + }, + ), ) { Column(modifier = Modifier.padding(24.dp)) { Text( @@ -85,20 +169,67 @@ fun ComposeNoteDialog( Spacer(Modifier.height(16.dp)) OutlinedTextField( - value = content, + value = if (postAsPicture) "" else content, onValueChange = { content = it errorMessage = null }, - modifier = Modifier.fillMaxWidth().height(200.dp), - label = { Text("What's on your mind?") }, - placeholder = { Text("Write your note...") }, - enabled = !isPosting, - maxLines = 10, + modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp), + label = { + Text( + if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?", + ) + }, + placeholder = { Text(if (postAsPicture) "" else "Write your note...") }, + enabled = !isPosting && !postAsPicture, + maxLines = if (postAsPicture) 1 else 10, ) Spacer(Modifier.height(8.dp)) + MediaAttachmentRow( + attachedFiles = attachedFiles, + isUploading = uploadState.isUploading, + onAttach = { + val files = DesktopFilePicker.pickMediaFiles() + attachedFiles.addAll(files) + }, + onPaste = { + val files = ClipboardPasteHandler.getClipboardFiles() + attachedFiles.addAll(files) + }, + onRemove = { attachedFiles.remove(it) }, + ) + + // Server selector + post type — shown when files are attached + if (attachedFiles.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + ServerSelector( + selectedServer = selectedServer, + onServerSelected = { selectedServer = it }, + ) + + // Post type toggle — only when images are attached + val hasImages = + attachedFiles.any { + it.extension.lowercase() in IMAGE_EXTENSIONS + } + if (hasImages) { + PostTypeSelector( + isPicture = postAsPicture, + onToggle = { postAsPicture = it }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + // Character count Text( "${content.length} characters", @@ -115,6 +246,15 @@ fun ComposeNoteDialog( ) } + uploadState.error?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + "Upload error: $error", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Spacer(Modifier.height(16.dp)) Row( @@ -132,7 +272,7 @@ fun ComposeNoteDialog( Button( onClick = { - if (content.isBlank()) { + if (content.isBlank() && attachedFiles.isEmpty()) { errorMessage = "Note cannot be empty" return@Button } @@ -142,21 +282,61 @@ fun ComposeNoteDialog( errorMessage = null try { - publishNote( - content = content, - account = account, - relayManager = relayManager, - replyTo = replyTo, - ) + // Upload attached files and collect results + val uploadResults = mutableListOf() + for (file in attachedFiles) { + uploadTracker.startUpload(file.name) + val result = + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + ) + uploadTracker.onSuccess(result) + uploadResults.add(result) + } + + // Append uploaded URLs to content + val finalContent = + buildString { + append(content) + for (result in uploadResults) { + result.blossom.url?.let { url -> + if (isNotBlank()) append("\n") + append(url) + } + } + } + + if (postAsPicture) { + val pictureMetas = buildPictureMetas(uploadResults) + publishPicture( + description = content, + images = pictureMetas, + account = account, + relayManager = relayManager, + ) + } else { + val imetaTags = buildIMetaTags(uploadResults) + publishNote( + content = finalContent, + account = account, + relayManager = relayManager, + replyTo = replyTo, + imetaTags = imetaTags, + ) + } onDismiss() } catch (e: Exception) { - errorMessage = "Failed to publish: ${e.message}" + errorMessage = "Failed: ${e.message}" + uploadTracker.onError(e.message ?: "Unknown error") } finally { isPosting = false } } }, - enabled = !isPosting && content.isNotBlank(), + enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()), ) { Text(if (isPosting) "Publishing..." else "Publish") } @@ -166,23 +346,176 @@ fun ComposeNoteDialog( } } -/** - * Publishes a text note to relays. - * Uses the Account's key to sign the event. - */ -private suspend fun publishNote( - content: String, +private fun buildIMetaTags(results: List): List = + results.mapNotNull { result -> + val url = result.blossom.url ?: return@mapNotNull null + val meta = result.metadata + val props = mutableMapOf>() + props["m"] = listOf(meta.mimeType) + props["x"] = listOf(meta.sha256) + props["size"] = listOf(meta.size.toString()) + if (meta.width != null && meta.height != null) { + props["dim"] = listOf("${meta.width}x${meta.height}") + } + meta.blurhash?.let { props["blurhash"] = listOf(it) } + IMetaTag(url = url, properties = props) + } + +@Composable +private fun ServerSelector( + selectedServer: String, + onServerSelected: (String) -> Unit, +) { + val servers = DesktopPreferences.blossomServers + if (servers.size <= 1) { + // Only one server — just show label, no dropdown + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + "Upload to: ", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + selectedServer, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + return + } + + var expanded by remember { mutableStateOf(false) } + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + "Upload to: ", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.foundation.layout.Box { + androidx.compose.material3.TextButton(onClick = { expanded = true }) { + Text( + selectedServer, + style = MaterialTheme.typography.labelSmall, + ) + } + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + servers.forEach { server -> + androidx.compose.material3.DropdownMenuItem( + text = { Text(server, style = MaterialTheme.typography.bodySmall) }, + onClick = { + onServerSelected(server) + expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun PostTypeSelector( + isPicture: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "Post as:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.FilterChip( + selected = !isPicture, + onClick = { onToggle(false) }, + label = { Text("Note", style = MaterialTheme.typography.labelSmall) }, + ) + androidx.compose.material3.FilterChip( + selected = isPicture, + onClick = { onToggle(true) }, + label = { Text("Picture", style = MaterialTheme.typography.labelSmall) }, + ) + } +} + +private fun buildPictureMetas(results: List): List = + results.mapNotNull { result -> + val url = result.blossom.url ?: return@mapNotNull null + val meta = result.metadata + com.vitorpamplona.quartz.nip68Picture.PictureMeta( + url = url, + mimeType = meta.mimeType, + blurhash = meta.blurhash, + dimension = + if (meta.width != null && meta.height != null) { + com.vitorpamplona.quartz.nip94FileMetadata.tags + .DimensionTag(meta.width, meta.height) + } else { + null + }, + hash = meta.sha256, + size = meta.size.toInt(), + ) + } + +private suspend fun publishPicture( + description: String, + images: List, account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, - replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { withContext(Dispatchers.IO) { if (account.isReadOnly) { throw IllegalStateException("Cannot post in read-only mode") } - val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo) + val template = + com.vitorpamplona.quartz.nip68Picture.PictureEvent.build( + images = images, + description = description, + ) { + hashtags(findHashtags(description)) + } + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } +} + +private suspend fun publishNote( + content: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + replyTo: Event?, + imetaTags: List = emptyList(), +) { + withContext(Dispatchers.IO) { + if (account.isReadOnly) { + throw IllegalStateException("Cannot post in read-only mode") + } + + val template = + TextNoteEvent.build(content) { + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + hashtags(findHashtags(content)) + references(findURLs(content)) + for (imeta in imetaTags) { + add(imeta.toTagArray()) + } + } + + val signedEvent = account.signer.sign(template) relayManager.broadcastToAll(signedEvent) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index e539b15109..b8fee13443 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui -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.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -55,6 +55,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -75,7 +76,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -86,6 +89,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +data class LightboxState( + val urls: List, + val index: Int, + val seekPosition: Float = 0f, + val fullscreen: Boolean = false, +) + /** * Note card with action buttons. */ @@ -100,6 +110,8 @@ fun FeedNoteCard( onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, zapReceipts: List = emptyList(), reactionCount: Int = 0, replyCount: Int = 0, @@ -110,15 +122,15 @@ fun FeedNoteCard( ) { val zapAmountSats = zapReceipts.sumOf { it.amountSats } - Column( - modifier = - Modifier.clickable { - onNavigateToThread(event.id) - }, - ) { + Column { NoteCard( note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, ) // Action buttons (only if logged in) @@ -180,6 +192,7 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } + var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } @@ -434,30 +447,40 @@ fun FeedScreen( ) } - // Subscribe to metadata for note authors (to enable zaps and populate search cache) + // Subscribe to metadata for note authors + mentioned users val authorPubkeys = events.map { it.pubKey }.distinct() + val mentionedPubkeys = + remember(events) { + val parser = UrlParser() + events + .flatMap { event -> + val urls = parser.parseValidUrls(event.content) + extractMentionedPubkeys(urls.bech32s) + }.distinct() + } + val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() } // Use coordinator for rate-limited metadata loading (preferred) - LaunchedEffect(authorPubkeys, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys) + LaunchedEffect(allPubkeys, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys) } } // Fallback subscription if coordinator not available - rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { + rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) { // Skip if using coordinator if (subscriptionsCoordinator != null) { return@rememberSubscription null } - if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) { + if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) { return@rememberSubscription null } // Only fetch metadata for users we don't have yet val missingPubkeys = - authorPubkeys.filter { pubkey -> + allPubkeys.filter { pubkey -> localCache .getUserIfExists(pubkey) ?.metadataOrNull() @@ -480,147 +503,156 @@ fun FeedScreen( } @OptIn(ExperimentalLayoutApi::class) - Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button — wraps on narrow columns - FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // Header with compose button — wraps on narrow columns + FlowRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { + FlowRow( + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) - // Feed mode selector - if (account != null) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - FilterChip( - selected = feedMode == FeedMode.GLOBAL, - onClick = { - feedMode = FeedMode.GLOBAL - DesktopPreferences.feedMode = FeedMode.GLOBAL - }, - label = { Text("Global") }, + // Feed mode selector + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { + feedMode = FeedMode.GLOBAL + DesktopPreferences.feedMode = FeedMode.GLOBAL + }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { + feedMode = FeedMode.FOLLOWING + DesktopPreferences.feedMode = FeedMode.FOLLOWING + }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (feedMode == FeedMode.FOLLOWING) { + Text( + " • ${followedUsers.size} followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - FilterChip( - selected = feedMode == FeedMode.FOLLOWING, - onClick = { - feedMode = FeedMode.FOLLOWING - DesktopPreferences.feedMode = FeedMode.FOLLOWING - }, - label = { Text("Following") }, + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), ) } } } - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${connectedRelays.size} relays connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (feedMode == FeedMode.FOLLOWING) { - Text( - " • ${followedUsers.size} followed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // New Post button (primary action) + Button( + onClick = onCompose, + enabled = account != null && !account.isReadOnly, + ) { + Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { relayManager.connect() }, - modifier = Modifier.size(24.dp), - ) { - Icon( - Icons.Default.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), + Text("New Post") + } + } + + Spacer(Modifier.height(8.dp)) + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { + LoadingState("Loading followed users...") + } else if (events.isEmpty() && !initialLoadComplete) { + LoadingState("Loading notes...") + } else if (events.isEmpty() && initialLoadComplete) { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" + }, + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" + }, + onRefresh = { relayManager.connect() }, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Use distinctBy to prevent duplicate key crashes from events with same ID + items(events.distinctBy { it.id }, key = { it.id }) { event -> + FeedNoteCard( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { replyToEvent = event }, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + zapReceipts = zapsByEvent[event.id] ?: emptyList(), + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } } - - // New Post button (primary action) - Button( - onClick = onCompose, - enabled = account != null && !account.isReadOnly, - ) { - Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("New Post") - } - } - - Spacer(Modifier.height(8.dp)) - - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { - LoadingState("Loading followed users...") - } else if (events.isEmpty() && !initialLoadComplete) { - LoadingState("Loading notes...") - } else if (events.isEmpty() && initialLoadComplete) { - EmptyState( - title = - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users" - } else { - "No notes found" - }, - description = - if (feedMode == FeedMode.FOLLOWING) { - "Notes from people you follow will appear here" - } else { - "Notes from the network will appear here" - }, - onRefresh = { relayManager.connect() }, - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Use distinctBy to prevent duplicate key crashes from events with same ID - items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = { replyToEvent = event }, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - zapReceipts = zapsByEvent[event.id] ?: emptyList(), - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds - }, - ) - } - } - } + } // end Column // Reply dialog if (replyToEvent != null && account != null) { @@ -631,5 +663,16 @@ fun FeedScreen( replyTo = replyToEvent, ) } - } + + // Lightbox overlay + lightboxState?.let { state -> + LightboxOverlay( + urls = state.urls, + initialIndex = state.index, + initialSeekPosition = state.seekPosition, + initialFullscreen = state.fullscreen, + onDismiss = { lightboxState = null }, + ) + } + } // end Box } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 1caa34d945..c866b0ba5b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,39 +42,68 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.commons.search.SavedSearch +import com.vitorpamplona.amethyst.commons.search.SearchQuery import com.vitorpamplona.amethyst.commons.search.SearchResult -import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard -import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState +import com.vitorpamplona.amethyst.commons.search.SearchResultFilter +import com.vitorpamplona.amethyst.commons.search.parseSearchInput +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel +import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList +import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -85,61 +119,140 @@ fun SearchScreen( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() - val searchState = remember { SearchBarState(localCache, scope) } + val state = remember { AdvancedSearchBarState(scope) } val focusRequester = remember { FocusRequester() } - // Pre-fill initial query (e.g., hashtag column) + // Pre-fill initial query LaunchedEffect(initialQuery) { if (initialQuery.isNotBlank()) { - searchState.updateSearchText(initialQuery) + state.updateFromText(initialQuery) } } + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + val displayText by state.displayText.collectAsState() + // Track TextFieldValue locally to preserve cursor position + var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) } + // Sync from flow only when text changes externally (form-driven updates) + LaunchedEffect(displayText) { + if (textFieldValue.text != displayText) { + textFieldValue = TextFieldValue(text = displayText, selection = TextRange(displayText.length)) + } + } + val query by state.query.collectAsState() + val debouncedQuery by state.debouncedQuery.collectAsState() + val panelExpanded by state.panelExpanded.collectAsState() + val isSearching by state.isSearching.collectAsState() + val peopleResults by state.peopleResults.collectAsState() + val noteResults by state.noteResults.collectAsState() + val relayStates by state.relayStates.collectAsState() - // Collect state from SearchBarState - val searchText by searchState.searchText.collectAsState() - val bech32Results by searchState.bech32Results.collectAsState() - val cachedUserResults by searchState.cachedUserResults.collectAsState() - val relaySearchResults by searchState.relaySearchResults.collectAsState() - val isSearchingRelays by searchState.isSearchingRelays.collectAsState() + // Bech32 parsing (immediate, no debounce) + val bech32Results = remember(displayText) { parseSearchInput(displayText) } - // NIP-50 relay search when local cache has few/no results - rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) { - if (connectedRelays.isEmpty()) return@rememberSubscription null + // Skip people search when query specifies kinds that don't include profile (kind 0) + val shouldSearchPeople = + (debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) || + debouncedQuery.kinds.contains(MetadataEvent.KIND) - // Only search relays if we have a real query and limited local results - if (searchState.shouldSearchRelays) { - searchState.startRelaySearch() - createSearchPeopleSubscription( - relays = connectedRelays, - searchQuery = searchText, - limit = 20, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - val user = localCache.getUserIfExists(event.pubKey) - if (user != null) { - searchState.addRelaySearchResult(user) - } - } - }, - onEose = { _, _ -> - searchState.endRelaySearch() - }, - ) - } else { - null + // Clear results and start loading when query changes + LaunchedEffect(debouncedQuery) { + if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) { + state.clearResults() + state.initRelayStates(allRelayUrls) + if (shouldSearchPeople) { + state.startSearching("people-search") + } + state.startSearching("adv-search") + // Timeout relays that silently ignore NIP-50 (e.g. strfry) + kotlinx.coroutines.delay(10_000L) + state.timeoutWaitingRelays() } } - // Subscribe to metadata for searched users (to populate cache) - rememberSubscription(connectedRelays, searchText, relayManager = relayManager) { - if (connectedRelays.isEmpty() || searchText.length < 2) { + // NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect) + rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + if (bech32Results.isNotEmpty()) return@rememberSubscription null + if (!shouldSearchPeople) { + state.stopSearching("people-search") return@rememberSubscription null } - // If it's a specific pubkey search, fetch that user's metadata - val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) + createSearchPeopleSubscription( + relays = allRelayUrls, + searchQuery = + debouncedQuery.text.ifBlank { + QuerySerializer.serialize(debouncedQuery) + }, + limit = 20, + onEvent = { event, _, relay, _ -> + if (state.trackRelayEvent(relay.url, event.id)) { + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + @Suppress("UNCHECKED_CAST") + val user = localCache.getUserIfExists(event.pubKey) as? User + if (user != null) { + state.addPeopleResult(user) + } + } + } + }, + onEose = { relay, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + state.stopSearching("people-search") + }, + onClosed = { relay, _, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.FAILED) + state.stopSearching("people-search") + }, + ) + } + + // NIP-50 advanced note search subscription (use allRelayUrls) + rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + if (bech32Results.isNotEmpty()) return@rememberSubscription null + + val filters = SearchFilterFactory.createFilters(debouncedQuery) + if (filters.isEmpty()) return@rememberSubscription null + + SubscriptionConfig( + subId = generateSubId("adv-search"), + filters = filters, + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig + if (state.trackRelayEvent(relay.url, event.id)) { + val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) + if (filtered.isNotEmpty()) { + state.addNoteResults(filtered) + } + } + }, + onEose = { relay, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + state.stopSearching("adv-search") + }, + onClosed = { relay, _, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.FAILED) + state.stopSearching("adv-search") + }, + ) + } + + // Metadata subscription for bech32 pubkey lookups + rememberSubscription(connectedRelays, displayText, relayManager = relayManager) { + if (connectedRelays.isEmpty() || displayText.length < 2) { + return@rememberSubscription null + } + val pubkeyHex = decodePublicKeyAsHexOrNull(displayText) if (pubkeyHex != null) { createMetadataSubscription( relays = connectedRelays, @@ -155,14 +268,67 @@ fun SearchScreen( } } - // Auto-focus the search field + // Save to history when search completes (snapshotFlow avoids LaunchedEffect race) + LaunchedEffect(Unit) { + snapshotFlow { isSearching to debouncedQuery } + .collect { (searching, query) -> + if (!searching && !query.isEmpty) { + SearchHistoryStore.addToHistory(query) + } + } + } + + // History state + val historyItems by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + + // Auto-focus LaunchedEffect(Unit) { focusRequester.requestFocus() } Column( - modifier = modifier.fillMaxSize(), + modifier = + modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (panelExpanded) { + state.togglePanel() + } else if (displayText.isNotEmpty()) { + state.clearSearch() + } + true + } + + else -> { + false + } + } + }, ) { + // Progress bar at very top + AnimatedVisibility( + visible = isSearching, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + // Relay status banner + SearchSyncBanner( + relayStates = relayStates, + isSearching = isSearching, + ) + + // Title row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -182,180 +348,266 @@ fun SearchScreen( Spacer(Modifier.height(16.dp)) - // Search input field - OutlinedTextField( - value = searchText, - onValueChange = { searchState.updateSearchText(it) }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - placeholder = { Text("Search by name, npub, nevent, or #hashtag") }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailingIcon = { - if (searchText.isNotEmpty()) { - IconButton(onClick = { searchState.clearSearch() }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Search bar with advanced toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + state.updateFromText(it.text) + }, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + placeholder = { Text("Search notes, people, tags... or use operators") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (displayText.isNotEmpty()) { + IconButton(onClick = { state.clearSearch() }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + IconButton(onClick = { state.togglePanel() }) { + Icon( + Icons.Default.Tune, + contentDescription = "Advanced Search", + tint = + if (panelExpanded) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + // Expandable advanced panel + AnimatedVisibility( + visible = panelExpanded, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + AdvancedSearchPanel( + query = query, + onKindsChanged = { state.updateKinds(it) }, + onPseudoKindsChanged = { state.updatePseudoKinds(it) }, + onAuthorAdded = { state.addAuthor(it) }, + onAuthorRemoved = { state.removeAuthor(it) }, + onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, + onHashtagAdded = { state.addHashtag(it) }, + onHashtagRemoved = { state.removeHashtag(it) }, + onExcludeAdded = { state.addExcludeTerm(it) }, + onExcludeRemoved = { state.removeExcludeTerm(it) }, + onLanguageChanged = { state.updateLanguage(it) }, + onClear = { state.clearSearch() }, + modifier = Modifier.padding(top = 8.dp), + ) + } Spacer(Modifier.height(16.dp)) // Results - val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty() + val hasAnyResults = + bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() - if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) { + if (bech32Results.isNotEmpty()) { + // Show bech32 results (exact lookup) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Direct lookup", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + bech32Results.forEach { result -> + SearchResultCard( + result = result, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onNavigateToHashtag = onNavigateToHashtag, + ) + } + } + } else if (hasAnyResults) { + SearchResultsList( + state = state, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + localCache = localCache, + ) + } else if (!debouncedQuery.isEmpty && !isSearching) { Text( - "No matches found. Try a name, npub, nevent, or #hashtag.", + "No results found. Try broader terms or fewer filters.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) - } else if (isSearchingRelays && !hasResults) { - Text( - "Searching relays...", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + } else if (!isSearching) { + // Empty state: show history + saved searches + operator hints + SearchEmptyState( + historyItems = historyItems, + savedSearches = savedSearches, + onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, + onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, + onClearHistory = { SearchHistoryStore.clearHistory() }, ) - } else if (hasResults) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Bech32/hex results first - if (bech32Results.isNotEmpty()) { - item { - Text( - "Direct lookup", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - items(bech32Results) { result -> - SearchResultCard( - result = result, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, - ) - } - } - - // Cached user results - if (cachedUserResults.isNotEmpty()) { - if (bech32Results.isNotEmpty()) { - item { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - } - } - item { - Text( - "Cached users (${cachedUserResults.size})", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user -> - UserSearchCard( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) - } - } - - // Relay search results (NIP-50) - if (relaySearchResults.isNotEmpty()) { - if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) { - item { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - } - } - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "From relays (${relaySearchResults.size})", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), - ) - if (isSearchingRelays) { - Text( - "searching...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user -> - UserSearchCard( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) - } - } else if (isSearchingRelays && cachedUserResults.isEmpty()) { - item { - Text( - "Searching relays...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - } - } - } else { - // Empty state - Column( - modifier = Modifier.fillMaxWidth().padding(top = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Search for users or notes", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - Text( - "Enter a name or Nostr identifier:", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(16.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - SearchHint("vitor", "Search by name") - SearchHint("npub1...", "User profile") - SearchHint("note1...", "Single note") - SearchHint("nevent1...", "Note with metadata") - SearchHint("#hashtag", "Hashtag search") - } - } } } } +@Composable +private fun SearchEmptyState( + historyItems: List, + savedSearches: List, + onLoadQuery: (SearchQuery) -> Unit, + onDeleteSaved: (String) -> Unit, + onClearHistory: () -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Saved searches + if (savedSearches.isNotEmpty()) { + item { + Text( + "Saved Searches", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + items(savedSearches, key = { "saved-${it.id}" }) { saved -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onLoadQuery(saved.query) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + saved.label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + QuerySerializer.serialize(saved.query), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + } + IconButton(onClick = { onDeleteSaved(saved.id) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + } + + // Recent history + if (historyItems.isNotEmpty()) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Recent", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onClearHistory) { + Text("Clear", style = MaterialTheme.typography.labelSmall) + } + } + } + items(historyItems.take(10), key = { "history-${QuerySerializer.serialize(it)}" }) { query -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onLoadQuery(query) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.History, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + QuerySerializer.serialize(query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + } + } + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + } + + // Operator hints + item { + Column( + modifier = Modifier.fillMaxWidth().padding(top = if (historyItems.isEmpty() && savedSearches.isEmpty()) 32.dp else 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Search operators", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + } + } + item { SearchHint("from:npub1...", "Filter by author") } + item { SearchHint("kind:article", "Long-form content") } + item { SearchHint("since:2025-01", "After January 2025") } + item { SearchHint("#bitcoin", "Hashtag search") } + item { SearchHint("\"exact phrase\"", "Exact match") } + item { SearchHint("bitcoin OR nostr", "Either term") } + item { SearchHint("-spam", "Exclude term") } + item { SearchHint("lang:en", "Language filter") } + } +} + @Composable private fun SearchHint( - identifier: String, + example: String, description: String, ) { Row( @@ -363,7 +615,7 @@ private fun SearchHint( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - identifier, + example, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.primary, @@ -390,25 +642,10 @@ private fun SearchResultCard( .fillMaxWidth() .clickable { when (result) { - is SearchResult.UserResult -> { - onNavigateToProfile(result.pubKeyHex) - } - - is SearchResult.CachedUserResult -> { - onNavigateToProfile(result.user.pubkeyHex) - } - - is SearchResult.NoteResult -> { - onNavigateToThread(result.noteIdHex) - } - - is SearchResult.AddressResult -> { - onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") - } - - is SearchResult.HashtagResult -> { - onNavigateToHashtag(result.hashtag) - } + is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex) + is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex) + is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") + is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag) } }, colors = @@ -425,7 +662,6 @@ private fun SearchResultCard( imageVector = when (result) { is SearchResult.UserResult -> Icons.Default.Person - is SearchResult.CachedUserResult -> Icons.Default.Person is SearchResult.NoteResult -> Icons.Default.Description is SearchResult.AddressResult -> Icons.Default.Description is SearchResult.HashtagResult -> Icons.Default.Tag @@ -439,7 +675,6 @@ private fun SearchResultCard( Text( when (result) { is SearchResult.UserResult -> "User Profile" - is SearchResult.CachedUserResult -> result.user.toBestDisplayName() is SearchResult.NoteResult -> "Note" is SearchResult.AddressResult -> "Event (kind ${result.kind})" is SearchResult.HashtagResult -> "#${result.hashtag}" @@ -450,7 +685,6 @@ private fun SearchResultCard( Text( when (result) { is SearchResult.UserResult -> result.displayId - is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex() is SearchResult.NoteResult -> result.displayId is SearchResult.AddressResult -> result.displayId is SearchResult.HashtagResult -> "Search posts with this hashtag" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ca79ca553b..d4368f9846 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui 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.Row import androidx.compose.foundation.layout.Spacer @@ -51,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -68,7 +70,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscriptio import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -135,12 +139,25 @@ fun ThreadScreen( var bookmarkList by remember { mutableStateOf(null) } var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } - // Load metadata for thread authors via coordinator + // Lightbox state + var lightboxState by remember { mutableStateOf(null) } + + // Load metadata for thread authors + mentioned users via coordinator LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { if (subscriptionsCoordinator != null) { val pubkeys = mutableListOf() rootNote?.let { pubkeys.add(it.pubKey) } pubkeys.addAll(replyEvents.map { it.pubKey }) + + // Also load metadata for users mentioned in note content + val parser = UrlParser() + val allEvents = listOfNotNull(rootNote) + replyEvents + val mentionedPubkeys = + allEvents.flatMap { event -> + extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s) + } + pubkeys.addAll(mentionedPubkeys) + if (pubkeys.isNotEmpty()) { subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct()) } @@ -331,173 +348,200 @@ fun ThreadScreen( return level } - Column(modifier = Modifier.fillMaxSize()) { - // Header with back button - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(24.dp), + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // Header with back button + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Thread", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, ) } - Spacer(Modifier.width(8.dp)) - Text( - "Thread", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (rootNote == null && !rootNoteEoseReceived) { - LoadingState("Loading thread...") - } else if (rootNote == null && rootNoteEoseReceived) { - EmptyState( - title = "Note not found", - description = "This note may have been deleted or is not available from connected relays", - onRefresh = onBack, - refreshLabel = "Go back", - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - // Root note (no reply level indicator) - item(key = noteId) { - Column( - modifier = - Modifier.clickable { - // Already viewing this thread, no-op - }, - ) { - NoteCard( - note = rootNote!!.toNoteDisplayData(localCache), - onAuthorClick = onNavigateToProfile, - ) - if (account != null) { - val rootZaps = zapsByEvent[noteId] ?: emptyList() - NoteActionsRow( - event = rootNote!!, - relayManager = relayManager, + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (rootNote == null && !rootNoteEoseReceived) { + LoadingState("Loading thread...") + } else if (rootNote == null && rootNoteEoseReceived) { + EmptyState( + title = "Note not found", + description = "This note may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + // Root note (no reply level indicator) + item(key = noteId) { + Column { + NoteCard( + note = rootNote!!.toNoteDisplayData(localCache), localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = { onReply(rootNote!!) }, - onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = rootZaps.size, - zapAmountSats = rootZaps.sumOf { it.amountSats }, - zapReceipts = rootZaps, - reactionCount = reactionsByEvent[noteId] ?: 0, - replyCount = repliesByEvent[noteId] ?: 0, - repostCount = repostsByEvent[noteId] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(noteId), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) + if (account != null) { + val rootZaps = zapsByEvent[noteId] ?: emptyList() + NoteActionsRow( + event = rootNote!!, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onReply(rootNote!!) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = rootZaps.size, + zapAmountSats = rootZaps.sumOf { it.amountSats }, + zapReceipts = rootZaps, + reactionCount = reactionsByEvent[noteId] ?: 0, + replyCount = repliesByEvent[noteId] ?: 0, + repostCount = repostsByEvent[noteId] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(noteId), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, + ) + } } + HorizontalDivider(thickness = 1.dp) } - HorizontalDivider(thickness = 1.dp) - } - // Reply notes with level indicators - items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> - val level = calculateLevel(event) + // Reply notes with level indicators + items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> + val level = calculateLevel(event) - Column( - modifier = - Modifier - .drawReplyLevel( - level = level, - color = MaterialTheme.colorScheme.outlineVariant, - selected = - if (event.id == noteId) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant - }, - ).clickable { - onNavigateToThread(event.id) - }, - ) { - NoteCard( - note = event.toNoteDisplayData(localCache), - onAuthorClick = onNavigateToProfile, - ) - if (account != null) { - val eventZaps = zapsByEvent[event.id] ?: emptyList() - NoteActionsRow( - event = event, - relayManager = relayManager, + Column( + modifier = + Modifier + .drawReplyLevel( + level = level, + color = MaterialTheme.colorScheme.outlineVariant, + selected = + if (event.id == noteId) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + ).clickable { + onNavigateToThread(event.id) + }, + ) { + NoteCard( + note = event.toNoteDisplayData(localCache), localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = { onReply(event) }, - onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = eventZaps.size, - zapAmountSats = eventZaps.sumOf { it.amountSats }, - zapReceipts = eventZaps, - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) + if (account != null) { + val eventZaps = zapsByEvent[event.id] ?: emptyList() + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onReply(event) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = eventZaps.size, + zapAmountSats = eventZaps.sumOf { it.amountSats }, + zapReceipts = eventZaps, + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, + ) + } } + HorizontalDivider(thickness = 1.dp) } - HorizontalDivider(thickness = 1.dp) - } - // Empty state for no replies - if (replyEvents.isEmpty() && repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "No replies yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } - } else if (replyEvents.isEmpty() && !repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "Loading replies...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) + // Empty state for no replies + if (replyEvents.isEmpty() && repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "No replies yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } else if (replyEvents.isEmpty() && !repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "Loading replies...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } } } } + } // end Column + + // Lightbox overlay + val lb = lightboxState + if (lb != null) { + LightboxOverlay( + urls = lb.urls, + initialIndex = lb.index, + initialSeekPosition = lb.seekPosition, + initialFullscreen = lb.fullscreen, + onDismiss = { lightboxState = null }, + ) } - } + } // end Box } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index eed9c760f3..15f0a0de6b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +37,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check @@ -49,12 +54,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,12 +87,16 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay +import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -141,6 +153,11 @@ fun UserProfileScreen( var postsError by remember { mutableStateOf(null) } var retryTrigger by remember { mutableStateOf(0) } + // Tab and gallery state + var selectedTab by remember { mutableStateOf(0) } + var lightboxState by remember { mutableStateOf(null) } + val pictureEvents = remember { mutableStateListOf() } + // Follow state val followState = remember(account) { @@ -214,7 +231,7 @@ fun UserProfileScreen( latestMetadataEvent = event } } - } catch (e: Exception) { + } catch (_: Exception) { // Ignore parse errors } } @@ -300,343 +317,472 @@ fun UserProfileScreen( } } - Column(modifier = Modifier.fillMaxSize()) { - // Broadcast banner for profile updates - ProfileBroadcastBanner( - status = broadcastStatus, - onTap = { - // Clear banner on tap (could add retry logic for failed) - if (broadcastStatus is ProfileBroadcastStatus.Success || - broadcastStatus is ProfileBroadcastStatus.Failed - ) { - broadcastStatus = ProfileBroadcastStatus.Idle - } - }, - ) - - // Header with back button - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") - } - Spacer(Modifier.width(8.dp)) - Text( - "Profile", - style = MaterialTheme.typography.headlineMedium, - ) - } - - // Edit button for own profile - if (isOwnProfile && account?.isReadOnly == false) { - OutlinedButton( - onClick = { - editingDisplayName = displayName ?: "" - showEditDialog = true - }, - ) { - Icon( - Icons.Default.Edit, - contentDescription = "Edit profile", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text("Edit Profile") - } - } - - // Follow/Unfollow button for other profiles - if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { - Column(horizontalAlignment = Alignment.End) { - Button( - onClick = { - scope.launch { - val currentStatus = followState.currentStatusOrNull() - - followState.setFollowLoading() - try { - val updatedEvent = - if (currentStatus?.isFollowing == true) { - unfollowUser(pubKeyHex, account, relayManager, myContactList) - } else { - followUser(pubKeyHex, account, relayManager, myContactList) - } - - // Update both stored contact list and followState - myContactList = updatedEvent - followState.setFollowSuccess(updatedEvent, pubKeyHex) - } catch (e: Exception) { - e.printStackTrace() - followState.setFollowError(e.message ?: "Failed to update follow status", e) - } - } - }, - enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, - ) { - val state = followState.state.collectAsState().value - val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false - val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading - - when { - !contactListLoaded -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text("Loading...") - } - - isLoading -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollowing..." else "Following...") - } - - else -> { - Icon( - if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd, - contentDescription = if (isFollowing) "Unfollow" else "Follow", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollow" else "Follow") - } - } + // Subscribe to picture events (kind 20) for gallery tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + pictureEvents.clear() + SubscriptionConfig( + subId = generateSubId("pics-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(PictureEvent.KIND), + limit = 100, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is PictureEvent && pictureEvents.none { it.id == event.id }) { + pictureEvents.add(event) } - - val errorMessage = - followState.state - .collectAsState() - .value - .errorOrNull() - errorMessage?.let { error -> - Spacer(Modifier.height(4.dp)) - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } + }, + onEose = { _, _ -> }, + ) + } else { + null } + } + // Scroll state for detecting scroll direction + val listState = rememberLazyListState() + var showFloatingHeader by remember { mutableStateOf(false) } + var previousFirstVisibleItemIndex by remember { mutableStateOf(0) } + var previousFirstVisibleItemScrollOffset by remember { mutableStateOf(0) } + + // Show floating header when scrolling up and header is scrolled out of view + LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { + val currentIndex = listState.firstVisibleItemIndex + val currentOffset = listState.firstVisibleItemScrollOffset + val scrollingUp = + currentIndex < previousFirstVisibleItemIndex || + (currentIndex == previousFirstVisibleItemIndex && currentOffset < previousFirstVisibleItemScrollOffset) + + // Header items are indices 0-3, so if first visible >= 3, header is out of view + showFloatingHeader = scrollingUp && currentIndex >= 3 + if (!scrollingUp && currentIndex < 3) showFloatingHeader = false + + previousFirstVisibleItemIndex = currentIndex + previousFirstVisibleItemScrollOffset = currentOffset + } + + Box(modifier = Modifier.fillMaxSize()) { if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") } else { - // Profile card - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), ) { - Column(modifier = Modifier.padding(16.dp)) { + // Broadcast banner + item(key = "broadcast") { + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + } + + // Header with back button + item(key = "header") { Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - // Profile picture with robohash fallback - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 56.dp, - contentDescription = "Profile picture", - ) - - Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Spacer(Modifier.width(8.dp)) Text( - displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + "Profile", + style = MaterialTheme.typography.headlineMedium, ) - Spacer(Modifier.height(4.dp)) - val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() - var copied by remember { mutableStateOf(false) } + } - // Reset copied state after delay - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false + // Edit button for own profile + if (isOwnProfile && account.isReadOnly == false) { + OutlinedButton( + onClick = { + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Edit Profile") + } + } + + // Follow/Unfollow button for other profiles + if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { + Column(horizontalAlignment = Alignment.End) { + Button( + onClick = { + scope.launch { + val currentStatus = followState.currentStatusOrNull() + + followState.setFollowLoading() + try { + val updatedEvent = + if (currentStatus?.isFollowing == true) { + unfollowUser(pubKeyHex, account, relayManager, myContactList) + } else { + followUser(pubKeyHex, account, relayManager, myContactList) + } + + // Update both stored contact list and followState + myContactList = updatedEvent + followState.setFollowSuccess(updatedEvent, pubKeyHex) + } catch (e: Exception) { + e.printStackTrace() + followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + } + }, + enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, + ) { + val state = followState.state.collectAsState().value + val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false + val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading + + when { + !contactListLoaded -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Loading...") + } + + isLoading -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollowing..." else "Following...") + } + + else -> { + Icon( + if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd, + contentDescription = if (isFollowing) "Unfollow" else "Follow", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollow" else "Follow") + } + } + } + + val errorMessage = + followState.state + .collectAsState() + .value + .errorOrNull() + errorMessage?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + + // Profile card + item(key = "profile-card") { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 56.dp, + contentDescription = "Profile picture", + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (npub != null) { + IconButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(npub), null) + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + if (copied) Icons.Default.Check else Icons.Default.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy npub", + modifier = Modifier.size(14.dp), + tint = + if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + if (about != null) { + Spacer(Modifier.height(12.dp)) Text( - (npub?.take(32) ?: pubKeyHex.take(32)) + "...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + about!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) - if (npub != null) { - IconButton( - onClick = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(npub), null) - copied = true - }, - modifier = Modifier.size(20.dp), + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column { + Text( + "$followersCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Followers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column { + Text( + "$followingCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Following", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + // Tabs + item(key = "tabs") { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text("Notes", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text("Gallery", modifier = Modifier.padding(12.dp)) + } + } + } + + // Tab content + when (selectedTab) { + 0 -> { + when { + postsError != null -> { + item(key = "error") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, ) { - Icon( - if (copied) Icons.Default.Check else Icons.Default.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy npub", - modifier = Modifier.size(14.dp), - tint = - if (copied) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Failed to load posts", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + postsError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } + } + } + } + + postsLoading -> { + item(key = "loading") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading posts...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + events.isEmpty() -> { + item(key = "empty") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No posts yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } - } - } - if (about != null) { - Spacer(Modifier.height(12.dp)) - Text( - about!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column { - Text( - "$followersCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Followers", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Column { - Text( - "$followingCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Following", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - Spacer(Modifier.height(16.dp)) - - // User's posts - Text( - "Posts", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - - when { - postsError != null -> { - // Error state with retry - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "Failed to load posts", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(8.dp)) - Text( - postsError!!, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { retryTrigger++ }) { - Text("Retry") + else -> { + items(events.distinctBy { it.id }, key = { it.id }) { event -> + FeedNoteCard( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } } } } - } - postsLoading -> { - // Loading state - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - androidx.compose.material3.CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - Text( - "Loading posts...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - events.isEmpty() -> { - // Empty state (loaded but no posts) - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No posts yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - else -> { - // Posts loaded successfully - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, + 1 -> { + item(key = "gallery") { + GalleryTab( + pictureEvents = pictureEvents, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + modifier = Modifier.fillParentMaxHeight(), ) } } } } } + + // Floating header — appears on scroll up when profile header is out of view + AnimatedVisibility( + visible = showFloatingHeader, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Spacer(Modifier.width(8.dp)) + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 28.dp, + contentDescription = "Profile picture", + ) + Spacer(Modifier.width(8.dp)) + Text( + displayName ?: pubKeyHex.take(12) + "...", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + } + } + + // Lightbox overlay + lightboxState?.let { state -> + LightboxOverlay( + urls = state.urls, + initialIndex = state.index, + initialSeekPosition = state.seekPosition, + initialFullscreen = state.fullscreen, + onDismiss = { lightboxState = null }, + ) } // Edit Profile Dialog diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt index d8c7c16321..bae3732b17 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -41,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.login_hide_key diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index 67d1bcdf76..ea6c001135 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -54,6 +53,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt index f4a3c432c3..573d035861 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -35,6 +34,7 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt new file mode 100644 index 0000000000..8c9993d671 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Forward +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.EncryptedMediaService +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import org.jetbrains.skia.Image as SkiaImage + +@Composable +fun ChatFileAttachment( + event: ChatMessageEncryptedFileHeaderEvent, + onForward: ((ChatMessageEncryptedFileHeaderEvent) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val url = event.url() + val mimeType = event.mimeType() + val keyBytes = event.key() + val nonce = event.nonce() + val isImage = mimeType?.startsWith("image/") == true + val scope = rememberCoroutineScope() + + var decryptedImage by remember { mutableStateOf(null) } + var decryptedBytes by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(false) } + + // Auto-decrypt images + LaunchedEffect(url) { + if (keyBytes != null && nonce != null && url != null) { + isLoading = true + try { + val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce) + decryptedBytes = bytes + if (isImage) { + withContext(Dispatchers.Default) { + val skImage = SkiaImage.makeFromEncoded(bytes) + decryptedImage = skImage.toComposeImageBitmap() + } + } + } catch (e: Exception) { + error = e.message + } finally { + isLoading = false + } + } + } + + Box { + Card( + modifier = + modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + // Right-click detection handled via awaiting press + // Context menu is shown on secondary button via the other modifier + }, + ) + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { + showContextMenu = true + } + } + } + }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column(modifier = Modifier.padding(8.dp)) { + // Encryption indicator + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Lock, + contentDescription = "Encrypted", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + Text( + "Encrypted file", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(4.dp)) + + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).align(Alignment.CenterHorizontally), + ) + } + + decryptedImage != null -> { + androidx.compose.foundation.Image( + bitmap = decryptedImage!!, + contentDescription = "Encrypted image", + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.FillWidth, + ) + } + + error != null -> { + Text( + "Failed to decrypt: $error", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + else -> { + // Non-image file + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.InsertDriveFile, + contentDescription = "File", + modifier = Modifier.size(32.dp), + ) + Spacer(Modifier.width(8.dp)) + Column { + Text( + mimeType ?: "Unknown file", + style = MaterialTheme.typography.bodySmall, + ) + event.size()?.let { size -> + Text( + "${size / 1024}KB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + + // Right-click context menu + DropdownMenu( + expanded = showContextMenu, + onDismissRequest = { showContextMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Save to disk") }, + leadingIcon = { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + }, + enabled = decryptedBytes != null, + onClick = { + showContextMenu = false + scope.launch { + saveDecryptedFile(decryptedBytes!!, mimeType, event.hash()) + } + }, + ) + if (onForward != null) { + DropdownMenuItem( + text = { Text("Forward") }, + leadingIcon = { + Icon(Icons.Default.Forward, contentDescription = null, modifier = Modifier.size(18.dp)) + }, + onClick = { + showContextMenu = false + onForward(event) + }, + ) + } + } + } +} + +/** + * Save decrypted bytes to disk via a native save dialog. + */ +private suspend fun saveDecryptedFile( + bytes: ByteArray, + mimeType: String?, + hash: String?, +) { + val extension = mimeTypeToExtension(mimeType) + val suggestedName = "${hash?.take(12) ?: "file"}.$extension" + + val file = + withContext(Dispatchers.Main) { + val dialog = + FileDialog(null as Frame?, "Save Decrypted File", FileDialog.SAVE).apply { + this.file = suggestedName + } + dialog.isVisible = true + val dir = dialog.directory ?: return@withContext null + File(dir, dialog.file ?: return@withContext null) + } ?: return + + withContext(Dispatchers.IO) { + file.writeBytes(bytes) + } +} + +private fun mimeTypeToExtension(mimeType: String?): String = + when (mimeType) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + "image/svg+xml" -> "svg" + "video/mp4" -> "mp4" + "video/webm" -> "webm" + "video/quicktime" -> "mov" + "audio/mpeg" -> "mp3" + "audio/ogg" -> "ogg" + "audio/wav" -> "wav" + "audio/flac" -> "flac" + else -> "bin" + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 6c936d8859..da37450021 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui.chats +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,7 +39,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.outlined.AddReaction @@ -46,6 +50,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -53,6 +59,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -60,6 +67,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed @@ -88,14 +97,28 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator +import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker +import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import kotlinx.coroutines.launch +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DnDConstants +import java.awt.dnd.DropTargetDropEvent +import java.io.File private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +private val MEDIA_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") + /** * Right panel of the DM split-pane layout (flexible width). * @@ -111,6 +134,7 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") * @param messageState ChatNewMessageState for composition * @param onNavigateToProfile Called when user clicks on a profile */ +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ChatPane( roomKey: ChatroomKey, @@ -120,13 +144,56 @@ fun ChatPane( messageState: ChatNewMessageState, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, + onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() val feedState by feedViewModel.feedState.feedContent.collectAsState() val messageText by messageState.message.collectAsState() - val isNip17 by messageState.nip17.collectAsState() - val requiresNip17 by messageState.requiresNip17.collectAsState() + val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState() + + // File attachment state + val attachedFiles = remember { mutableStateListOf() } + var isUploading by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + // Helper: attach files + fun attachFiles(files: List) { + val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS } + if (mediaFiles.isEmpty()) return + attachedFiles.addAll(mediaFiles) + } + + // Drag-and-drop target for file attachments (NIP-17 only) + var isDragOver by remember { mutableStateOf(false) } + val dropTarget = + remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + isDragOver = false + val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false + dropEvent.acceptDrop(DnDConstants.ACTION_COPY) + val transferable = dropEvent.transferable + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + @Suppress("UNCHECKED_CAST") + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + attachFiles(files) + dropEvent.dropComplete(true) + return true + } + dropEvent.dropComplete(false) + return false + } + + override fun onStarted(event: DragAndDropEvent) { + isDragOver = true + } + + override fun onEnded(event: DragAndDropEvent) { + isDragOver = false + } + } + } // Resolve users for the header val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } @@ -137,110 +204,184 @@ fun ChatPane( messageState.load(roomKey) } - Column(modifier = modifier.fillMaxSize()) { - // Header - if (isGroup) { - GroupChatroomHeader( - users = users, - onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, - ) - } else { - users.firstOrNull()?.let { user -> - ChatroomHeader( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) - } ?: run { - // Fallback header with raw pubkey - Text( - text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(10.dp), - ) - } - } - - HorizontalDivider() - - // Broadcast status banner - DmBroadcastBanner(status = dmBroadcastStatus) - - // Message list - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - when (feedState) { - is FeedState.Loading -> { - LoadingState("Loading messages...") - } - - is FeedState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - "No messages yet. Send the first one!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is FeedState.Loaded -> { - val loaded = feedState as FeedState.Loaded - val loadedState by loaded.feed.collectAsState() - val messages = loadedState.list - - MessageList( - messages = messages, - account = account, - cacheProvider = cacheProvider, - onAuthorClick = onNavigateToProfile, - onReaction = { note, emoji -> - scope.launch { - try { - sendWrappedReaction(note, emoji, roomKey, account) - } catch (e: Exception) { - println("Failed to send reaction: ${e.message}") - } - } + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .dragAndDropTarget( + shouldStartDragAndDrop = { true }, + target = dropTarget, + ).then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) + } else { + Modifier }, - ) - } - - is FeedState.FeedError -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + ), + ) { + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (onBack != null) { + IconButton( + onClick = onBack, + modifier = Modifier.size(40.dp), ) { - Text( - "Error loading messages", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to conversations", ) } } - } - } - HorizontalDivider() - - // Message input - MessageInput( - messageText = messageText.text, - isNip17 = isNip17, - requiresNip17 = requiresNip17, - canSend = messageState.canSend, - onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, - onToggleNip17 = { messageState.toggleNip17() }, - onSend = { - scope.launch { - if (messageState.send()) { - messageState.clear() + Box(modifier = Modifier.weight(1f)) { + if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) + } else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + // Fallback header with raw pubkey + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } } } - }, + } + + HorizontalDivider() + + // Broadcast status banner + DmBroadcastBanner(status = dmBroadcastStatus) + + // Message list + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when (feedState) { + is FeedState.Loading -> { + LoadingState("Loading messages...") + } + + is FeedState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "No messages yet. Send the first one!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is FeedState.Loaded -> { + val loaded = feedState as FeedState.Loaded + val loadedState by loaded.feed.collectAsState() + val messages = loadedState.list + + MessageList( + messages = messages, + account = account, + cacheProvider = cacheProvider, + onAuthorClick = onNavigateToProfile, + onReaction = { note, emoji -> + scope.launch { + try { + sendWrappedReaction(note, emoji, roomKey, account) + } catch (e: Exception) { + println("Failed to send reaction: ${e.message}") + } + } + }, + ) + } + + is FeedState.FeedError -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "Error loading messages", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + HorizontalDivider() + + // File attachment row (only when NIP-17 and files attached) + if (attachedFiles.isNotEmpty()) { + MediaAttachmentRow( + attachedFiles = attachedFiles, + isUploading = isUploading, + onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, + onPaste = {}, + onRemove = { attachedFiles.remove(it) }, + ) + } + + // Message input + MessageInput( + messageText = messageText.text, + recipientsMissingRelays = recipientsMissingRelays, + canSend = messageState.canSend || attachedFiles.isNotEmpty(), + isUploading = isUploading, + hasAttachments = attachedFiles.isNotEmpty(), + onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, + onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, + onSend = { + scope.launch { + if (attachedFiles.isNotEmpty()) { + isUploading = true + try { + sendEncryptedFiles( + files = attachedFiles.toList(), + roomKey = roomKey, + account = account, + cacheProvider = cacheProvider, + ) + attachedFiles.clear() + } catch (e: Exception) { + // Keep files in attachment row for retry + println("Encrypted file send failed: ${e.message}") + } finally { + isUploading = false + } + } + // Also send text message if present + if (messageState.canSend) { + if (messageState.send()) { + messageState.clear() + } + } else if (attachedFiles.isEmpty()) { + messageState.clear() + } + } + }, + ) + } // end Column + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 80.dp), ) - } + } // end Box } /** @@ -373,6 +514,29 @@ private fun MessageWithReactions( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + // Encryption badge + when (event) { + is PrivateDmEvent -> { + Icon( + Icons.Default.LockOpen, + contentDescription = "NIP-04 (legacy)", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + } + + is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent, + is ChatMessageEncryptedFileHeaderEvent, + -> { + Icon( + Icons.Default.Lock, + contentDescription = "NIP-17 (encrypted)", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + ) + } + } + // Timestamp note.createdAt()?.let { timestamp -> Text( @@ -436,12 +600,20 @@ private fun MessageWithReactions( } }, ) { _ -> - SelectionContainer { - Text( - text = decryptedContent ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + when (note.event) { + is ChatMessageEncryptedFileHeaderEvent -> { + ChatFileAttachment(event = note.event as ChatMessageEncryptedFileHeaderEvent) + } + + else -> { + SelectionContainer { + Text( + text = decryptedContent ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } } @@ -517,11 +689,12 @@ private fun ReactionBar(onReaction: (String) -> Unit) { @Composable private fun MessageInput( messageText: String, - isNip17: Boolean, - requiresNip17: Boolean, + recipientsMissingRelays: Boolean, canSend: Boolean, + isUploading: Boolean = false, + hasAttachments: Boolean = false, onMessageChange: (String) -> Unit, - onToggleNip17: () -> Unit, + onAttach: () -> Unit = {}, onSend: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { @@ -530,6 +703,24 @@ private fun MessageInput( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + // Paperclip attach button — always visible, auto-switches to NIP-17 on send + IconButton( + onClick = onAttach, + enabled = !isUploading, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach file", + tint = + if (isUploading) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.primary + }, + ) + } + OutlinedTextField( value = messageText, onValueChange = onMessageChange, @@ -555,14 +746,14 @@ private fun MessageInput( IconButton( onClick = onSend, - enabled = canSend, + enabled = canSend && !isUploading, modifier = Modifier.size(40.dp), ) { Icon( Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = - if (canSend) { + if (canSend && !isUploading) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) @@ -571,48 +762,72 @@ private fun MessageInput( } } - // NIP-17 indicator + // NIP-17 indicator / recipient warning Spacer(Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp), ) { - IconButton( - onClick = onToggleNip17, - enabled = !requiresNip17, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (isNip17) Icons.Default.Lock else Icons.Default.LockOpen, - contentDescription = if (isNip17) "NIP-17 (encrypted)" else "NIP-04 (legacy)", - modifier = Modifier.size(16.dp), - tint = - if (isNip17) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - }, - ) - } + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "NIP-17 (encrypted)", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) Spacer(Modifier.width(4.dp)) Text( - text = if (isNip17) "NIP-17" else "NIP-04", + text = "NIP-17", style = MaterialTheme.typography.labelSmall, - color = - if (isNip17) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - }, + color = MaterialTheme.colorScheme.primary, ) - if (requiresNip17) { - Spacer(Modifier.width(4.dp)) + if (recipientsMissingRelays) { + Spacer(Modifier.width(8.dp)) Text( - text = "(required for groups)", + text = "Recipient has no DM relay list — messages cannot be delivered", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.error, ) } } } } + +/** + * Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15) + * wrapped in GiftWrap for each recipient. + */ +private suspend fun sendEncryptedFiles( + files: List, + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, +) { + val orchestrator = DesktopUploadOrchestrator() + val server = DesktopPreferences.preferredBlossomServer + val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() } + + for (file in files) { + val cipher = AESGCM() + val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer) + val url = result.blossom.url ?: continue + + val template = + ChatMessageEncryptedFileHeaderEvent.build( + to = recipients, + url = url, + cipher = cipher, + mimeType = result.metadata.mimeType, + hash = result.encryptedHash, + size = result.encryptedSize, + dimension = + if (result.metadata.width != null && result.metadata.height != null) { + DimensionTag(result.metadata.width, result.metadata.height) + } else { + null + }, + blurhash = result.metadata.blurhash, + originalHash = result.metadata.sha256, + ) + account.sendNip17EncryptedFile(template) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index ec2c9d9e73..8f6bb36dd4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -119,7 +119,6 @@ fun ConversationListPane( Column( modifier = modifier - .width(280.dp) .fillMaxHeight() .focusRequester(focusRequester) .focusable() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 599ee6929d..fa8da9e2ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -24,8 +24,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Icon @@ -59,18 +61,19 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.CoroutineScope private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") /** - * Desktop DM screen with split-pane layout. + * Desktop DM screen with two layout modes: * - * Left pane (280dp): ConversationListPane with Known/New tabs - * Right pane (flex): ChatPane with messages + input, or empty state + * - **Split mode** (compactMode = false): Side-by-side with conversation list (280dp) + chat pane. + * Used in single-pane layout where there's plenty of horizontal space. * - * @param account The user's IAccount for DM operations - * @param cacheProvider ICacheProvider for user/note lookups - * @param onNavigateToProfile Called when navigating to a user profile + * - **Compact mode** (compactMode = true): Stacked navigation — full-width contact list OR + * full-width chat. Used in multi-deck columns where width is limited. */ @Composable fun DesktopMessagesScreen( @@ -78,6 +81,7 @@ fun DesktopMessagesScreen( cacheProvider: ICacheProvider, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, + compactMode: Boolean = false, onNavigateToProfile: (String) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -89,51 +93,159 @@ fun DesktopMessagesScreen( val listFocusRequester = remember { FocusRequester() } var showNewDmDialog by remember { mutableStateOf(false) } - Row( - modifier = - Modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + // Shared keyboard shortcuts + val keyHandler = + Modifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed - when { - // Escape -> deselect conversation - event.key == Key.Escape -> { - listState.clearSelection() - true - } + when { + event.key == Key.Escape -> { + listState.clearSelection() + true + } - // Cmd+Shift+N / Ctrl+Shift+N -> new DM - event.key == Key.N && isModifier && event.isShiftPressed -> { - showNewDmDialog = true - true - } + event.key == Key.N && isModifier && event.isShiftPressed -> { + showNewDmDialog = true + true + } - else -> { - false - } - } - }, - ) { - // Left pane: conversation list (280dp fixed) + else -> { + false + } + } + } + + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } + + if (showNewDmDialog) { + NewDmDialog( + cacheProvider = cacheProvider, + relayManager = relayManager, + localCache = localCache, + onUserSelected = { roomKey -> + listState.selectRoom(roomKey) + showNewDmDialog = false + }, + onDismiss = { showNewDmDialog = false }, + ) + } +} + +/** + * Compact (stacked) layout for deck columns. + * Shows either the contact list OR the chat, never both. + */ +@Composable +private fun CompactMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Box(modifier = Modifier.fillMaxSize().then(keyHandler)) { + val currentRoom = selectedRoom + if (currentRoom != null) { + val feedViewModel = + remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = + remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + val broadcastStatus = + if (account is DesktopIAccount) { + account.dmSendTracker.status + .collectAsState() + .value + } else { + DmBroadcastStatus.Idle + } + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + dmBroadcastStatus = broadcastStatus, + onNavigateToProfile = onNavigateToProfile, + onBack = { listState.clearSelection() }, + ) + } else { + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, + focusRequester = listFocusRequester, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** + * Split (side-by-side) layout for single-pane mode. + * Contact list (280dp) + divider + chat pane (flex). + */ +@Composable +private fun SplitMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Row(modifier = Modifier.fillMaxSize().then(keyHandler)) { ConversationListPane( state = listState, selectedRoom = selectedRoom, - onConversationSelected = { roomKey -> - listState.selectRoom(roomKey) - }, - onNewConversation = { showNewDmDialog = true }, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, focusRequester = listFocusRequester, + modifier = Modifier.width(280.dp), ) VerticalDivider(modifier = Modifier.fillMaxHeight()) - // Right pane: chat or empty state (flex) Box(modifier = Modifier.weight(1f).fillMaxHeight()) { val currentRoom = selectedRoom if (currentRoom != null) { - // Create feed VM and message state scoped to the selected room val feedViewModel = remember(currentRoom) { ChatroomFeedViewModel(currentRoom, account, cacheProvider) @@ -142,7 +254,6 @@ fun DesktopMessagesScreen( remember(currentRoom) { ChatNewMessageState(account, cacheProvider, scope) } - val broadcastStatus = if (account is DesktopIAccount) { account.dmSendTracker.status @@ -162,28 +273,14 @@ fun DesktopMessagesScreen( onNavigateToProfile = onNavigateToProfile, ) } else { - // Empty state EmptyConversationState() } } } - - if (showNewDmDialog) { - NewDmDialog( - cacheProvider = cacheProvider, - relayManager = relayManager, - localCache = localCache, - onUserSelected = { roomKey -> - listState.selectRoom(roomKey) - showNewDmDialog = false - }, - onDismiss = { showNewDmDialog = false }, - ) - } } /** - * Shown when no conversation is selected in the right pane. + * Shown when no conversation is selected in the split layout right pane. */ @Composable private fun EmptyConversationState() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index ce90b7ef6c..4e3ff30160 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -53,7 +55,6 @@ import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen -import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -88,6 +89,7 @@ fun DeckColumnContainer( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, @@ -120,37 +122,44 @@ fun DeckColumnContainer( Box( modifier = Modifier.fillMaxSize().padding(12.dp), ) { + // Always keep RootContent composed so state (e.g. search results) survives navigation + RootContent( + columnType = column.type, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + compactMode = true, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) if (currentOverlay != null) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onBack = { navState.pop() }, - ) - } else { - RootContent( - columnType = column.type, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - ) + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } } } } @@ -163,9 +172,11 @@ internal fun RootContent( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, + compactMode: Boolean = false, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, @@ -195,19 +206,12 @@ internal fun RootContent( } DeckColumnType.Messages -> { - val dmSendTracker = - remember(relayManager) { - DmSendTracker(relayManager.client) - } - val iAccount = - remember(account, localCache, relayManager, dmSendTracker) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope) - } DesktopMessagesScreen( account = iAccount, cacheProvider = localCache, relayManager = relayManager, localCache = localCache, + compactMode = compactMode, onNavigateToProfile = onNavigateToProfile, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 1df6bf135c..519d932178 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -58,6 +58,7 @@ fun DeckLayout( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, @@ -109,6 +110,7 @@ fun DeckLayout( localCache = localCache, accountManager = accountManager, account = account, + iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, appScope = appScope, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index e58734588c..ea458daa2a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -20,20 +20,16 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -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.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Email @@ -43,12 +39,11 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -57,7 +52,6 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow @@ -71,6 +65,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope @@ -99,6 +94,7 @@ fun SinglePaneLayout( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, @@ -114,132 +110,96 @@ fun SinglePaneLayout( val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() + val isImmersive by LocalIsImmersiveFullscreen.current + Row(modifier = modifier.fillMaxSize()) { - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - navItems.forEach { item -> - NavigationRailItem( - selected = currentColumnType == item.type && navStack.isEmpty(), - onClick = { - currentColumnType = item.type - navState.clear() - }, - icon = { - Icon( - item.icon, - contentDescription = item.label, - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - item.label, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, + if (!isImmersive) { + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + navItems.forEach { item -> + NavigationRailItem( + selected = currentColumnType == item.type && navStack.isEmpty(), + onClick = { + currentColumnType = item.type + navState.clear() + }, + icon = { + Icon( + item.icon, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + item.label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + + Spacer(Modifier.weight(1f)) + + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.padding(bottom = 12.dp), ) } - - Spacer(Modifier.weight(1f)) - - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - modifier = Modifier.padding(bottom = 12.dp), - ) } - VerticalDivider() + if (!isImmersive) { + VerticalDivider() + } Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - // Show header with back button when navigated into overlay - if (navStack.isNotEmpty()) { - SinglePaneHeader( - title = - when (currentOverlay) { - is DesktopScreen.UserProfile -> "Profile" - is DesktopScreen.Thread -> "Thread" - else -> currentColumnType.title() - }, - onBack = { navState.pop() }, - ) - HorizontalDivider() - } - Box( - modifier = Modifier.fillMaxSize().padding(12.dp), + modifier = Modifier.fillMaxSize().padding(if (isImmersive) 0.dp else 12.dp), ) { + // Always keep RootContent composed so state (e.g. search results) survives navigation + RootContent( + columnType = currentColumnType, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) if (currentOverlay != null) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onBack = { navState.pop() }, - ) - } else { - RootContent( - columnType = currentColumnType, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - ) + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } } } } } } - -@Composable -private fun SinglePaneHeader( - title: String, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .height(40.dp) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt new file mode 100644 index 0000000000..1872b7b04e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Codec +import org.jetbrains.skia.Data +import java.util.concurrent.TimeUnit + +private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF +private const val MIN_FRAME_DURATION_MS = 20 + +private val gifHttpClient: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() +} + +fun isAnimatedGifUrl(url: String): Boolean { + val lower = url.lowercase() + return lower.endsWith(".gif") || + lower.contains(".gif?") || + lower.contains(".gif#") +} + +private class GifFrames( + val frames: List, + val durations: List, +) + +@Composable +fun AnimatedGifImage( + url: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) { + var gifFrames by remember(url) { mutableStateOf(null) } + var currentFrame by remember(url) { mutableIntStateOf(0) } + var loadFailed by remember(url) { mutableStateOf(false) } + + LaunchedEffect(url) { + currentFrame = 0 + loadFailed = false + gifFrames = withContext(Dispatchers.IO) { decodeGifFrames(url) } + if (gifFrames == null) loadFailed = true + } + + // Reset frame index when frames change + DisposableEffect(url) { + onDispose { currentFrame = 0 } + } + + val data = gifFrames + when { + data != null && data.frames.size > 1 -> { + LaunchedEffect(data) { + while (isActive) { + val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS) + delay(duration.toLong()) + currentFrame = (currentFrame + 1) % data.frames.size + } + } + + Image( + bitmap = data.frames[currentFrame], + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + data != null -> { + Image( + bitmap = data.frames[0], + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + loadFailed -> { + AsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + else -> { + Box(modifier) + } + } +} + +private fun decodeGifFrames(url: String): GifFrames? = + try { + val request = Request.Builder().url(url).build() + val response = gifHttpClient.newCall(request).execute() + val bytes = response.body.bytes() + + val skData = Data.makeFromBytes(bytes) + val codec = Codec.makeFromData(skData) + val frameCount = codec.frameCount + if (frameCount <= 0) return null + + val frameBitmapSize = codec.width.toLong() * codec.height * 4 + val totalMemory = frameBitmapSize * frameCount + val decodableFrames = + if (totalMemory > MAX_BITMAP_MEMORY) { + // Only decode first frame for huge GIFs + 1 + } else { + frameCount + } + + val frameInfos = codec.framesInfo + val frames = ArrayList(decodableFrames) + val durations = ArrayList(decodableFrames) + + for (i in 0 until decodableFrames) { + val bitmap = Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, i) + bitmap.setImmutable() + frames.add(bitmap.asComposeImageBitmap()) + durations.add(if (frameInfos.size > i) frameInfos[i].duration else 100) + } + + GifFrames(frames, durations) + } catch (e: Exception) { + println("AnimatedGif: failed to load $url — ${e.message}") + null + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt new file mode 100644 index 0000000000..c02f4167be --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + +@Composable +fun AudioPlayer( + url: String, + modifier: Modifier = Modifier, +) { + val audioState by GlobalMediaPlayer.audioState.collectAsState() + val isActiveAudio = audioState.url == url + + val isPlaying = if (isActiveAudio) audioState.isPlaying else false + val position = if (isActiveAudio) audioState.position else 0f + val duration = if (isActiveAudio) audioState.duration else 0L + val currentTime = if (isActiveAudio) audioState.currentTime else 0L + + Row( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + IconButton( + onClick = { + if (isActiveAudio) { + GlobalMediaPlayer.toggleAudioPlayPause() + } else { + GlobalMediaPlayer.playAudio(url) + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = formatTime(currentTime), + style = MaterialTheme.typography.labelSmall, + ) + + Slider( + value = position, + onValueChange = { GlobalMediaPlayer.seekAudio(it) }, + modifier = Modifier.weight(1f), + ) + + Text( + text = formatTime(duration), + style = MaterialTheme.typography.labelSmall, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt new file mode 100644 index 0000000000..2d398d54ef --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt @@ -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.desktop.ui.media + +import java.awt.Image +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +object ClipboardPasteHandler { + fun getClipboardFiles(): List { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + return try { + when { + clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor) -> { + @Suppress("UNCHECKED_CAST") + (clipboard.getData(DataFlavor.javaFileListFlavor) as? List) ?: emptyList() + } + + clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor) -> { + val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return emptyList() + val buffered = toBufferedImage(image) + val tempFile = File.createTempFile("clipboard_", ".png") + tempFile.deleteOnExit() + ImageIO.write(buffered, "png", tempFile) + listOf(tempFile) + } + + else -> { + emptyList() + } + } + } catch (_: Exception) { + emptyList() + } + } + + private fun toBufferedImage(image: Image): BufferedImage { + if (image is BufferedImage) return image + val buffered = BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB) + buffered.graphics.drawImage(image, 0, 0, null) + return buffered + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt new file mode 100644 index 0000000000..428a9983af --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.io.FilenameFilter + +object DesktopFilePicker { + private val mediaExtensions = + setOf( + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "avif", + "mp4", + "webm", + "mov", + "mp3", + "ogg", + "wav", + "flac", + ) + + fun pickMediaFiles(parent: Frame? = null): List { + val dialog = + FileDialog(parent, "Select Media", FileDialog.LOAD).apply { + isMultipleMode = true + filenameFilter = + FilenameFilter { _, name -> + val ext = name.substringAfterLast('.', "").lowercase() + ext in mediaExtensions + } + } + dialog.isVisible = true + return dialog.files?.toList() ?: emptyList() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt new file mode 100644 index 0000000000..ae3b082bcb --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache +import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool +import kotlinx.coroutines.delay + +@Composable +fun DesktopVideoPlayer( + url: String, + modifier: Modifier = Modifier, + autoPlay: Boolean = false, + initialSeekPosition: Float = 0f, + onFullscreen: ((Float) -> Unit)? = null, + viewMode: ViewMode = ViewMode.DEFAULT, + onViewModeChange: ((ViewMode) -> Unit)? = null, + trailingControls: @Composable (() -> Unit)? = null, +) { + // Check if this URL is the active video + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + val isActiveVideo = videoState.url == url + + // Thumbnail for inactive videos + var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) } + var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } + + // Load thumbnail when not active + LaunchedEffect(url, isActiveVideo) { + if (!isActiveVideo && thumbnail == null) { + for (attempt in 1..3) { + val result = VideoThumbnailCache.getThumbnail(url) + if (result != null) { + thumbnail = result + break + } + if (attempt < 3) delay(2000L * attempt) + } + } + } + + // Auto-play on mount if requested + LaunchedEffect(url, autoPlay) { + if (autoPlay) { + GlobalMediaPlayer.playVideo(url, initialSeekPosition) + } + } + + // Sync aspect ratio from global state when active + if (isActiveVideo && videoState.aspectRatio != 16f / 9f) { + aspectRatio = videoState.aspectRatio + } + + if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) { + VlcNotAvailableMessage(url, modifier) + return + } + + BoxWithConstraints(modifier = modifier) { + val desiredHeight = maxWidth / aspectRatio + val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(constrainedHeight) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail + displayBitmap?.let { bitmap -> + Image( + bitmap = bitmap, + contentDescription = "Video", + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit, + ) + } + + VideoControls( + isPlaying = if (isActiveVideo) videoState.isPlaying else false, + isBuffering = if (isActiveVideo) videoState.isBuffering else false, + position = if (isActiveVideo) videoState.position else 0f, + duration = if (isActiveVideo) videoState.duration else 0L, + currentTime = if (isActiveVideo) videoState.currentTime else 0L, + volume = if (isActiveVideo) videoState.volume else 100, + isMuted = if (isActiveVideo) videoState.isMuted else false, + viewMode = viewMode, + onPlayPause = { + if (isActiveVideo) { + GlobalMediaPlayer.toggleVideoPlayPause() + } else { + GlobalMediaPlayer.playVideo(url, initialSeekPosition) + } + }, + onSeek = { pos -> + if (isActiveVideo) { + GlobalMediaPlayer.seekVideo(pos) + } + }, + onVolumeChange = { vol -> + GlobalMediaPlayer.setVideoVolume(vol) + }, + onMuteToggle = { + GlobalMediaPlayer.toggleVideoMute() + }, + onFullscreen = + if (onFullscreen != null) { + { + val pos = if (isActiveVideo) videoState.position else 0f + onFullscreen(pos) + } + } else { + null + }, + onViewModeChange = onViewModeChange, + trailingControls = trailingControls, + ) + } + } +} + +@Composable +private fun VlcNotAvailableMessage( + url: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Video: $url\nInstall VLC to play videos: https://www.videolan.org/vlc/", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt new file mode 100644 index 0000000000..8cd466742c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import java.awt.GraphicsEnvironment +import java.awt.Window + +object FullscreenHelper { + fun enterFullscreen(window: Window) { + val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice + if (device.isFullScreenSupported) { + device.fullScreenWindow = window + } + } + + fun exitFullscreen() { + val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice + device.fullScreenWindow = null + } + + fun isFullscreen(): Boolean { + val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice + return device.fullScreenWindow != null + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt new file mode 100644 index 0000000000..18a59732f8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + +@Composable +fun GlobalFullscreenOverlay() { + val isFullscreen by GlobalMediaPlayer.isFullscreen.collectAsState() + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + + if (!isFullscreen || videoState.url == null) return + + val focusRequester = remember { FocusRequester() } + val awtWindow = LocalAwtWindow.current + val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current + + // Enter native fullscreen + LaunchedEffect(isFullscreen) { + if (isFullscreen) { + isImmersiveFullscreen.value = true + awtWindow?.let { FullscreenHelper.enterFullscreen(it) } + focusRequester.requestFocus() + } + } + + // Restore on exit + DisposableEffect(Unit) { + onDispose { + isImmersiveFullscreen.value = false + if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen() + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Escape -> { + GlobalMediaPlayer.exitFullscreen() + true + } + + Key.F -> { + GlobalMediaPlayer.exitFullscreen() + true + } + + Key.Spacebar -> { + GlobalMediaPlayer.toggleVideoPlayPause() + true + } + + else -> { + false + } + } + }, + contentAlignment = Alignment.Center, + ) { + // Video frame + videoFrame?.let { frame -> + Image( + bitmap = frame, + contentDescription = "Video fullscreen", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + + // Video controls overlay + VideoControls( + isPlaying = videoState.isPlaying, + isBuffering = videoState.isBuffering, + position = videoState.position, + duration = videoState.duration, + currentTime = videoState.currentTime, + volume = videoState.volume, + isMuted = videoState.isMuted, + viewMode = ViewMode.FULLSCREEN, + onPlayPause = { GlobalMediaPlayer.toggleVideoPlayPause() }, + onSeek = { GlobalMediaPlayer.seekVideo(it) }, + onVolumeChange = { GlobalMediaPlayer.setVideoVolume(it) }, + onMuteToggle = { GlobalMediaPlayer.toggleVideoMute() }, + onViewModeChange = { mode -> + if (mode == ViewMode.DEFAULT) { + GlobalMediaPlayer.exitFullscreen() + } + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt new file mode 100644 index 0000000000..194bad9f7c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -0,0 +1,521 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File +import java.net.URI + +val LocalWindowState = compositionLocalOf { null } + +val LocalAwtWindow = compositionLocalOf { null } + +val LocalIsImmersiveFullscreen = compositionLocalOf { mutableStateOf(false) } + +enum class ViewMode { DEFAULT, FULLSCREEN } + +private sealed class DownloadState { + data object Idle : DownloadState() + + data class Downloading( + val progress: Float, + val filename: String, + ) : DownloadState() + + data class Done( + val file: File, + ) : DownloadState() + + data class Failed( + val message: String, + ) : DownloadState() +} + +@Composable +fun LightboxOverlay( + urls: List, + initialIndex: Int = 0, + initialSeekPosition: Float = 0f, + initialFullscreen: Boolean = false, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) } + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var menuExpanded by remember { mutableStateOf(false) } + var downloadState by remember { mutableStateOf(DownloadState.Idle) } + var viewMode by remember { mutableStateOf(if (initialFullscreen) ViewMode.FULLSCREEN else ViewMode.DEFAULT) } + val awtWindow = LocalAwtWindow.current + val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + // Sync exclusive fullscreen with viewMode and signal parent layouts + LaunchedEffect(viewMode) { + isImmersiveFullscreen.value = viewMode == ViewMode.FULLSCREEN + if (viewMode == ViewMode.FULLSCREEN) { + awtWindow?.let { FullscreenHelper.enterFullscreen(it) } + } else { + if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen() + } + } + + // Restore fullscreen on dismiss + DisposableEffect(Unit) { + onDispose { + isImmersiveFullscreen.value = false + if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen() + } + } + + // Auto-dismiss banner after 3s + LaunchedEffect(downloadState) { + if (downloadState is DownloadState.Done || downloadState is DownloadState.Failed) { + delay(3000) + downloadState = DownloadState.Idle + } + } + + val currentUrl = urls[currentIndex] + val isVideo = RichTextParser.isVideoUrl(currentUrl) + + fun triggerSave() { + if (downloadState is DownloadState.Downloading) return + val url = urls[currentIndex] + val filename = url.substringAfterLast('/').substringBefore('?').ifBlank { "media" } + downloadState = DownloadState.Downloading(progress = -1f, filename = filename) + scope.launch { + val result = + SaveMediaAction.saveMedia( + url = url, + onProgress = { downloaded, total -> + val progress = if (total > 0) downloaded.toFloat() / total else -1f + downloadState = DownloadState.Downloading(progress = progress, filename = filename) + }, + ) + downloadState = + if (result != null) { + DownloadState.Done(result) + } else { + DownloadState.Failed("Download failed") + } + } + } + + fun toggleFullscreen() { + viewMode = + if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN + } + + // Content modifier based on view mode + val contentModifier = + if (viewMode == ViewMode.DEFAULT) { + Modifier.fillMaxSize().padding(48.dp) + } else { + Modifier.fillMaxSize() + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(if (viewMode == ViewMode.FULLSCREEN) Color.Black else Color.Black.copy(alpha = 0.9f)) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Escape -> { + if (viewMode == ViewMode.FULLSCREEN) { + viewMode = ViewMode.DEFAULT + } else { + onDismiss() + } + true + } + + Key.F -> { + toggleFullscreen() + true + } + + Key.DirectionLeft -> { + if (currentIndex > 0) currentIndex-- + true + } + + Key.DirectionRight -> { + if (currentIndex < urls.lastIndex) currentIndex++ + true + } + + Key.S -> { + if (event.isCtrlPressed || event.isMetaPressed) { + triggerSave() + true + } else { + false + } + } + + else -> { + false + } + } + }.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (viewMode != ViewMode.FULLSCREEN) onDismiss() + }, + ) { + // Main content — video or image + if (isVideo) { + Box( + modifier = + contentModifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + // Consume clicks so backdrop dismiss doesn't fire + }, + contentAlignment = Alignment.Center, + ) { + DesktopVideoPlayer( + url = currentUrl, + autoPlay = true, + initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f, + viewMode = viewMode, + onViewModeChange = { newMode -> + viewMode = newMode + }, + modifier = + if (viewMode == ViewMode.DEFAULT) { + Modifier.widthIn(max = 1200.dp) + } else { + Modifier + }, + trailingControls = { + MoreOptionsMenu( + menuExpanded = menuExpanded, + onExpandMenu = { menuExpanded = true }, + onDismissMenu = { menuExpanded = false }, + onSave = { triggerSave() }, + onCopyUrl = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(urls[currentIndex]), null) + }, + onOpenInBrowser = { + Desktop.getDesktop().browse(URI(urls[currentIndex])) + }, + ) + }, + ) + } + } else { + ZoomableImage( + url = currentUrl, + modifier = contentModifier, + ) + } + + // Download banner (top) + AnimatedVisibility( + visible = downloadState !is DownloadState.Idle, + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter), + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + val state = downloadState + Row( + modifier = + Modifier + .fillMaxWidth() + .background( + when (state) { + is DownloadState.Failed -> MaterialTheme.colorScheme.errorContainer + is DownloadState.Done -> Color(0xFF2E7D32) + else -> Color(0xFF1565C0) + }, + ).padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (state) { + is DownloadState.Downloading -> { + Text( + state.filename, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + if (state.progress >= 0f) { + LinearProgressIndicator( + progress = { state.progress }, + modifier = Modifier.width(120.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + } else { + LinearProgressIndicator( + modifier = Modifier.width(120.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + } + } + + is DownloadState.Done -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "Saved to ${state.file.name}", + color = Color.White, + style = MaterialTheme.typography.bodySmall, + ) + } + + is DownloadState.Failed -> { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + state.message, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + + is DownloadState.Idle -> {} + } + } + } + + // "..." menu (bottom right) — only for images; videos get it in the controls bar + // Hidden in fullscreen to keep the view immersive + if (!isVideo && viewMode != ViewMode.FULLSCREEN) { + Box( + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) { + MoreOptionsMenu( + menuExpanded = menuExpanded, + onExpandMenu = { menuExpanded = true }, + onDismissMenu = { menuExpanded = false }, + onSave = { triggerSave() }, + onCopyUrl = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(urls[currentIndex]), null) + }, + onOpenInBrowser = { + Desktop.getDesktop().browse(URI(urls[currentIndex])) + }, + ) + } + } + + // Close button (top-left) — hidden in fullscreen + if (viewMode != ViewMode.FULLSCREEN) { + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.TopStart).padding(8.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + + // Image counter (bottom-center) — hidden in fullscreen + if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) { + Text( + text = "${currentIndex + 1} / ${urls.size}", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + + // Navigation arrows — hidden in fullscreen + if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) { + if (currentIndex > 0) { + IconButton( + onClick = { currentIndex-- }, + modifier = Modifier.align(Alignment.CenterStart).padding(16.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Previous", + tint = Color.White, + modifier = Modifier.size(48.dp), + ) + } + } + + if (currentIndex < urls.lastIndex) { + IconButton( + onClick = { currentIndex++ }, + modifier = Modifier.align(Alignment.CenterEnd).padding(16.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Next", + tint = Color.White, + modifier = Modifier.size(48.dp), + ) + } + } + } + } +} + +@Composable +private fun MoreOptionsMenu( + menuExpanded: Boolean, + onExpandMenu: () -> Unit, + onDismissMenu: () -> Unit, + onSave: () -> Unit, + onCopyUrl: () -> Unit, + onOpenInBrowser: () -> Unit, +) { + Box { + IconButton(onClick = onExpandMenu, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More options", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissMenu, + ) { + DropdownMenuItem( + text = { Text("Save") }, + leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }, + onClick = { + onDismissMenu() + onSave() + }, + ) + DropdownMenuItem( + text = { Text("Copy URL") }, + leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, + onClick = { + onDismissMenu() + onCopyUrl() + }, + ) + DropdownMenuItem( + text = { Text("Open in Browser") }, + leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) }, + onClick = { + onDismissMenu() + onOpenInBrowser() + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt new file mode 100644 index 0000000000..a8713d0470 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import java.io.File + +@Composable +fun MediaAttachmentRow( + attachedFiles: List, + isUploading: Boolean, + onAttach: () -> Unit, + onPaste: () -> Unit, + onRemove: (File) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onAttach) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach media", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onPaste) { + Icon( + Icons.Default.ContentPaste, + contentDescription = "Paste from clipboard", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + if (isUploading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) + Spacer(Modifier.height(4.dp)) + } + + if (attachedFiles.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (file in attachedFiles) { + AttachedFileThumbnail(file = file, onRemove = { onRemove(file) }) + } + } + } + } +} + +@Composable +private fun AttachedFileThumbnail( + file: File, + onRemove: () -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row { + AsyncImage( + model = file, + contentDescription = file.name, + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + IconButton(onClick = onRemove, modifier = Modifier.size(20.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(Modifier.width(4.dp)) + Text( + text = file.name.take(12), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt new file mode 100644 index 0000000000..978ab591a7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import kotlinx.coroutines.launch + +enum class MediaType { AUDIO, VIDEO } + +@Composable +fun NowPlayingBar(modifier: Modifier = Modifier) { + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val audioState by GlobalMediaPlayer.audioState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + + val hasVideo = videoState.url != null + val hasAudio = audioState.url != null + val visible = hasVideo || hasAudio + + // Show video bar if video is active, otherwise audio + val activeState = if (hasVideo) videoState else audioState + val activeType = if (hasVideo) MediaType.VIDEO else MediaType.AUDIO + + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = modifier, + ) { + if (!visible) return@AnimatedVisibility + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Mini video thumbnail or music icon + if (activeType == MediaType.VIDEO && videoFrame != null) { + Image( + bitmap = videoFrame!!, + contentDescription = "Video thumbnail", + modifier = + Modifier + .size(width = 48.dp, height = 36.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + // Play/pause + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.toggleVideoPlayPause() + } else { + GlobalMediaPlayer.toggleAudioPlayPause() + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (activeState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (activeState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(20.dp), + ) + } + + // Current time + Text( + text = formatTime(activeState.currentTime), + style = MaterialTheme.typography.labelSmall, + ) + + // Seek bar + Slider( + value = activeState.position, + onValueChange = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.seekVideo(it) + } else { + GlobalMediaPlayer.seekAudio(it) + } + }, + modifier = Modifier.weight(1f), + colors = + SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + + // Duration + Text( + text = formatTime(activeState.duration), + style = MaterialTheme.typography.labelSmall, + ) + + // URL label (truncated) + Text( + text = activeState.url?.substringAfterLast('/')?.substringBefore('?') ?: "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(120.dp), + ) + + // Volume / Mute + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.toggleVideoMute() + } else { + GlobalMediaPlayer.toggleAudioMute() + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + if (activeState.isMuted) { + Icons.AutoMirrored.Filled.VolumeOff + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = if (activeState.isMuted) "Unmute" else "Mute", + modifier = Modifier.size(16.dp), + ) + } + + Slider( + value = activeState.volume / 100f, + onValueChange = { + val vol = (it * 100).toInt() + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.setVideoVolume(vol) + } else { + GlobalMediaPlayer.setAudioVolume(vol) + } + }, + modifier = Modifier.width(80.dp), + colors = + SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + + // Save button + val scope = rememberCoroutineScope() + IconButton( + onClick = { + activeState.url?.let { url -> + scope.launch { + SaveMediaAction.saveMedia(url = url) + } + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Save, + contentDescription = "Save", + modifier = Modifier.size(16.dp), + ) + } + + // Fullscreen (video only) + if (activeType == MediaType.VIDEO) { + IconButton( + onClick = { GlobalMediaPlayer.toggleFullscreen() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Fullscreen, + contentDescription = "Fullscreen", + modifier = Modifier.size(16.dp), + ) + } + } + + Spacer(Modifier.width(4.dp)) + + // Close/stop + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.stopVideo() + } else { + GlobalMediaPlayer.stopAudio() + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Stop", + modifier = Modifier.size(16.dp), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt new file mode 100644 index 0000000000..ba2159ccca --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +/** + * Displays a kind 20 picture event (NIP-68) with image-first layout. + */ +@Composable +fun PictureDisplay( + event: PictureEvent, + modifier: Modifier = Modifier, + onImageClick: ((List, Int) -> Unit)? = null, +) { + val imetas = remember(event.id) { event.imetaTags() } + val imageUrls = remember(imetas) { imetas.mapNotNull { it.url } } + val title = remember(event.id) { event.title() } + val description = event.content + + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column { + // Images + for ((index, url) in imageUrls.withIndex()) { + val imageModifier = + Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + .clip( + if (index == 0 && title == null && description.isBlank()) { + RoundedCornerShape(8.dp) + } else if (index == 0) { + RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) + } else { + RoundedCornerShape(0.dp) + }, + ).then( + if (onImageClick != null) { + Modifier.clickable { onImageClick(imageUrls, index) } + } else { + Modifier + }, + ) + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = title, + modifier = imageModifier, + contentScale = ContentScale.FillWidth, + ) + } else { + AsyncImage( + model = url, + contentDescription = title, + modifier = imageModifier, + contentScale = ContentScale.FillWidth, + ) + } + } + + // Title + description below images + if (title != null || description.isNotBlank()) { + Column(modifier = Modifier.padding(12.dp)) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + } + + if (description.isNotBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt new file mode 100644 index 0000000000..e29518db6e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.awt.FileDialog +import java.awt.Frame +import java.io.File + +object SaveMediaAction { + private val httpClient = OkHttpClient() + + /** + * Opens a save dialog and downloads the media URL to the chosen file. + * Returns the saved file path or null if cancelled/failed. + */ + suspend fun saveMedia( + url: String, + suggestedFilename: String? = null, + onProgress: ((downloaded: Long, total: Long) -> Unit)? = null, + ): File? { + val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" } + + // FileDialog must be shown on EDT + val file = + withContext(Dispatchers.Main) { + val dialog = + FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply { + this.file = filename + } + dialog.isVisible = true + + val dir = dialog.directory ?: return@withContext null + File(dir, dialog.file ?: return@withContext null) + } ?: return null + + // Download on IO + return withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + response.use { resp -> + if (!resp.isSuccessful) return@withContext null + val total = resp.body.contentLength() + resp.body.byteStream().use { input -> + file.outputStream().use { output -> + val buffer = ByteArray(8192) + var downloaded = 0L + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + downloaded += bytesRead + onProgress?.invoke(downloaded, total) + } + } + } + } + file + } catch (_: Exception) { + null + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt new file mode 100644 index 0000000000..1dbd20837e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp + +@Composable +fun VideoControls( + isPlaying: Boolean, + position: Float, + duration: Long, + currentTime: Long, + onPlayPause: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + isBuffering: Boolean = false, + volume: Int = 100, + isMuted: Boolean = false, + onVolumeChange: ((Int) -> Unit)? = null, + onMuteToggle: (() -> Unit)? = null, + onFullscreen: (() -> Unit)? = null, + viewMode: ViewMode = ViewMode.DEFAULT, + onViewModeChange: ((ViewMode) -> Unit)? = null, + trailingControls: @Composable (() -> Unit)? = null, +) { + var hovering by remember { mutableStateOf(false) } + + Box( + modifier = + modifier + .fillMaxSize() + .clickable { onPlayPause() } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.Enter -> hovering = true + PointerEventType.Exit -> hovering = false + } + } + } + }, + ) { + // Center play/buffering indicator + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(48.dp), + color = Color.White, + strokeWidth = 3.dp, + ) + } else if (!isPlaying) { + // Always show play button when paused + IconButton( + onClick = onPlayPause, + modifier = Modifier.align(Alignment.Center).size(64.dp), + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play", + tint = Color.White, + modifier = Modifier.size(48.dp), + ) + } + } + + // Bottom controls — show on hover + AnimatedVisibility( + visible = hovering, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 8.dp), + ) { + // Seek slider (full width, no horizontal competition) + Slider( + value = position, + onValueChange = onSeek, + modifier = Modifier.fillMaxWidth(), + colors = + SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = Color.White.copy(alpha = 0.3f), + ), + ) + + // Buttons row + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onPlayPause, modifier = Modifier.size(32.dp)) { + Icon( + if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = "${formatTime(currentTime)} / ${formatTime(duration)}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + + // Spacer pushes right-side controls to the end + Box(Modifier.weight(1f)) + + // Volume + if (onMuteToggle != null) { + IconButton(onClick = onMuteToggle, modifier = Modifier.size(32.dp)) { + Icon( + if (isMuted) { + Icons.AutoMirrored.Filled.VolumeOff + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = if (isMuted) "Unmute" else "Mute", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + + if (onVolumeChange != null) { + Slider( + value = volume / 100f, + onValueChange = { onVolumeChange((it * 100).toInt()) }, + modifier = Modifier.width(240.dp), + colors = + SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color.White.copy(alpha = 0.7f), + inactiveTrackColor = Color.White.copy(alpha = 0.3f), + ), + ) + } + + // Fullscreen toggle (lightbox) or simple fullscreen (inline) + if (onViewModeChange != null) { + IconButton( + onClick = { + onViewModeChange( + if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN, + ) + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (viewMode == ViewMode.FULLSCREEN) { + Icons.Default.FullscreenExit + } else { + Icons.Default.Fullscreen + }, + contentDescription = + if (viewMode == ViewMode.FULLSCREEN) "Exit fullscreen" else "Fullscreen", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } else if (onFullscreen != null) { + IconButton(onClick = onFullscreen, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Fullscreen, + contentDescription = "Fullscreen", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + + // Trailing controls (e.g. more-options menu) + trailingControls?.invoke() + } + } + } + } +} + +internal fun formatTime(millis: Long): String { + val totalSeconds = millis / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt new file mode 100644 index 0000000000..9813b0807f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ZoomableImage( + url: String, + modifier: Modifier = Modifier, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + Box( + modifier = + modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scale = 1f + offsetX = 0f + offsetY = 0f + }, + onTap = { + // Consume single taps so they don't propagate to backdrop + }, + ) + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Scroll) { + val scrollDelta = + event.changes + .firstOrNull() + ?.scrollDelta + ?.y ?: 0f + val zoomFactor = if (scrollDelta > 0) 0.9f else 1.1f + scale = (scale * zoomFactor).coerceIn(0.5f, 10f) + event.changes.forEach { it.consume() } + } + } + } + }.pointerInput(Unit) { + detectDragGestures { _, dragAmount -> + if (scale > 1f) { + offsetX += dragAmount.x + offsetY += dragAmount.y + } + } + }, + contentAlignment = Alignment.Center, + ) { + val imageModifier = + Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ) + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = null, + modifier = imageModifier, + contentScale = ContentScale.Fit, + ) + } else { + AsyncImage( + model = url, + contentDescription = null, + modifier = imageModifier, + contentScale = ContentScale.Fit, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index aca0f12d92..44417e19ad 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -22,13 +22,16 @@ package com.vitorpamplona.amethyst.desktop.ui.note 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -38,16 +41,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage +import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer +import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer +import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState +import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a") /** * Data class for displaying a note card. @@ -69,10 +91,66 @@ data class NoteDisplayData( fun NoteCard( note: NoteDisplayData, modifier: Modifier = Modifier, + localCache: DesktopLocalCache? = null, onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, + onMentionClick: ((String) -> Unit)? = null, + onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } + val imageUrls = + remember(urls) { + urls.withScheme.filter { RichTextParser.isImageUrl(it) } + } + val videoAndAudioUrls = + remember(urls) { + urls.withScheme.filter { RichTextParser.isVideoUrl(it) } + } + val audioUrls = + remember(videoAndAudioUrls) { + videoAndAudioUrls.filter { url -> + val ext = + url + .substringAfterLast('.', "") + .substringBefore('?') + .lowercase() + ext in AUDIO_EXTENSIONS + } + } + val videoUrls = + remember(videoAndAudioUrls, audioUrls) { + videoAndAudioUrls - audioUrls.toSet() + } + val mediaUrls = remember(imageUrls, videoAndAudioUrls) { (imageUrls + videoAndAudioUrls).toSet() } + val strippedContent = + remember(note.content, mediaUrls) { + var text = note.content + for (url in mediaUrls) { + text = text.replace(url, "").trim() + } + text + } + val strippedUrls = + remember(urls, mediaUrls) { + Urls( + withScheme = urls.withScheme - mediaUrls, + withoutScheme = urls.withoutScheme, + emails = urls.emails, + bech32s = urls.bech32s, + relayUrls = urls.relayUrls, + blossomUris = urls.blossomUris, + ) + } + + // Cap media height to half the window so text is never pushed off-screen + val windowState = LocalWindowState.current + val maxMediaHeight = + if (windowState != null) { + (windowState.size.height * 0.5f).coerceAtLeast(200.dp) + } else { + 400.dp + } Card( modifier = modifier.fillMaxWidth(), @@ -80,56 +158,149 @@ fun NoteCard( CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), - onClick = onClick ?: {}, ) { Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + // Header + text area — clickable to navigate to thread + Column( + modifier = + if (onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + }, ) { - // Author with avatar Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = - if (onAuthorClick != null) { - Modifier.clickable { onAuthorClick(note.pubKeyHex) } - } else { - Modifier - }, ) { - UserAvatar( - userHex = note.pubKeyHex, - pictureUrl = note.profilePictureUrl, - size = 32.dp, - contentDescription = "Profile picture of ${note.pubKeyDisplay}", - ) + // Author with avatar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + if (onAuthorClick != null) { + Modifier.clickable { onAuthorClick(note.pubKeyHex) } + } else { + Modifier + }, + ) { + UserAvatar( + userHex = note.pubKeyHex, + pictureUrl = note.profilePictureUrl, + size = 32.dp, + contentDescription = "Profile picture of ${note.pubKeyDisplay}", + ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } + + // Timestamp Text( - text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, + text = note.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Timestamp - Text( - text = note.createdAt.toTimeAgo(withDot = false), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Spacer(Modifier.height(8.dp)) + + if (strippedContent.isNotBlank()) { + RichTextContent( + content = strippedContent, + urls = strippedUrls, + localCache = localCache, + onMentionClick = onMentionClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } // end clickable header+text column + + // Inline images + if (imageUrls.isNotEmpty()) { + if (strippedContent.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + } + for ((index, url) in imageUrls.withIndex()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = maxMediaHeight) + .clip(RoundedCornerShape(8.dp)) + .then( + if (onImageClick != null) { + Modifier.clickable { onImageClick(imageUrls, index) } + } else { + Modifier + }, + ), + ) { + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit, + ) + } else { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit, + ) + } + } + if (url != imageUrls.last()) { + Spacer(Modifier.height(4.dp)) + } + } } - Spacer(Modifier.height(8.dp)) + // Inline videos + if (videoUrls.isNotEmpty()) { + if (strippedContent.isNotBlank() || imageUrls.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + } + for ((index, url) in videoUrls.withIndex()) { + DesktopVideoPlayer( + url = url, + modifier = Modifier.fillMaxWidth().heightIn(max = maxMediaHeight), + onFullscreen = + if (onMediaClick != null) { + { seekPos -> onMediaClick(videoUrls, index, seekPos) } + } else { + null + }, + ) + if (url != videoUrls.last()) { + Spacer(Modifier.height(4.dp)) + } + } + } - RichTextContent( - content = note.content, - urls = urls, - modifier = Modifier.fillMaxWidth(), - ) + // Inline audio + if (audioUrls.isNotEmpty()) { + if (strippedContent.isNotBlank() || imageUrls.isNotEmpty() || videoUrls.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + } + for (url in audioUrls) { + AudioPlayer( + url = url, + modifier = Modifier.fillMaxWidth(), + ) + if (url != audioUrls.last()) { + Spacer(Modifier.height(4.dp)) + } + } + } Spacer(Modifier.height(8.dp)) @@ -148,52 +319,152 @@ fun NoteCard( } /** - * Renders text content with highlighted URLs. - * Uses RichTextParser from commons to detect and highlight links. + * Resolved bech32 mention with display text and optional pubkey for click navigation. + */ +private data class ResolvedMention( + val displayText: String, + val pubKeyHex: String? = null, +) + +/** + * Resolves a nostr: bech32 reference to a display string and optional pubkey. + * For npub/nprofile → @displayName + pubkey hex for navigation. + * For note/nevent → truncated note ID. + */ +private fun resolveBech32( + bech32: String, + localCache: DesktopLocalCache?, +): ResolvedMention { + val parsed = Nip19Parser.uriToRoute(bech32) ?: return ResolvedMention(bech32) + return when (val entity = parsed.entity) { + is NPub -> { + val user = localCache?.getUserIfExists(entity.hex) + ResolvedMention( + displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}", + pubKeyHex = entity.hex, + ) + } + + is NProfile -> { + val user = localCache?.getUserIfExists(entity.hex) + ResolvedMention( + displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}", + pubKeyHex = entity.hex, + ) + } + + is NNote -> { + ResolvedMention("note:${entity.hex.take(8)}...") + } + + is NEvent -> { + ResolvedMention("note:${entity.hex.take(8)}...") + } + + else -> { + ResolvedMention(bech32.take(24) + "...") + } + } +} + +/** + * Extracts pubkey hex strings from all npub/nprofile bech32 references in a set. + * Used to trigger metadata loading for mentioned users. + */ +fun extractMentionedPubkeys(bech32s: Set): List = + bech32s.mapNotNull { bech32 -> + val parsed = Nip19Parser.uriToRoute(bech32) ?: return@mapNotNull null + when (val entity = parsed.entity) { + is NPub -> entity.hex + is NProfile -> entity.hex + else -> null + } + } + +/** + * Renders text content with highlighted URLs and clickable nostr: bech32 mentions. + * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color + * and navigate to profile on click. */ @Composable fun RichTextContent( content: String, urls: Urls, + localCache: DesktopLocalCache? = null, + onMentionClick: ((String) -> Unit)? = null, modifier: Modifier = Modifier, - maxLines: Int = 10, ) { - if (urls.withScheme.isEmpty()) { + val defaultColor = MaterialTheme.colorScheme.onSurface + val primaryColor = MaterialTheme.colorScheme.primary + + if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) { Text( text = content, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, + color = defaultColor, modifier = modifier, ) } else { + data class Segment( + val start: Int, + val raw: String, + val isUrl: Boolean, + ) + + val segments = mutableListOf() + for (url in urls.withScheme) { + val idx = content.indexOf(url) + if (idx != -1) segments.add(Segment(idx, url, true)) + } + for (bech32 in urls.bech32s) { + val idx = content.indexOf(bech32) + if (idx != -1) segments.add(Segment(idx, bech32, false)) + } + segments.sortBy { it.start } + val annotatedText = buildAnnotatedString { var lastIndex = 0 - // TODO: User the other urls. - val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) } - for (url in sortedUrls) { - val startIndex = content.indexOf(url, lastIndex) - if (startIndex == -1) continue + for (segment in segments) { + if (segment.start < lastIndex) continue - // Add text before URL - if (startIndex > lastIndex) { - append(content.substring(lastIndex, startIndex)) + // Add text before segment + if (segment.start > lastIndex) { + append(content.substring(lastIndex, segment.start)) } - // Add URL with styling - withStyle( - SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - ) { - append(url) + if (segment.isUrl) { + withStyle( + SpanStyle( + color = primaryColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(segment.raw) + } + } else { + val resolved = resolveBech32(segment.raw, localCache) + if (resolved.pubKeyHex != null && onMentionClick != null) { + val pubKey = resolved.pubKeyHex + withLink( + LinkAnnotation.Clickable( + tag = "mention", + styles = TextLinkStyles(SpanStyle(color = primaryColor)), + ) { + onMentionClick(pubKey) + }, + ) { + append(resolved.displayText) + } + } else { + withStyle(SpanStyle(color = primaryColor)) { + append(resolved.displayText) + } + } } - lastIndex = startIndex + url.length + lastIndex = segment.start + segment.raw.length } // Add remaining text @@ -205,9 +476,7 @@ fun RichTextContent( Text( text = annotatedText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, + color = defaultColor, modifier = modifier, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt new file mode 100644 index 0000000000..f624e286d7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +/** + * Grid gallery view of a user's picture posts (kind 20). + */ +@Composable +fun GalleryTab( + pictureEvents: List, + onImageClick: ((List, Int) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + if (pictureEvents.isEmpty()) { + Box( + modifier = modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No pictures yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + modifier = modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(pictureEvents, key = { it.id }) { event -> + val firstImageUrl = + remember(event.id) { + event.imetaTags().firstNotNullOfOrNull { it.url } + } + + if (firstImageUrl != null) { + GalleryThumbnail( + url = firstImageUrl, + onClick = { + val allUrls = event.imetaTags().mapNotNull { it.url } + onImageClick?.invoke(allUrls, 0) + }, + ) + } + } + } +} + +@Composable +private fun GalleryThumbnail( + url: String, + onClick: () -> Unit, +) { + AsyncImage( + model = url, + contentDescription = null, + modifier = + Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick), + contentScale = ContentScale.Crop, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt new file mode 100644 index 0000000000..4b18918c42 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt @@ -0,0 +1,429 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.search + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.search.ContentPreset +import com.vitorpamplona.amethyst.commons.search.DateUtils +import com.vitorpamplona.amethyst.commons.search.KindRegistry +import com.vitorpamplona.amethyst.commons.search.QueryParser +import com.vitorpamplona.amethyst.commons.search.SearchQuery + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AdvancedSearchPanel( + query: SearchQuery, + onKindsChanged: (List) -> Unit, + onPseudoKindsChanged: (List) -> Unit, + onAuthorAdded: (String) -> Unit, + onAuthorRemoved: (String) -> Unit, + onDateRangeChanged: (Long?, Long?) -> Unit, + onHashtagAdded: (String) -> Unit, + onHashtagRemoved: (String) -> Unit, + onExcludeAdded: (String) -> Unit, + onExcludeRemoved: (String) -> Unit, + onLanguageChanged: (String?) -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Content type presets + Text( + "Content Type", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + KindRegistry.presets.forEach { (name, preset) -> + FilterChip( + selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()), + onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) }, + label = { Text(name) }, + ) + } + } + + // Author field + AuthorInputField( + authors = query.authors.toList() + query.authorNames.toList(), + onAuthorAdded = onAuthorAdded, + onAuthorRemoved = onAuthorRemoved, + ) + + // Date range + DateRangeFields( + since = query.since, + until = query.until, + onChanged = onDateRangeChanged, + ) + + // Hashtags + ChipGroupWithInput( + label = "Tags", + items = query.hashtags.toList(), + prefix = "#", + placeholder = "Add tag...", + onAdd = onHashtagAdded, + onRemove = onHashtagRemoved, + ) + + // Exclude terms + ChipGroupWithInput( + label = "Exclude", + items = query.excludeTerms.toList(), + prefix = "-", + placeholder = "Exclude term...", + onAdd = onExcludeAdded, + onRemove = onExcludeRemoved, + ) + + // Language + LanguageSelector( + selected = query.language, + onChanged = onLanguageChanged, + ) + + // Clear button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = onClear) { + Text("Clear All") + } + } + } + } +} + +private fun togglePreset( + preset: ContentPreset, + query: SearchQuery, + onKindsChanged: (List) -> Unit, + onPseudoKindsChanged: (List) -> Unit, +) { + val pseudo = preset.pseudoKind + if (pseudo != null) { + val current = query.pseudoKinds.toList() + if (pseudo in current) { + onPseudoKindsChanged(current - pseudo) + } else { + onPseudoKindsChanged(current + pseudo) + } + } else { + val current = query.kinds.toList() + if (current.containsAll(preset.kinds)) { + onKindsChanged(current - preset.kinds.toSet()) + } else { + onKindsChanged((current + preset.kinds).distinct()) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AuthorInputField( + authors: List, + onAuthorAdded: (String) -> Unit, + onAuthorRemoved: (String) -> Unit, +) { + Column { + Text( + "Author", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (authors.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + authors.forEach { author -> + AssistChip( + onClick = { onAuthorRemoved(author) }, + label = { + Text( + if (author.length > 16) author.take(8) + "..." + author.takeLast(4) else author, + style = MaterialTheme.typography.bodySmall, + ) + }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp)) + }, + ) + } + } + Spacer(Modifier.height(4.dp)) + } + var authorInput by remember { mutableStateOf("") } + OutlinedTextField( + value = authorInput, + onValueChange = { authorInput = it }, + modifier = + Modifier.fillMaxWidth().onKeyEvent { + if (it.key == Key.Enter && authorInput.isNotBlank()) { + onAuthorAdded(authorInput.trim()) + authorInput = "" + true + } else { + false + } + }, + placeholder = { Text("npub or name...") }, + singleLine = true, + trailingIcon = { + if (authorInput.isNotBlank()) { + IconButton(onClick = { + onAuthorAdded(authorInput.trim()) + authorInput = "" + }) { + Icon(Icons.Default.Add, contentDescription = "Add author") + } + } + }, + ) + } +} + +@Composable +private fun DateRangeFields( + since: Long?, + until: Long?, + onChanged: (Long?, Long?) -> Unit, +) { + // Local text is source of truth while typing. + // Only propagate valid timestamps (or null when cleared). + // Only sync from external when the timestamp changes to something we didn't produce. + var sinceText by remember { mutableStateOf(since?.let { DateUtils.timestampToDate(it) } ?: "") } + var lastSince by remember { mutableStateOf(since) } + if (since != lastSince) { + sinceText = since?.let { DateUtils.timestampToDate(it) } ?: "" + lastSince = since + } + + var untilText by remember { mutableStateOf(until?.let { DateUtils.timestampToDate(it) } ?: "") } + var lastUntil by remember { mutableStateOf(until) } + if (until != lastUntil) { + untilText = until?.let { DateUtils.timestampToDate(it) } ?: "" + lastUntil = until + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Since", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = sinceText, + onValueChange = { + sinceText = it + val ts = QueryParser.parseDateToTimestamp(it) + if (ts != null || it.isBlank()) { + lastSince = ts + onChanged(ts, until) + } + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("YYYY-MM-DD") }, + singleLine = true, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + "Until", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = untilText, + onValueChange = { + untilText = it + val ts = QueryParser.parseDateToTimestamp(it) + if (ts != null || it.isBlank()) { + lastUntil = ts + onChanged(since, ts) + } + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("YYYY-MM-DD") }, + singleLine = true, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipGroupWithInput( + label: String, + items: List, + prefix: String, + placeholder: String, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, +) { + Column { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + AssistChip( + onClick = { onRemove(item) }, + label = { Text("$prefix$item", style = MaterialTheme.typography.bodySmall) }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp)) + }, + ) + } + } + if (items.isNotEmpty()) Spacer(Modifier.height(4.dp)) + var inputText by remember { mutableStateOf("") } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = inputText, + onValueChange = { inputText = it }, + modifier = + Modifier.weight(1f).onKeyEvent { + if (it.key == Key.Enter && inputText.isNotBlank()) { + onAdd(inputText.trim()) + inputText = "" + true + } else { + false + } + }, + placeholder = { Text(placeholder) }, + singleLine = true, + ) + TextButton( + onClick = { + if (inputText.isNotBlank()) { + onAdd(inputText.trim()) + inputText = "" + } + }, + ) { + Text("Add") + } + } + } +} + +@Composable +private fun LanguageSelector( + selected: String?, + onChanged: (String?) -> Unit, +) { + val languages = + listOf( + null to "Any", + "en" to "English", + "es" to "Spanish", + "pt" to "Portuguese", + "ja" to "Japanese", + "zh" to "Chinese", + "de" to "German", + "fr" to "French", + ) + + Column { + Text( + "Language", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + languages.forEach { (code, name) -> + FilterChip( + selected = selected == code, + onClick = { onChanged(code) }, + label = { Text(name) }, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt new file mode 100644 index 0000000000..2a50d2ce84 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.search + +import androidx.compose.animation.core.animateFloatAsState +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.SearchSortOrder +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +@Composable +fun SearchResultsList( + state: AdvancedSearchBarState, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + localCache: DesktopLocalCache? = null, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), +) { + val people by state.sortedPeopleResults.collectAsState() + val notes by state.sortedNoteResults.collectAsState() + val eventSortOrder by state.eventSortOrder.collectAsState() + val peopleSortOrder by state.peopleSortOrder.collectAsState() + + val hasResults = people.isNotEmpty() || notes.isNotEmpty() + + if (!hasResults) return + + // Group notes by kind + val textNotes = notes.filter { it.kind == 1 } + val articles = notes.filter { it.kind == LongTextNoteEvent.KIND } + val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND } + + // Per-section collapsed state (absent = expanded) + val collapsedSections = remember { mutableStateMapOf() } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + // People section + if (people.isNotEmpty()) { + val collapsed = collapsedSections["people"] == true + stickyHeader(key = "header-people") { + SortableHeader( + title = "People", + count = people.size, + icon = Icons.Default.Person, + options = SearchSortOrder.PEOPLE_OPTIONS, + selected = peopleSortOrder, + onSelect = { state.updatePeopleSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["people"] = !collapsed }, + ) + } + if (!collapsed) { + val displayPeople = people.take(5) + items(displayPeople, key = { "person-${it.pubkeyHex}" }) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + if (people.size > 5) { + item(key = "people-expand") { + ExpandableSection( + remaining = people.drop(5), + ) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + } + } + } + } + + // Notes section + if (textNotes.isNotEmpty()) { + if (people.isNotEmpty()) { + item(key = "divider-notes") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + } + val collapsed = collapsedSections["notes"] == true + stickyHeader(key = "header-notes") { + SortableHeader( + title = "Notes", + count = textNotes.size, + icon = Icons.Default.Description, + options = SearchSortOrder.EVENT_OPTIONS, + selected = eventSortOrder, + onSelect = { state.updateEventSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["notes"] = !collapsed }, + ) + } + if (!collapsed) { + val displayNotes = textNotes.take(5) + items(displayNotes, key = { "note-${it.id}" }) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } + if (textNotes.size > 5) { + item(key = "notes-expand") { + ExpandableSection( + remaining = textNotes.drop(5), + ) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } + } + } + } + } + + // Articles section + if (articles.isNotEmpty()) { + if (people.isNotEmpty() || textNotes.isNotEmpty()) { + item(key = "divider-articles") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + } + val collapsed = collapsedSections["articles"] == true + stickyHeader(key = "header-articles") { + SortableHeader( + title = "Articles", + count = articles.size, + icon = Icons.AutoMirrored.Default.Article, + options = SearchSortOrder.EVENT_OPTIONS, + selected = eventSortOrder, + onSelect = { state.updateEventSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["articles"] = !collapsed }, + ) + } + if (!collapsed) { + items(articles.take(5), key = { "article-${it.id}" }) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } + if (articles.size > 5) { + item(key = "articles-expand") { + ExpandableSection( + remaining = articles.drop(5), + ) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } + } + } + } + } + + // Other section + if (otherNotes.isNotEmpty()) { + item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + val collapsed = collapsedSections["other"] == true + stickyHeader(key = "header-other") { + SortableHeader( + title = "Other", + count = otherNotes.size, + icon = Icons.Default.Forum, + options = SearchSortOrder.EVENT_OPTIONS, + selected = eventSortOrder, + onSelect = { state.updateEventSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["other"] = !collapsed }, + ) + } + if (!collapsed) { + items(otherNotes.take(5), key = { "other-${it.id}" }) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } + } + } + + // Bottom padding + item(key = "bottom-spacer") { Spacer(Modifier.height(16.dp)) } + } +} + +@Composable +private fun SortableHeader( + title: String, + count: Int, + icon: ImageVector, + options: List, + selected: SearchSortOrder, + onSelect: (SearchSortOrder) -> Unit, + collapsed: Boolean = false, + onToggleCollapse: () -> Unit = {}, +) { + val chevronRotation by animateFloatAsState(if (collapsed) -90f else 0f) + + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(onClick = onToggleCollapse).padding(vertical = 4.dp), + ) { + Icon( + Icons.Default.ExpandMore, + contentDescription = if (collapsed) "Expand $title" else "Collapse $title", + modifier = Modifier.size(18.dp).rotate(chevronRotation), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + icon, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp).size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + "$title ($count)", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + Spacer(Modifier.weight(1f)) + if (!collapsed) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + options.forEach { option -> + FilterChip( + selected = option == selected, + onClick = { onSelect(option) }, + label = { + Text( + option.label, + style = MaterialTheme.typography.labelSmall, + ) + }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.height(28.dp), + ) + } + } + } + } + } +} + +@Composable +private fun ExpandableSection( + remaining: List, + content: @Composable (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + if (expanded) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + remaining.forEach { item -> + content(item) + } + } + } else { + TextButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.ExpandMore, contentDescription = null, modifier = Modifier.size(16.dp)) + Text("Show all ${remaining.size} more") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt new file mode 100644 index 0000000000..348dcaf0fe --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.search + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chess.RelaySyncState +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SearchSyncBanner( + relayStates: ImmutableList, + isSearching: Boolean, + modifier: Modifier = Modifier, +) { + val isVisible = isSearching || relayStates.isNotEmpty() + var isExpanded by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = isVisible, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = modifier, + ) { + Column { + // Collapsed summary row + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(horizontal = 4.dp, vertical = 6.dp), + ) { + val responded = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val total = relayStates.size + val totalEvents = relayStates.sumOf { it.eventsReceived } + + Text( + text = + if (total > 0) { + "$responded/$total relays responded \u00B7 $totalEvents events" + } else { + "Connecting to relays..." + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + + // Expanded per-relay details + AnimatedVisibility( + visible = isExpanded && relayStates.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp, + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + ) { + relayStates.forEach { relay -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = relayStatusIcon(relay.status), + contentDescription = null, + tint = relayStatusColor(relay.status), + modifier = Modifier.size(14.dp), + ) + Text( + text = relay.displayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "${relay.eventsReceived} events", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } + } + } + } +} + +private fun relayStatusIcon(status: RelaySyncStatus): ImageVector = + when (status) { + RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty + RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty + RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload + RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle + RelaySyncStatus.FAILED -> Icons.Default.Error + } + +@Composable +private fun relayStatusColor(status: RelaySyncStatus): Color = + when (status) { + RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary + RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary + RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt new file mode 100644 index 0000000000..31f7031e41 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.settings + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +@Composable +fun MediaServerSettings( + initialServers: List = emptyList(), + onServersChanged: (List) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val servers = remember { mutableStateListOf().apply { addAll(initialServers) } } + val serverStatuses = remember { mutableStateMapOf() } + var newServerUrl by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + var isChecking by remember { mutableStateOf(false) } + + // Check health on first load (parallel) + LaunchedEffect(servers.toList()) { + coroutineScope { + for (server in servers) { + if (server !in serverStatuses) { + launch { + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + } + } + } + } + + Column(modifier = modifier.fillMaxWidth().padding(16.dp)) { + Text( + "Media Servers (Blossom)", + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Configure Blossom servers for media uploads. First server is the default.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + // Server list + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (server in servers.toList()) { + ServerRow( + server = server, + status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN, + isDefault = servers.indexOf(server) == 0, + onSetDefault = { + servers.remove(server) + servers.add(0, server) + onServersChanged(servers.toList()) + }, + onRemove = { + servers.remove(server) + serverStatuses.remove(server) + onServersChanged(servers.toList()) + }, + onRefresh = { + scope.launch { + serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Add server + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newServerUrl, + onValueChange = { newServerUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("https://blossom.example.com") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = { + val url = newServerUrl.trim().removeSuffix("/") + if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) { + servers.add(url) + newServerUrl = "" + onServersChanged(servers.toList()) + scope.launch { + val status = ServerHealthCheck.check(url) + serverStatuses[url] = status + } + } + }, + enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()), + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + Spacer(Modifier.width(4.dp)) + Text("Add") + } + } + + Spacer(Modifier.height(8.dp)) + + // Refresh all + Button( + onClick = { + scope.launch { + isChecking = true + coroutineScope { + for (server in servers) { + launch { + serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + } + } + isChecking = false + } + }, + enabled = !isChecking, + ) { + if (isChecking) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + } else { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + Spacer(Modifier.width(4.dp)) + Text("Check All") + } + } +} + +private fun isValidServerUrl(url: String): Boolean { + val trimmed = url.trim().removeSuffix("/") + return try { + val uri = java.net.URI(trimmed) + uri.scheme in listOf("https", "http") && uri.host != null && uri.host.contains(".") + } catch (_: Exception) { + false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ServerRow( + server: String, + status: ServerHealthCheck.ServerStatus, + isDefault: Boolean, + onSetDefault: () -> Unit, + onRemove: () -> Unit, + onRefresh: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status indicator with tooltip + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + when (status) { + ServerHealthCheck.ServerStatus.ONLINE -> "Online" + ServerHealthCheck.ServerStatus.OFFLINE -> "Offline — server unreachable" + ServerHealthCheck.ServerStatus.UNKNOWN -> "Checking..." + }, + ) + } + }, + state = rememberTooltipState(), + ) { + Surface( + modifier = Modifier.size(12.dp), + shape = CircleShape, + color = + when (status) { + ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) + ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) + ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) + }, + ) {} + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + server, + style = MaterialTheme.typography.bodyMedium, + ) + if (isDefault) { + Text( + "Default server", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + "Set as default", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { onSetDefault() }, + ) + } + } + + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + modifier = Modifier.size(18.dp), + ) + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/resources/icon.icns b/desktopApp/src/jvmMain/resources/icon.icns new file mode 100644 index 0000000000..c46a41b0b0 Binary files /dev/null and b/desktopApp/src/jvmMain/resources/icon.icns differ diff --git a/desktopApp/src/jvmMain/resources/icon.ico b/desktopApp/src/jvmMain/resources/icon.ico new file mode 100644 index 0000000000..61b727bfe5 Binary files /dev/null and b/desktopApp/src/jvmMain/resources/icon.ico differ diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt new file mode 100644 index 0000000000..8921264ecb --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for the AESGCM encryption used by EncryptedMediaService. + * These test the crypto primitives without requiring network access. + */ +class EncryptedMediaServiceTest { + @Test + fun aesgcmEncryptDecryptRoundTrip() { + val plaintext = "Hello, encrypted media!".toByteArray() + val cipher = AESGCM() + + val encrypted = cipher.encrypt(plaintext) + val decrypted = cipher.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun aesgcmEncryptedDataDiffersFromPlaintext() { + val plaintext = "Secret data".toByteArray() + val cipher = AESGCM() + + val encrypted = cipher.encrypt(plaintext) + + assertFalse(plaintext.contentEquals(encrypted)) + assertTrue(encrypted.size > plaintext.size) // Includes auth tag + } + + @Test + fun aesgcmDecryptWithExplicitKeyAndNonce() { + val cipher1 = AESGCM() + val plaintext = "Roundtrip test data".toByteArray() + + val encrypted = cipher1.encrypt(plaintext) + + // Reconstruct cipher with same key and nonce + val cipher2 = AESGCM(cipher1.keyBytes, cipher1.nonce) + val decrypted = cipher2.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun aesgcmKeyAndNonceAreGenerated() { + val cipher = AESGCM() + + assertNotNull(cipher.keyBytes) + assertNotNull(cipher.nonce) + assertTrue(cipher.keyBytes.size == 32) // AES-256 + assertTrue(cipher.nonce.size == 16) + } + + @Test + fun aesgcmDifferentCiphersProduceDifferentOutput() { + val plaintext = "Same message".toByteArray() + val cipher1 = AESGCM() + val cipher2 = AESGCM() + + val encrypted1 = cipher1.encrypt(plaintext) + val encrypted2 = cipher2.encrypt(plaintext) + + // Different keys should produce different ciphertext + assertFalse(encrypted1.contentEquals(encrypted2)) + } + + @Test + fun aesgcmHandlesEmptyData() { + val cipher = AESGCM() + val plaintext = byteArrayOf() + + val encrypted = cipher.encrypt(plaintext) + val decrypted = cipher.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun aesgcmHandlesLargeData() { + val cipher = AESGCM() + // Simulate a small "file" - 64KB + val plaintext = ByteArray(65536) { (it % 256).toByte() } + + val encrypted = cipher.encrypt(plaintext) + val decrypted = cipher.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt new file mode 100644 index 0000000000..bd0a6a205b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ServerHealthCheckTest { + @Test + fun checkOfflineForInvalidUrl() = + runTest { + // Localhost on a random high port should be unreachable + val status = ServerHealthCheck.check("http://127.0.0.1:19999") + assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status) + } + + @Test + fun checkOfflineForMalformedUrl() = + runTest { + val status = ServerHealthCheck.check("not-a-url") + assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status) + } + + @Test + fun checkOfflineForNonexistentHost() = + runTest { + val status = ServerHealthCheck.check("https://this-host-definitely-does-not-exist-12345.example.com") + assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt new file mode 100644 index 0000000000..75b94e6a8b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class DesktopBlossomClientTest { + private fun mockOkHttp( + responseCode: Int, + body: String = "", + headers: Headers = Headers.headersOf(), + ): OkHttpClient { + val requestSlot = slot() + val mockCall = mockk() + val mockClient = mockk() + + every { mockClient.newCall(capture(requestSlot)) } returns mockCall + every { mockCall.execute() } returns + Response + .Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(responseCode) + .message(if (responseCode == 200) "OK" else "Error") + .headers(headers) + .body(body.toResponseBody()) + .build() + + return mockClient + } + + @Test + fun uploadSuccessReturnsResult() = + runTest { + val json = + """{"url":"https://blossom.example.com/abc123.png","sha256":"abc123","size":1024}""" + val client = DesktopBlossomClient(mockOkHttp(200, json)) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1, 2, 3)) + + try { + val result = + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = "Nostr abc", + ) + + assertEquals("https://blossom.example.com/abc123.png", result.url) + assertEquals("abc123", result.sha256) + assertEquals(1024L, result.size) + } finally { + file.delete() + } + } + + @Test + fun uploadFailureThrowsException() = + runTest { + val headers = Headers.headersOf("X-Reason", "File too large") + val client = DesktopBlossomClient(mockOkHttp(413, "", headers)) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1, 2, 3)) + + try { + val ex = + assertFailsWith { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = null, + ) + } + assertTrue(ex.message!!.contains("File too large")) + } finally { + file.delete() + } + } + + @Test + fun uploadFailureUsesStatusCodeWhenNoXReason() = + runTest { + val client = DesktopBlossomClient(mockOkHttp(500)) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1)) + + try { + val ex = + assertFailsWith { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = null, + ) + } + assertTrue(ex.message!!.contains("500")) + } finally { + file.delete() + } + } + + @Test + fun uploadSendsAuthorizationHeader() = + runTest { + val requestSlot = slot() + val mockCall = mockk() + val mockClient = mockk() + + every { mockClient.newCall(capture(requestSlot)) } returns mockCall + every { mockCall.execute() } returns + Response + .Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"url":"https://example.com/hash"}""".toResponseBody()) + .build() + + val client = DesktopBlossomClient(mockClient) + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1)) + + try { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = "Nostr base64token", + ) + + val sentRequest = requestSlot.captured + assertEquals("Nostr base64token", sentRequest.header("Authorization")) + assertEquals("https://blossom.example.com/upload", sentRequest.url.toString()) + assertEquals("PUT", sentRequest.method) + } finally { + file.delete() + } + } + + @Test + fun uploadUrlStripsTrailingSlash() = + runTest { + val requestSlot = slot() + val mockCall = mockk() + val mockClient = mockk() + + every { mockClient.newCall(capture(requestSlot)) } returns mockCall + every { mockCall.execute() } returns + Response + .Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"url":"https://example.com/hash"}""".toResponseBody()) + .build() + + val client = DesktopBlossomClient(mockClient) + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1)) + + try { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com/", + authHeader = null, + ) + + // Should not have double slash + assertEquals("https://blossom.example.com/upload", requestSlot.captured.url.toString()) + } finally { + file.delete() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt new file mode 100644 index 0000000000..6cef50f6f3 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopMediaCompressorTest { + @Test + fun stripExifReturnsSameFileForPng() { + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "png", file) + + val result = DesktopMediaCompressor.stripExif(file) + + // Should return the same file object since it's not JPEG + assertEquals(file, result) + file.delete() + } + + @Test + fun stripExifReturnsSameFileForTextFile() { + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("not a jpeg") + + val result = DesktopMediaCompressor.stripExif(file) + + assertEquals(file, result) + file.delete() + } + + @Test + fun stripExifReturnsSameFileForMp4() { + val file = File.createTempFile("test_", ".mp4") + file.deleteOnExit() + file.writeBytes(byteArrayOf(0, 0, 0)) + + val result = DesktopMediaCompressor.stripExif(file) + + assertEquals(file, result) + file.delete() + } + + @Test + fun stripExifHandlesJpegWithoutExif() { + // Create a minimal JPEG without EXIF + val file = createMinimalJpeg() + try { + val result = DesktopMediaCompressor.stripExif(file) + // Should return the same file since there's no EXIF to strip + assertEquals(file, result) + } finally { + file.delete() + } + } + + @Test + fun stripExifProcessesJpegFile() { + // Create a JPEG (may or may not have metadata depending on ImageIO) + val file = File.createTempFile("test_", ".jpg") + file.deleteOnExit() + val img = BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "jpg", file) + + val result = DesktopMediaCompressor.stripExif(file) + + // Result should be a valid file regardless + assertTrue(result.exists()) + assertTrue(result.length() > 0) + + // Clean up temp file if different from original + if (result != file) { + result.delete() + } + file.delete() + } + + @Test + fun stripExifHandlesUppercaseJpeg() { + val file = File.createTempFile("test_", ".JPEG") + file.deleteOnExit() + val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "jpg", file) + + val result = DesktopMediaCompressor.stripExif(file) + + assertTrue(result.exists()) + if (result != file) result.delete() + file.delete() + } + + private fun createMinimalJpeg(): File { + val file = File.createTempFile("minimal_", ".jpg") + file.deleteOnExit() + val img = BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "jpg", file) + return file + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt new file mode 100644 index 0000000000..2bb0f40160 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopMediaMetadataTest { + // --- guessMimeType --- + + @Test + fun guessMimeTypeForJpeg() { + assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpg"))) + assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpeg"))) + assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.JPEG"))) + } + + @Test + fun guessMimeTypeForPng() { + assertEquals("image/png", DesktopMediaMetadata.guessMimeType(File("image.png"))) + } + + @Test + fun guessMimeTypeForGif() { + assertEquals("image/gif", DesktopMediaMetadata.guessMimeType(File("anim.gif"))) + } + + @Test + fun guessMimeTypeForWebp() { + assertEquals("image/webp", DesktopMediaMetadata.guessMimeType(File("image.webp"))) + } + + @Test + fun guessMimeTypeForSvg() { + assertEquals("image/svg+xml", DesktopMediaMetadata.guessMimeType(File("icon.svg"))) + } + + @Test + fun guessMimeTypeForAvif() { + assertEquals("image/avif", DesktopMediaMetadata.guessMimeType(File("photo.avif"))) + } + + @Test + fun guessMimeTypeForVideoFormats() { + assertEquals("video/mp4", DesktopMediaMetadata.guessMimeType(File("clip.mp4"))) + assertEquals("video/webm", DesktopMediaMetadata.guessMimeType(File("clip.webm"))) + assertEquals("video/quicktime", DesktopMediaMetadata.guessMimeType(File("clip.mov"))) + } + + @Test + fun guessMimeTypeForAudioFormats() { + assertEquals("audio/mpeg", DesktopMediaMetadata.guessMimeType(File("song.mp3"))) + assertEquals("audio/ogg", DesktopMediaMetadata.guessMimeType(File("track.ogg"))) + assertEquals("audio/wav", DesktopMediaMetadata.guessMimeType(File("sound.wav"))) + assertEquals("audio/flac", DesktopMediaMetadata.guessMimeType(File("lossless.flac"))) + } + + @Test + fun guessMimeTypeForUnknownExtension() { + assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("data.xyz"))) + assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("noext"))) + } + + // --- compute --- + + @Test + fun computeForPngImage() { + val file = createTempPng(width = 10, height = 5) + try { + val meta = DesktopMediaMetadata.compute(file) + + assertEquals("image/png", meta.mimeType) + assertTrue(meta.size > 0) + assertTrue(meta.sha256.length == 64) // hex-encoded SHA-256 + assertEquals(10, meta.width) + assertEquals(5, meta.height) + assertNotNull(meta.blurhash) + } finally { + file.delete() + } + } + + @Test + fun computeForTextFile() { + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("hello world") + try { + val meta = DesktopMediaMetadata.compute(file) + + assertEquals("application/octet-stream", meta.mimeType) + assertEquals(11L, meta.size) + assertTrue(meta.sha256.isNotEmpty()) + assertNull(meta.width) + assertNull(meta.height) + assertNull(meta.blurhash) + } finally { + file.delete() + } + } + + @Test + fun computeProducesConsistentHash() { + val file = File.createTempFile("hash_", ".txt") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + try { + val meta1 = DesktopMediaMetadata.compute(file) + val meta2 = DesktopMediaMetadata.compute(file) + assertEquals(meta1.sha256, meta2.sha256) + } finally { + file.delete() + } + } + + @Test + fun computeForMp4GivesNoDimensions() { + val file = File.createTempFile("video_", ".mp4") + file.deleteOnExit() + file.writeBytes(byteArrayOf(0, 0, 0)) + try { + val meta = DesktopMediaMetadata.compute(file) + assertEquals("video/mp4", meta.mimeType) + assertNull(meta.width) + assertNull(meta.height) + assertNull(meta.blurhash) + } finally { + file.delete() + } + } + + private fun createTempPng( + width: Int, + height: Int, + ): File { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + // Draw something so blurhash has data + val g = img.createGraphics() + g.color = java.awt.Color.BLUE + g.fillRect(0, 0, width, height) + g.dispose() + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + ImageIO.write(img, "png", file) + return file + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt new file mode 100644 index 0000000000..b2ba3f3ba2 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopUploadOrchestratorTest { + @BeforeTest + fun setup() { + mockkObject(DesktopBlossomAuth) + coEvery { + DesktopBlossomAuth.createUploadAuth(any(), any(), any(), any()) + } returns "Nostr fakeAuthToken" + } + + @AfterTest + fun teardown() { + unmockkObject(DesktopBlossomAuth) + } + + @Test + fun uploadCallsClientWithCorrectParameters() = + runTest { + val mockClient = mockk() + val fileSlot = slot() + val contentTypeSlot = slot() + val urlSlot = slot() + + coEvery { + mockClient.upload( + file = capture(fileSlot), + contentType = capture(contentTypeSlot), + serverBaseUrl = capture(urlSlot), + authHeader = any(), + ) + } returns + BlossomUploadResult( + url = "https://blossom.example.com/abc123.png", + sha256 = "abc123", + size = 100, + ) + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + val img = java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB) + javax.imageio.ImageIO.write(img, "png", file) + + val mockSigner = mockk(relaxed = true) + + try { + val result = + orchestrator.upload( + file = file, + alt = "test upload", + serverBaseUrl = "https://blossom.example.com", + signer = mockSigner, + stripExif = false, + ) + + coVerify(exactly = 1) { + mockClient.upload( + file = any(), + contentType = any(), + serverBaseUrl = any(), + authHeader = any(), + ) + } + + assertEquals("https://blossom.example.com", urlSlot.captured) + assertEquals("image/png", contentTypeSlot.captured) + assertNotNull(result.metadata) + assertEquals("image/png", result.metadata.mimeType) + } finally { + file.delete() + } + } + + @Test + fun uploadPassesSameFileWhenNoStripExif() = + runTest { + val mockClient = mockk() + val fileSlot = slot() + + coEvery { + mockClient.upload( + file = capture(fileSlot), + contentType = any(), + serverBaseUrl = any(), + authHeader = any(), + ) + } returns BlossomUploadResult(url = "https://example.com/hash") + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("content") + + val mockSigner = mockk(relaxed = true) + + try { + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = "https://example.com", + signer = mockSigner, + stripExif = false, + ) + + assertEquals(file.absolutePath, fileSlot.captured.absolutePath) + } finally { + file.delete() + } + } + + @Test + fun uploadComputesMetadata() = + runTest { + val mockClient = mockk() + + coEvery { + mockClient.upload(any(), any(), any(), any()) + } returns BlossomUploadResult(url = "https://example.com/hash") + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("hello world") + + val mockSigner = mockk(relaxed = true) + + try { + val result = + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = "https://example.com", + signer = mockSigner, + stripExif = false, + ) + + assertEquals(11L, result.metadata.size) + assertTrue(result.metadata.sha256.length == 64) + assertEquals("application/octet-stream", result.metadata.mimeType) + } finally { + file.delete() + } + } + + @Test + fun uploadPassesAuthHeaderToClient() = + runTest { + val mockClient = mockk() + val authSlot = slot() + + coEvery { + mockClient.upload( + file = any(), + contentType = any(), + serverBaseUrl = any(), + authHeader = captureNullable(authSlot), + ) + } returns BlossomUploadResult(url = "https://example.com/hash") + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("data") + + val mockSigner = mockk(relaxed = true) + + try { + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = "https://example.com", + signer = mockSigner, + stripExif = false, + ) + + assertEquals("Nostr fakeAuthToken", authSlot.captured) + } finally { + file.delete() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt new file mode 100644 index 0000000000..085f96046c --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopUploadTrackerTest { + @Test + fun initialStateIsIdle() { + val tracker = DesktopUploadTracker() + val state = tracker.state.value + + assertFalse(state.isUploading) + assertNull(state.fileName) + assertNull(state.error) + assertNull(state.result) + } + + @Test + fun startUploadSetsUploadingState() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("photo.jpg") + + val state = tracker.state.value + assertTrue(state.isUploading) + assertEquals("photo.jpg", state.fileName) + assertNull(state.error) + assertNull(state.result) + } + + @Test + fun onSuccessStoresResult() { + val tracker = DesktopUploadTracker() + val metadata = MediaMetadata(sha256 = "abc123", size = 1024, mimeType = "image/png") + val blossom = BlossomUploadResult(url = "https://blossom.example.com/abc123.png") + val result = UploadResult(blossom = blossom, metadata = metadata) + + tracker.startUpload("test.png") + tracker.onSuccess(result) + + val state = tracker.state.value + assertFalse(state.isUploading) + assertNotNull(state.result) + assertEquals("https://blossom.example.com/abc123.png", state.result!!.blossom.url) + assertNull(state.error) + } + + @Test + fun onErrorStoresErrorMessage() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("test.png") + tracker.onError("Connection refused") + + val state = tracker.state.value + assertFalse(state.isUploading) + assertEquals("Connection refused", state.error) + assertNull(state.result) + } + + @Test + fun resetReturnsToInitialState() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("test.png") + tracker.onError("failed") + tracker.reset() + + val state = tracker.state.value + assertFalse(state.isUploading) + assertNull(state.fileName) + assertNull(state.error) + assertNull(state.result) + } + + @Test + fun stateFlowEmitsLatestValue() = + runTest { + val tracker = DesktopUploadTracker() + + // Initial emission + val initial = tracker.state.first() + assertFalse(initial.isUploading) + + tracker.startUpload("file.mp4") + val uploading = tracker.state.first() + assertTrue(uploading.isUploading) + assertEquals("file.mp4", uploading.fileName) + } + + @Test + fun multipleUploadsOverwriteState() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("first.jpg") + tracker.startUpload("second.png") + + assertEquals("second.png", tracker.state.value.fileName) + } +} diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000000..979b4c9996 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,14 @@ +# TODO + +## DM: Mixed NIP-04 + NIP-17 conversations + +**Question:** What happens when a conversation with an npub has both NIP-04 (kind 4) and NIP-17 (kind 14/15 in GiftWrap) messages? + +**Current behavior to investigate:** +- NIP-04 messages use `PrivateDmEvent.chatroomKey(pubKey)` → creates a `ChatroomKey` based on recipient +- NIP-17 messages use `ChatMessageEvent.chatroomKey(pubKey)` → also creates a `ChatroomKey` based on group members +- Do these produce the **same** `ChatroomKey` for 1-on-1 chats? If yes, both message types merge into one conversation. If no, they appear as separate conversations. +- Android Amethyst handles this — check how `Account.kt` merges them +- Edge cases: timeline ordering, decryption display, NIP-04 messages showing as "legacy" vs NIP-17 + +**Action:** Test with a real conversation that has both types. If they split into two rooms, we need to merge them in `ChatroomListState` or unify the `ChatroomKey` derivation. diff --git a/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md b/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md new file mode 100644 index 0000000000..609a0fdfb6 --- /dev/null +++ b/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md @@ -0,0 +1,230 @@ +# Brainstorm: Advanced Search for Desktop + +**Date:** 2026-03-10 +**Status:** Draft +**Branch:** TBD (`feat/desktop-advanced-search`) + +## What We're Building + +Full-featured advanced search for Amethyst Desktop with: +1. **Twitter-style query operators** (`from:`, `kind:`, `since:`, etc.) that map to NIP-50 Filter fields +2. **Form-based UI** (expandable panel below search bar) for users who don't want to learn syntax +3. **Bidirectional sync** between text operators and form controls — editing one updates the other +4. **Extensible kind presets** — toggle groups like Notes, Articles, Media, Communities +5. **Future AI bridge** (follow-up) — natural language → structured query conversion + +**Philosophy:** Relay-first, pragmatic. Desktop users have resources; we prioritize completeness over privacy. Privacy controls available but not default friction. + +## Why This Approach + +- **No standard Nostr query language exists** — we define one that maps cleanly to `Filter` fields +- **Dual interface (text + form)** — power users get speed, casual users get discoverability +- **Current desktop search is minimal** — only kind 0 + 1, no operators, no kind filtering +- **Android has 30+ kinds** but no query language — desktop leapfrogs with both +- **AI deferred** — core query system must work standalone first; AI is a parsing layer on top + +## How Other Clients Do Search + +| Client | Approach | Operators | Notable | +|--------|----------|-----------|---------| +| **Amethyst Android** | Local cache + NIP-50, 30+ kinds | None (plain text) | Search relay list (kind 10007) | +| **Primal** | Proprietary caching server | Form UI only | Event type, time range, scope dropdowns | +| **Coracle** | NIP-50 + configurable relays | None | Also supports DVM requests (NIP-90) | +| **Damus** | NIP-50 relay search | None | User/profile focused | +| **noStrudel** | Local relay + NIP-50 | None | IndexedDB local indexing | +| **Gossip** | NIP-50 relay search | None | Desktop Rust client | +| **Noogle.lol** | NIP-90 DVM search | `from:npub` `from:me` | Pay-per-query via Lightning | + +**Key insight:** No client ships a query operator language. This is greenfield. + +## Proposed Query Schema + +Operators map directly to `Filter` fields and NIP-50 extensions: + +### Core Operators + +| Operator | Maps To | Example | Notes | +|----------|---------|---------|-------| +| `from:` | `Filter.authors` | `from:npub1abc...` | Resolve names via NIP-05/local cache | +| `kind:` | `Filter.kinds` | `kind:note` or `kind:1` | Named aliases for common kinds | +| `since:` | `Filter.since` | `since:2025-01-01` | ISO 8601 date parsing | +| `until:` | `Filter.until` | `until:2025-06-30` | ISO 8601 date parsing | +| `#` | `Filter.tags["t"]` | `#bitcoin` | Hashtag filter | +| `"exact phrase"` | Quoted in `Filter.search` | `"lightning network"` | Relay-dependent support | +| `-` | Client-side exclusion | `-spam` | Post-filter after relay results | + +### NIP-50 Extension Operators + +| Operator | NIP-50 Extension | Example | +|----------|-----------------|---------| +| `lang:` | `language:xx` | `lang:en` | +| `domain:` | `domain:xx` | `domain:nostr.com` | +| `nsfw:` | `nsfw:xx` | `nsfw:false` | + +### Kind Name Aliases + +| Alias | Kind(s) | Description | +|-------|---------|-------------| +| `note` | 1 | Short text note | +| `article` | 30023 | Long-form content | +| `repost` | 6 | Reposts | +| `reply` | 1 (with `e` tag) | Replies (client-side filter) | +| `media` | 1 (with `imeta` tag) | Notes with media | +| `channel` | 40, 41, 42 | Public channels | +| `live` | 30311 | Live activities | +| `community` | 34550 | Communities | +| `wiki` | 30818 | Wiki pages | +| `video` | 34235 | Video events | +| `classified` | 30402 | Classifieds | +| `profile` | 0 | Metadata/profiles | + +### Boolean Logic + +| Syntax | Behavior | +|--------|----------| +| `bitcoin lightning` | AND (default) | +| `bitcoin OR lightning` | OR — requires multiple relay queries | +| `-spam` | NOT — client-side exclusion | + +## UX Design + +### Search Bar (Default State) +``` +[ Search notes, people, tags... ] [Advanced v] +``` + +### Expanded Advanced Panel +``` +[ from:npub1abc kind:note since:2025-01 bitcoin ] [Advanced ^] ++------------------------------------------------------------------+ +| Content Type: [x] Notes [ ] Articles [ ] Media [ ] All | +| Author: [ npub or name... ] [+ Add] | +| Date Range: [ 2025-01-01 ] to [ today ] | +| Language: [ Any v ] | +| Hashtags: [ #bitcoin ] [+ Add] | +| Exclude: [ spam, nsfw... ] [+ Add] | +| | +| [Clear Filters] [Search] | ++------------------------------------------------------------------+ +``` + +### Bidirectional Sync +- Typing `from:npub1abc` in search bar → Author field populates in panel +- Selecting "Articles" checkbox → `kind:article` appears in search bar +- Editing either side updates the other in real-time +- Form is the "visual representation" of the query string + +### Result Display +- Results grouped by type: People, Notes, Articles, Channels +- Each result shows: content preview, author, timestamp, kind badge +- Infinite scroll with "Load more from relays" button +- Sort: Relevance (default, relay-determined) or Chronological + +## Architecture + +### Query Pipeline + +``` +User Input (text or form) + | + v +QueryParser (commons/commonMain) + |-- Tokenize operators: from:, kind:, since:, etc. + |-- Resolve names → hex pubkeys (local cache + NIP-05) + |-- Parse dates → unix timestamps + |-- Map kind aliases → kind numbers + | + v +SearchQuery (data class in commons) + |-- text: String (free text for NIP-50 search field) + |-- authors: List + |-- kinds: List + |-- since: Long? + |-- until: Long? + |-- tags: Map> + |-- excludeTerms: List + |-- language: String? + |-- nip50Extensions: Map + | + v +FilterBuilder (desktop) + |-- Convert SearchQuery → List + |-- Split by kind groups (like Android: 3 filters, ~10 kinds each) + |-- Inject NIP-50 extensions into search string + | + v +Relay Subscription + |-- Use search relay list (kind 10007) or connected relays + |-- Send REQ with filters + |-- Aggregate results + | + v +Client-side Post-filter + |-- Apply exclusions (-term) + |-- Apply "reply" detection (has e tag) + |-- Apply "media" detection (has imeta tag) + | + v +Results Display +``` + +### Module Placement + +| Component | Module | Rationale | +|-----------|--------|-----------| +| `QueryParser` | `commons/commonMain` | Reusable for Android later | +| `SearchQuery` | `commons/commonMain` | Shared data model | +| `QuerySerializer` | `commons/commonMain` | SearchQuery ↔ string conversion | +| `AdvancedSearchPanel` | `desktopApp` | Desktop-specific UI | +| `SearchScreen` (updated) | `desktopApp` | Desktop layout | +| `FilterBuilder` (updated) | `desktopApp` | Desktop filter assembly | +| Kind alias registry | `commons/commonMain` | Shared kind name mapping | + +### Search Relay Management + +- Use existing `SearchRelayListEvent` (kind 10007) from quartz +- Desktop UI to configure search relays (settings page) +- Default fallback: `relay.nostr.band`, `nostr.wine`, `relay.damus.io` +- Future: auto-discover NIP-50 capable relays via NIP-11 + +## Privacy Considerations + +| Concern | Mitigation | Default | +|---------|-----------|---------| +| Relay sees search queries | User-configurable search relay list | On (kind 10007) | +| Relay sees IP + query | VPN/Tor support (system-level) | Not enforced | +| Query history stored | No server-side history; client-side optional | Off | +| NIP-05 resolution leaks interest | Cache NIP-05 lookups locally | On | +| Author search reveals social graph | Already visible via follow lists | N/A | + +**Stance:** Relay-first, pragmatic. Desktop users accept relay visibility for better results. Advanced users can configure search relays or use Tor. + +## Key Decisions + +1. **Query language is NOT a Nostr standard** — it's a client-side UX convention that maps to `Filter` fields +2. **Bidirectional sync** between text bar and form panel — single source of truth (`SearchQuery` data class) +3. **Kind presets with extensibility** — start with core groups, users can toggle individual kinds +4. **Relay-first execution** — NIP-50 search as primary, local cache as supplement +5. **AI deferred** — follow-up feature, will parse natural language → `SearchQuery` +6. **Query parser in commons** — shared module so Android can adopt later +7. **Client-side post-filtering** for operators relays can't handle (exclusions, reply detection, media detection) + +## Resolved Questions + +1. **Name resolution** — Async + refine. Search immediately with text, resolve `from:name` in background, refine results when pubkey resolved. No blocking. +2. **OR queries** — Yes in v1. `bitcoin OR lightning` sends parallel relay subscriptions, merges results. +3. **Search history** — Recent history (last 20) stored locally. +4. **Saved searches** — Yes in v1. Pin/save queries. Future: persist as Nostr events. +5. **Result caching** — Session cache only (in-memory). Cleared on app restart. No disk persistence. + +## Open Questions + +None — all resolved. + +## Follow-up Features (Out of Scope) + +- AI natural language → query parsing (local Ollama / cloud API / NIP-90 DVM) +- Local SQLite FTS5 index for offline search +- NIP-90 DVM search integration +- Search analytics / trending topics +- Collaborative search (shared saved searches via Nostr events) diff --git a/docs/brainstorms/2026-03-16-blossom-protocol-research.md b/docs/brainstorms/2026-03-16-blossom-protocol-research.md new file mode 100644 index 0000000000..1030342688 --- /dev/null +++ b/docs/brainstorms/2026-03-16-blossom-protocol-research.md @@ -0,0 +1,604 @@ +# Blossom Protocol Research + +**Date**: 2026-03-16 +**Sources**: hzrd149/blossom GitHub (BUD specs), NIP-B7, Nostrify docs, Amethyst upstream codebase, Primal blog posts + +--- + +## Overview + +Blossom (**Bl**obs **O**n **S**imple **S**erver**om**... or something) is a specification for HTTP endpoints that let users store binary blobs on publicly accessible servers. Blobs are content-addressed by their **SHA-256 hash**. Uses Nostr keypairs for identity and authorization. + +**Two Nostr event kinds:** +- **Kind 24242** -- Authorization token (BUD-11) +- **Kind 10063** -- User's Blossom server list (BUD-03, NIP-B7) + +**BUD index (BUD-00 through BUD-11):** + +| BUD | Name | Status | Required | +|-----|------|--------|----------| +| 00 | BUD framework | - | - | +| 01 | Server requirements + blob retrieval | draft | mandatory | +| 02 | Upload + management | draft | optional | +| 03 | User server list (kind 10063) | draft | optional | +| 04 | Mirroring | draft | optional | +| 05 | Media optimization | draft | optional | +| 06 | Upload requirements (HEAD preflight) | draft | optional | +| 07 | Payment required (402) | draft | optional | +| 08 | NIP-94 file metadata tags | draft | optional | +| 09 | Blob report | draft | optional | +| 10 | Blossom URI scheme | draft | optional | +| 11 | Nostr authorization | draft | optional | + +--- + +## BUD-01: Server Requirements + Blob Retrieval + +**Status:** `draft` `mandatory` + +### CORS + +All responses MUST set `Access-Control-Allow-Origin: *`. + +Preflight (`OPTIONS`) responses MUST also set: +``` +Access-Control-Allow-Headers: Authorization, * +Access-Control-Allow-Methods: GET, HEAD, PUT, DELETE +``` + +MAY set `Access-Control-Max-Age: 86400` (cache 24h). + +### Error Responses + +Any 4xx/5xx response MAY include `X-Reason` header with human-readable error message. + +### Endpoints + +All endpoints served from domain root. No path prefix. + +#### GET / -- Get Blob + +```http +GET /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1 +Host: cdn.example.com +``` + +Response: +```http +HTTP/1.1 200 OK +Content-Type: application/pdf +Content-Length: 184292 + + +``` + +- MUST accept optional file extension in URL (`.pdf`, `.png`, etc.) +- MUST return correct `Content-Type` regardless of extension +- MUST default to `application/octet-stream` if MIME unknown +- MAY require authorization (BUD-11) + +**Proxying/Redirection:** +- 3xx redirects MUST redirect to URL containing same SHA-256 hash +- Destination MUST set `Access-Control-Allow-Origin: *`, `Content-Type`, `Content-Length` + +**Range Requests:** +- Servers SHOULD support `Range` header (RFC 7233) on GET +- Signal via `Accept-Ranges: bytes` and `Content-Length` on HEAD + +#### HEAD / -- Has Blob + +Identical to GET but MUST NOT return body. MUST return same `Content-Type` and `Content-Length` headers. + +--- + +## BUD-02: Upload + Management + +**Status:** `draft` `optional` + +### Blob Descriptor + +The standard JSON response for all upload/mirror operations: + +```json +{ + "url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf", + "sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553", + "size": 184292, + "type": "application/pdf", + "uploaded": 1725105921 +} +``` + +Fields: +- `url` -- Public URL to `GET /` endpoint **with file extension** +- `sha256` -- Hex-encoded SHA-256 of the blob +- `size` -- Size in bytes +- `type` -- MIME type (fallback `application/octet-stream`) +- `uploaded` -- Unix timestamp + +MAY include: `magnet`, `infohash`, `ipfs` + +### PUT /upload -- Upload Blob + +```http +PUT /upload HTTP/1.1 +Host: cdn.example.com +Authorization: Nostr +Content-Type: image/png +Content-Length: 184292 +X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553 + + +``` + +Response (success): +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "url": "https://cdn.example.com/b167...553.png", + "sha256": "b167...553", + "size": 184292, + "type": "image/png", + "uploaded": 1725105921 +} +``` + +Key rules: +- Server MUST NOT modify the blob +- Server MUST compute SHA-256 over exact bytes received +- Client SHOULD include `Content-Type` and `Content-Length` +- Client MAY provide `X-SHA-256` header (hex lowercase) +- Server MAY use `X-SHA-256` for pre-upload rejection policies +- Success: 2xx with Blob Descriptor +- Failure: 4xx with error message + +### GET /list/ -- List Blobs (Unrecommended) + +Optional. Returns JSON array of Blob Descriptors for a pubkey. + +Query params: +- `cursor` -- SHA-256 of last blob (cursor-based pagination) +- `limit` -- Max results +- `since`/`until` -- Filter by upload date (deprecated for pagination) + +Sorted by `uploaded` descending. + +### DELETE / -- Delete Blob + +```http +DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1 +Host: cdn.example.com +Authorization: Nostr +``` + +- Multiple `x` tags in auth token MUST NOT be interpreted as batch delete + +--- + +## BUD-03: User Server List + +**Kind 10063** (replaceable event). + +```json +{ + "kind": 10063, + "tags": [ + ["server", "https://cdn.self.hosted"], + ["server", "https://cdn.satellite.earth"], + ["alt", "File servers used by the author"] + ], + "content": "" +} +``` + +- Tag order = priority. Most trusted/reliable first. +- Clients MUST upload to at least the first server in user's list. +- Clients MAY mirror to other listed servers via BUD-04. + +**Discovery flow when URL breaks:** +1. Extract 64-char hex hash from broken URL +2. Fetch author's kind:10063 event +3. Try each listed server in order +4. Fall back to well-known servers + +--- + +## BUD-04: Mirroring + +**Status:** `draft` `optional` + +### PUT /mirror -- Mirror Blob + +```http +PUT /mirror HTTP/1.1 +Host: backup-server.example.com +Authorization: Nostr +Content-Type: application/json + +{ + "url": "https://cdn.satellite.earth/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf" +} +``` + +Response: Blob Descriptor (same as upload). + +Key rules: +- Server downloads blob from provided URL +- Server SHOULD use `Content-Type` from origin server +- Server verifies downloaded blob hash matches `x` tag in auth token +- Returns 2xx + Blob Descriptor on success, 4xx on failure + +**Typical flow:** +1. Client uploads to Server A, gets Blob Descriptor with URL +2. Client sends URL to Server B's `/mirror` with same upload auth token +3. Server B downloads from Server A +4. Server B verifies hash matches `x` tag +5. Server B returns Blob Descriptor + +--- + +## BUD-05: Media Optimization + +**Status:** `draft` `optional` + +### PUT /media -- Optimized Upload + +```http +PUT /media HTTP/1.1 +Host: trusted-server.example.com +Authorization: Nostr +Content-Type: image/png +Content-Length: 4194304 + + +``` + +Response: Blob Descriptor -- but hash will differ from input because server transforms the file. + +Key differences from `/upload`: +- Server MAY modify/optimize the blob (strip EXIF, compress, transcode) +- The returned SHA-256 will be of the **optimized** blob, not the original +- Client has NO control over optimization process +- `t` tag in auth event must be `media` (not `upload`) + +### HEAD /media + +Same as HEAD /upload (BUD-06) but for the media endpoint. + +### Client Implementation Pattern + +1. User selects a "trusted processing" server +2. Client uploads original media to `/media` on trusted server +3. Gets back optimized blob descriptor (new hash) +4. Client signs new upload auth for the optimized hash +5. Calls `/mirror` on other servers to distribute the optimized blob + +This is what Primal does -- all Primal 2.2+ apps use `/media` by default, strips metadata, then mirrors. + +--- + +## BUD-06: Upload Requirements (HEAD Preflight) + +**Status:** `draft` `optional` + +### HEAD /upload -- Pre-flight Check + +Client sends blob metadata, server says yes/no before actual upload. + +Request: +```http +HEAD /upload HTTP/1.1 +Host: cdn.example.com +X-Content-Type: application/pdf +X-Content-Length: 184292 +X-SHA-256: 88a74d0b866c8ba79251a11fe5ac807839226870e77355f02eaf68b156522576 +Authorization: Nostr +``` + +Success: +```http +HTTP/1.1 200 OK +``` + +Failure examples: +```http +HTTP/1.1 400 Bad Request +X-Reason: Invalid X-SHA-256 header format. Expected a string. + +HTTP/1.1 401 Unauthorized +X-Reason: Authorization required for uploading video files. + +HTTP/1.1 403 Forbidden +X-Reason: SHA-256 hash banned. + +HTTP/1.1 411 Length Required +X-Reason: Missing X-Content-Length header. + +HTTP/1.1 413 Content Too Large +X-Reason: File too large. Max allowed size is 100MB. + +HTTP/1.1 415 Unsupported Media Type +X-Reason: Unsupported file type. +``` + +**Note:** Uses `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers (not standard `Content-*`). + +--- + +## BUD-07: Payment Required + +Servers MAY return `402 Payment Required` with payment method headers: + +```http +HTTP/1.1 402 Payment Required +X-Cashu: "" +X-Lightning: "" +``` + +After payment, client retries with proof: +- Cashu: serialized `cashuB` token per NUT-24 +- Lightning: preimage of the BOLT-11 payment + +HEAD requests inform about cost but should not be retried with payment; proceed to PUT/GET after paying. + +--- + +## BUD-08: NIP-94 File Metadata Tags + +Servers MAY include a `nip94` field in Blob Descriptor responses: + +```json +{ + "url": "https://cdn.example.com/b167...553.pdf", + "sha256": "b167...553", + "size": 184292, + "type": "application/pdf", + "uploaded": 1725105921, + "nip94": [ + ["url", "https://cdn.example.com/b167...553.pdf"], + ["m", "application/pdf"], + ["x", "b167...553"], + ["size", "184292"], + ["magnet", "magnet:?xt=urn:btih:..."], + ["i", "infohash-here"] + ] +} +``` + +Follows NIP-94 tag format as KV pairs. Allows clients to get standardized metadata without separate requests. + +--- + +## BUD-09: Blob Report + +### PUT /report + +Body: signed NIP-56 report event (kind 1984): + +```json +{ + "kind": 1984, + "content": "This blob contains illegal content", + "tags": [ + ["x", "", "illegal"], + ["p", ""] + ] +} +``` + +Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal. + +--- + +## BUD-10: Blossom URI Scheme + +Format: +``` +blossom:.[?param1=value1¶m2=value2...] +``` + +Example: +``` +blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.example.com&xs=backup.example.com&as=&sz=184292 +``` + +Query parameters: +- `xs` -- Server domain hints (tried first). Repeatable. +- `as` -- Author hex pubkey for BUD-03 server list lookup. Repeatable. +- `sz` -- Size in bytes. + +**Resolution priority:** +1. Direct server hints (`xs`) via `GET /` +2. Author server lists (fetch kind:10063 for each `as` pubkey) +3. Fallback to well-known servers or local cache + +--- + +## BUD-11: Authorization + +### Kind 24242 Event Structure + +```json +{ + "id": "", + "pubkey": "", + "created_at": 1725105921, + "kind": 24242, + "tags": [ + ["t", "upload"], + ["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"], + ["expiration", "1725109521"], + ["server", "cdn.example.com"] + ], + "content": "Upload cat photo", + "sig": "" +} +``` + +### Required Tags + +| Tag | Description | +|-----|-------------| +| `t` | Action verb: `get`, `upload`, `list`, `delete`, `media` | +| `expiration` | Unix timestamp when token expires (NIP-40) | + +### Optional Tags + +| Tag | Description | +|-----|-------------| +| `x` | SHA-256 hash of specific blob. Multiple allowed. | +| `server` | Domain restriction (lowercase). Multiple allowed. | +| `size` | File size in bytes (Amethyst adds this) | + +### Authorization Header + +``` +Authorization: Nostr +``` + +Note: Amethyst uses standard Base64 (not Base64url), which works in practice. + +### Endpoint Authorization Requirements + +| Endpoint | `t` tag | Hash source | `x` tag | +|----------|---------|-------------|---------| +| GET/HEAD / | `get` | URL path | optional | +| PUT /upload | `upload` | X-SHA-256 header | required | +| HEAD /upload | `upload` | X-SHA-256 header | required | +| DELETE / | `delete` | URL path | required | +| GET /list/ | `list` | -- | N/A | +| PUT /mirror | `upload` | mirrored blob hash | required | +| PUT /media | `media` | X-SHA-256 header | required | +| HEAD /media | `media` | X-SHA-256 header | required | + +### Validation Checklist (Server) + +1. Event kind == 24242 +2. `created_at` < now +3. `expiration` > now +4. `t` tag matches endpoint action +5. `server` tags (if present) include this server's domain +6. `x` tags (if required) match the blob hash + +### Security Note + +Unscoped tokens (no `server` tag) can be replayed to other servers. Always scope `delete` tokens. + +--- + +## Error Handling -- HTTP Status Codes + +| Code | Meaning | When | +|------|---------|------| +| 200 | Success | GET, HEAD, successful upload/mirror/delete | +| 3xx | Redirect | GET with CDN redirect (must preserve hash in URL) | +| 400 | Bad Request | Invalid headers, malformed auth | +| 401 | Unauthorized | Missing or invalid authorization | +| 402 | Payment Required | BUD-07 paid servers | +| 403 | Forbidden | Hash banned, user blocked | +| 404 | Not Found | Blob doesn't exist | +| 411 | Length Required | Missing Content-Length / X-Content-Length | +| 413 | Content Too Large | File exceeds server limit | +| 415 | Unsupported Media Type | Server doesn't accept this MIME type | + +All error responses MAY include `X-Reason` header. + +--- + +## Popular Blossom Servers + +| Server | Limits | Notes | +|--------|--------|-------| +| `blossom.nostr.build` | 100 MiB hard, 20 MiB free | Run by nostr.build team. Supports BUD-01,02,04,05,06,08 | +| `blossom.band` | 100 MiB hard, 20 MiB free | Community server | +| `blossom.primal.net` | Integrated with Primal stack | Uses /media by default, strips metadata | +| `cdn.satellite.earth` | Unknown | Satellite CDN | +| `blossom.azzamo.net` | Free tier + premium | Azzamo's server | +| `blosstr.com` | Enterprise-grade | Commercial offering | + +Rate limiting is not standardized in the protocol. Each server implements its own policies. Free tiers generally have stricter limits. BUD-06 HEAD preflight is the mechanism for discovering server limitations before uploading. + +--- + +## Client Implementations + +### Amethyst (Kotlin -- upstream) + +**Quartz library** (`quartz/src/commonMain/kotlin/.../nipB7Blossom/`): +- `BlossomAuthorizationEvent` -- Kind 24242 event creation (get, upload, delete, list) +- `BlossomServersEvent` -- Kind 10063 server list management +- `BlossomUploadResult` -- Blob Descriptor deserialization (kotlinx.serialization) +- `BlossomUri` -- BUD-10 URI parsing/serialization + +**Android app** (`amethyst/src/main/java/.../service/uploads/blossom/`): +- `BlossomUploader` -- PUT /upload + DELETE implementation using OkHttp +- `BlossomServerResolver` -- BUD-10 URI resolution with LruCache +- `ServerHeadCache` -- HEAD request caching for blob existence checks +- `UploadOrchestrator` -- Orchestrates NIP-95, NIP-96, and Blossom uploads + +**Upload flow:** +1. Read file, compute SHA-256 hash + size +2. Compute blurhash metadata locally +3. Create kind 24242 auth event (t=upload, x=hash, expiration=+1hr) +4. Base64-encode auth event JSON +5. PUT /upload with `Authorization: Nostr `, `Content-Type`, `Content-Length` +6. Parse Blob Descriptor response +7. Download + verify the uploaded file (re-hash check) + +**Key:** Amethyst does NOT use `/media` endpoint. Uses `/upload` only. No mirroring implemented. + +### Primal + +- All Primal 2.2+ apps use `/media` (BUD-05) by default +- Strips all metadata before saving +- Optionally mirrors to other Blossom servers per user settings +- Deeply integrated into Primal stack, enabled by default + +### Nostrify (TypeScript/Web) + +```typescript +const uploader = new BlossomUploader({ + servers: ['https://blossom.primal.net/'], + signer: window.nostr, + expiresIn: 60, // seconds +}); + +const tags = await uploader.upload(file); +// Returns NIP-94 tags: url, x, ox, size, m +``` + +- `ox` tag = original hash (before server processing) +- `x` tag = final hash (after optimization if /media used) + +### NDK Blossom (@nostr-dev-kit/ndk-blossom) + +npm package wrapping BUD-01 through BUD-06. TypeScript. + +### Dart NDK (dart-nostr.com) + +Has Blossom use case documentation. Flutter/Dart integration. + +--- + +## Key Protocol Design Decisions + +1. **Content addressing via SHA-256** -- Same file = same hash everywhere. Deduplication is free. +2. **Servers are interchangeable** -- Any server with the blob can serve it. URLs break? Find the hash elsewhere. +3. **No server-side processing on /upload** -- Bit-perfect storage. Hash computed over exact bytes received. +4. **/media is the exception** -- Trusted server processes/optimizes. New hash for result. +5. **Authorization is opt-in per endpoint** -- Servers choose what to protect. +6. **User controls server list** -- Kind 10063 event = user's preferred servers. +7. **Mirror for redundancy** -- Upload once, mirror to N servers. + +--- + +## Unanswered Questions + +- Does BUD-11 require base64url (no padding) or standard base64? Spec says base64url, Amethyst uses standard base64 -- servers seem to accept both. +- What's the recommended expiration window for auth tokens? Amethyst uses 1 hour. +- How do clients handle the `/media` flow when the optimized hash differs from original? Need to re-sign auth for mirror requests with the new hash. +- Is there a standard way to discover server capabilities (which BUDs supported)? Not currently -- no capability endpoint defined. +- How to handle upload failures mid-stream for large files? No chunked upload in spec. +- Server-side dedup behavior when same hash uploaded by different users? Implementation-specific. diff --git a/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md new file mode 100644 index 0000000000..7793b10901 --- /dev/null +++ b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md @@ -0,0 +1,468 @@ +# Brainstorm: Desktop Media — Full Parity + +**Date:** 2026-03-16 +**Status:** Draft +**Branch:** TBD (`feat/desktop-media`) + +## What We're Building + +Full media functionality for Amethyst Desktop — display, upload, gallery, lightbox, video playback, encrypted media, and desktop-native UX. Feature parity with Android Amethyst's media stack, adapted for mouse-first desktop interaction. + +### Scope + +| Feature | Included | Notes | +|---------|----------|-------| +| Image display in notes | Yes | Coil3 AsyncImage, blurhash previews | +| Video playback | Yes | VLCJ with bundled libvlc | +| Blossom upload | Yes | Extract to commons, Blossom-only (no NIP-96) | +| Drag-drop / clipboard paste | Yes | Essential desktop UX from day 1 | +| Lightbox / zoom | Yes | Full-screen media viewer with zoom | +| Image gallery carousel | Yes | Multi-image posts | +| Profile gallery (NIP-68) | Yes | Kind 20 picture posts, create + view | +| Video events (NIP-71) | Yes | Kind 21/22 display | +| Encrypted media (NIP-17 DMs) | Yes | Full send + receive | +| Media server management | Yes | Blossom server list (kind 10063) | +| Audio/voice playback | Yes | MP3, OGG, FLAC, WAV | +| Media compression | Yes | Desktop-adapted (Java ImageIO) | +| Blurhash generation on upload | Yes | Already in commons/ | +| EXIF stripping | Yes | Privacy: strip metadata before upload | +| Alt text / accessibility | Yes | NIP-92 imeta alt field | + +### Out of Scope (for now) + +- NIP-96 upload (deprecated, Blossom replaces it) +- Voice recording (microphone capture — desktop-specific, complex) +- Picture-in-picture video +- Live streaming (NIP-53) +- Torrent/magnet distribution + +## Why This Approach + +### Blossom Only (No NIP-96) + +NIP-96 is officially marked "unrecommended: replaced by blossom APIs" in the NIP registry. Building desktop from scratch gives us the opportunity to skip legacy protocol support entirely. + +**Blossom advantages:** +- Content-addressed (SHA-256) — files portable across servers +- Native mirroring (BUD-04) — upload once, mirror to N servers +- Server-side optimization (BUD-05) — `/media` endpoint +- Payment support (BUD-07) — Cashu/Lightning +- Clean URI scheme (BUD-10) — `blossom:.` + +### Extraction Strategy — Layered Architecture + +Android's BlossomUploader is tightly coupled to Android (`Context`, `Uri`, `ContentResolver`). We need to separate the HTTP upload protocol from platform file access. + +**Layer 1: commons/commonMain — Pure Blossom protocol client** +- `BlossomClient` — HTTP PUT /upload, /mirror, /media, DELETE, GET /list. Takes `InputStream` + metadata. Returns `BlobDescriptor`. No platform deps. +- `BlossomAuthHelper` — Creates kind 24242 auth events, base64-encodes for Authorization header +- `BlossomServerDiscovery` — Queries kind 10063, resolves server list, caches +- `MediaUploadResult` — Result data class (already platform-agnostic) +- `UploadOrchestrator` — Coordinates upload to server + optional optimization + mirroring +- `MultiUploadOrchestrator` — Manages parallel upload of multiple files +- `MediaUploadTracker` — StateFlow-based progress tracking +- `ServerHeadCache` — BUD-06 pre-flight response cache + +**Layer 2: commons/commonMain — expect/actual for platform file operations** +``` +expect fun readFileBytes(path: String): ByteArray +expect fun computeFileSha256(path: String): String +expect fun getMimeType(path: String): String? +expect fun getFileSize(path: String): Long +expect class MediaMetadataExtractor { + fun extractDimensions(path: String): Pair? + fun extractBlurhash(path: String): String? +} +expect class MediaCompressor { + fun compressImage(path: String, quality: Float): String + fun stripExif(path: String): String +} +``` + +**Layer 3: Platform actuals** +- `androidMain/` — Uses `ContentResolver`, `Uri`, Android Bitmap, `MediaMetadataRetriever` +- `jvmMain/` — Uses `java.io.File`, Java ImageIO, `metadata-extractor`, BufferedImage→blurhash + +**Layer 4: Platform UI (desktopApp/ and amethyst/)** +- File pickers, drag-drop handlers, video players, lightbox composables + +This design lets any future client (iOS, web) reuse Layer 1 + 2 by providing Layer 3 actuals. + +### Coil3 for Image Loading + +Coil3 officially supports Compose Multiplatform including JVM/Desktop. Android Amethyst already uses Coil3. Benefits: +- Disk + memory caching +- Custom fetchers (Blossom URI, blurhash, base64) +- Crossfade animations +- SVG support + +### VLCJ for Video + +ExoPlayer is Android-only (Media3). VLCJ wraps VLC's libvlc via JNA — supports every format VLC does (mp4, webm, m3u8, mkv, etc.). Compose integration via `SwingPanel` or offscreen rendering. We bundle libvlc with the app (~100MB) for zero user setup. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Upload protocol | Blossom only | NIP-96 deprecated | +| Upload code location | commons/commonMain | Share with Android | +| Image loading | Coil3 | Already used on Android, KMP support | +| Video playback | VLCJ (bundled libvlc) | ExoPlayer is Android-only | +| Desktop input | Drag-drop + paste + file picker | Essential for desktop UX | +| Encrypted media | Full support | DM file sharing parity | +| Profile gallery | Yes (NIP-68 kind 20) | Create + view | + +## Existing Codebase Audit + +### Already Shared (commons/) + +| Component | Location | Status | +|-----------|----------|--------| +| BlurHashDecoder/Encoder | `commons/blurhash/` | Ready | +| PlatformImage (expect/actual) | `commons/blurhash/PlatformImage.kt` | Ready (JVM uses BufferedImage) | +| BitmapUtils (JVM) | `commons/blurhash/BitmapUtils.jvm.kt` | Ready | +| Base64ImagePlatform (JVM) | `commons/base64Image/` | Ready | +| RichTextParser | `commons/richtext/RichTextParser.kt` | Ready (classifies URLs as image/video) | +| MediaContentModels | `commons/richtext/MediaContentModels.kt` | Ready (MediaUrlImage, MediaUrlVideo, etc.) | +| UrlParser | `commons/richtext/UrlParser.kt` | Ready | +| Image extensions list | RichTextParser companion | png, jpg, gif, bmp, jpeg, webp, svg, avif | +| Video extensions list | RichTextParser companion | mp4, avi, wmv, mpg, amv, webm, mov + audio | + +### In Quartz (protocol layer, shared) + +| Component | Location | Status | +|-----------|----------|--------| +| BlossomAuthorizationEvent | `quartz/nipB7Blossom/` | Ready (kind 24242) | +| BlossomServersEvent | `quartz/nipB7Blossom/` | Ready (kind 10063) | +| BlossomUri | `quartz/nipB7Blossom/` | Ready (blossom: URI parsing) | +| BlossomUploadResult | `quartz/nipB7Blossom/` | Ready | +| FileHeaderEvent (NIP-94) | `quartz/nip94FileMetadata/` | Ready (kind 1063) | +| BlurhashTag | `quartz/nip94FileMetadata/tags/` | Ready | +| DimensionTag | `quartz/nip94FileMetadata/tags/` | Ready | +| IMetaTag/Builder (NIP-92) | `quartz/nip92IMeta/` | Ready | +| PictureEvent (NIP-68) | `quartz/nip68Picture/` | Ready (kind 20) | +| VideoEvent (NIP-71) | `quartz/nip71Video/` | Ready (kind 21/22/34235/34236) | +| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | Ready | +| FileServersEvent (NIP-96) | `quartz/nip96FileStorage/` | Exists but we're skipping NIP-96 | +| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Ready (encrypted DM files) | + +### Android-Only (needs extraction or desktop equivalent) + +| Component | Location | Action | +|-----------|----------|--------| +| BlossomUploader | `amethyst/service/uploads/blossom/` | Extract HTTP logic to commons | +| Nip96Uploader | `amethyst/service/uploads/nip96/` | Skip (Blossom only) | +| UploadOrchestrator | `amethyst/service/uploads/` | Extract to commons | +| MultiOrchestrator | `amethyst/service/uploads/` | Extract to commons | +| MediaUploadResult | `amethyst/service/uploads/` | Extract (already platform-agnostic) | +| MediaCompressor | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO) | +| BlurhashMetadataCalculator | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO + commons blurhash) | +| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/` | Extract to commons | +| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/` | Extract to commons | +| ImageLoaderSetup | `amethyst/service/images/` | Desktop Coil3 config | +| BlossomFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blossom: URIs) | +| BlurHashFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blurhash) | +| Base64Fetcher | `amethyst/service/images/` | Extract to commons | +| ZoomableContentView | `amethyst/ui/components/` | Desktop lightbox equivalent | +| ZoomableContentDialog | `amethyst/ui/components/` | Desktop lightbox dialog | +| ImageGallery | `amethyst/ui/components/` | Extract carousel to commons | +| MyAsyncImage | `amethyst/ui/components/` | Desktop equivalent | +| VideoView/VideoViewInner | `amethyst/service/playback/` | Desktop video player (VLCJ) | +| ExoPlayerPool/Builder | `amethyst/service/playback/` | Desktop player pool equivalent | +| MediaAspectRatioCache | `amethyst/model/` | Extract to commons | +| NewMediaView/Model | `amethyst/ui/actions/` | Desktop media post composer | +| MediaUploadTracker | `amethyst/ui/actions/uploads/` | Extract to commons | +| SelectFromGallery | `amethyst/ui/actions/uploads/` | Desktop file picker | +| ShowImageUploadItem | `amethyst/ui/actions/uploads/` | Extract upload preview to commons | +| ChatFileSender/Uploader | `amethyst/ui/screen/chats/` | Desktop encrypted upload | +| BlossomServersViewModel | `amethyst/ui/actions/mediaServers/` | Extract to commons | +| GalleryThumb | `amethyst/ui/screen/profile/gallery/` | Desktop gallery grid | +| PictureDisplay | `amethyst/ui/note/types/` | Extract to commons | +| VideoDisplay | `amethyst/ui/note/types/` | Desktop video renderer | +| VoiceTrack | `amethyst/ui/note/types/` | Desktop audio player | + +### Desktop-Specific (new code) + +| Component | Location | Notes | +|-----------|----------|-------| +| DesktopImageLoader setup | `desktopApp/` or `commons/jvmMain/` | Coil3 disk cache config for desktop | +| DesktopVideoPlayer | `desktopApp/` | VLCJ wrapper composable | +| DesktopFilePicker | `desktopApp/` | JFileChooser / AWT FileDialog | +| DragDropHandler | `desktopApp/` | Compose Desktop DnD API | +| ClipboardPasteHandler | `desktopApp/` | AWT clipboard image reading | +| DesktopMediaCompressor | `commons/jvmMain/` | Java ImageIO-based compression | +| DesktopBlurhashCalculator | `commons/jvmMain/` | BufferedImage → blurhash | +| DesktopExifStripper | `commons/jvmMain/` | metadata-extractor (lossless EXIF removal) | +| MediaScreen (desktop) | `desktopApp/` | Desktop media gallery/feed layout | +| UploadDialog (desktop) | `desktopApp/` | Upload progress, server selection, alt text | + +## Blossom Protocol Implementation + +### Upload Flow (BUD-02) + +``` +1. User drops/pastes/picks file +2. Client computes SHA-256 locally +3. (Optional) HEAD /upload pre-flight check (BUD-06) +4. Sign kind 24242 auth event (BUD-11) with t=upload +5. PUT /upload with binary body + Authorization header +6. Server returns BlobDescriptor {url, sha256, size, type, uploaded} +7. (Optional) PUT /media for server-side optimization (BUD-05) +8. (Optional) PUT /mirror to additional servers (BUD-04) +9. Client adds imeta tag to note event (NIP-92) +``` + +### Server Discovery (BUD-03) + +``` +1. Query user's kind 10063 event from relays +2. Parse server URLs from ["server", "https://..."] tags +3. Cache server list +4. Upload to preferred server(s) +5. When URL breaks: extract SHA-256 from URL → try other servers +``` + +### Auth (BUD-11) + +```kotlin +// Kind 24242 event structure +BlossomAuthorizationEvent( + content = "Upload Blob", + tags = [ + ["t", "upload"], + ["x", ""], // scope to specific blob + ["expiration", ""], // required + ["server", "cdn.example.com"] // scope to server + ] +) +// Sent as: Authorization: Nostr +``` + +## NIP Coverage + +| NIP | What | How We Use It | +|-----|------|---------------| +| NIP-92 | Media Attachments (imeta tag) | Attach metadata to media URLs in notes | +| NIP-94 | File Metadata (kind 1063) | File header events, metadata tags | +| NIP-B7 | Blossom Media | Server discovery, URL fallback | +| NIP-68 | Picture Events (kind 20) | Picture-first posts, profile gallery | +| NIP-71 | Video Events (kind 21/22) | Video display and metadata | +| NIP-17 | Private DMs (encrypted files) | Encrypted media in DMs | + +## Desktop UX Patterns + +### Drag & Drop +- Drop zone on compose area +- Visual feedback (border highlight, preview) +- Multiple files → MultiOrchestrator +- Accept: images, videos, audio files + +### Clipboard Paste (Ctrl+V) +- Detect image data in clipboard +- Auto-create temp file → upload flow +- Screenshot workflow: PrtScn → paste → upload + +### File Picker +- OS-native dialog (JFileChooser on desktop) +- Filter by supported media types +- Multiple file selection + +### Lightbox +- Click image → full-screen overlay +- Mouse wheel zoom + pan +- Arrow keys for gallery navigation +- Esc to close +- Save to disk option + +### Upload Progress +- Inline progress indicator per file +- Server selection dropdown (from kind 10063 list) +- Alt text input field +- Compression toggle +- Preview before send + +## Phases + +### Phase 1: Image Display Foundation +**Goal:** Images render in desktop notes with blurhash previews. + +| Task | Module | Details | +|------|--------|---------| +| Desktop Coil3 ImageLoader setup | `desktopApp/` | DiskCache (OS-appropriate path), MemoryCache, OkHttp network, custom fetchers | +| Extract BlossomFetcher | `amethyst/` → `commons/` | Coil3 fetcher for `blossom:` URIs | +| Extract BlurHashFetcher | `amethyst/` → `commons/` | Coil3 fetcher for blurhash placeholder rendering | +| Extract Base64Fetcher | `amethyst/` → `commons/` | Coil3 fetcher for base64 data URIs | +| Desktop inline image rendering | `desktopApp/` | AsyncImage in note content, aspect ratio handling | +| MediaAspectRatioCache extraction | `amethyst/` → `commons/` | LruCache for URL→aspect ratio (replace Android LruCache with common impl) | + +**Deliverable:** Notes in desktop feed display inline images with blurhash placeholders. +**Verifiable:** Run desktop app, navigate to feed with image posts, images load with blue/gray previews → full images. + +### Phase 2: Blossom Upload Protocol Extraction +**Goal:** Shared upload client in commons, usable by Android and Desktop. + +| Task | Module | Details | +|------|--------|---------| +| Create `BlossomClient` | `commons/commonMain/` | HTTP PUT /upload, /mirror, /media. Takes InputStream. Returns BlobDescriptor. | +| Create `BlossomAuthHelper` | `commons/commonMain/` | Kind 24242 event creation + base64 encoding | +| Create `BlossomServerDiscovery` | `commons/commonMain/` | Kind 10063 query, server list resolution | +| Create expect/actual file operations | `commons/` | `readFileBytes`, `computeFileSha256`, `getMimeType`, `getFileSize` | +| Create expect/actual `MediaMetadataExtractor` | `commons/` | Dimensions, blurhash computation | +| Create expect/actual `MediaCompressor` | `commons/` | JPEG quality, EXIF stripping (metadata-extractor) | +| Extract `UploadOrchestrator` | `amethyst/` → `commons/` | Multi-server coordination using BlossomClient | +| Extract `MultiUploadOrchestrator` | `amethyst/` → `commons/` | Parallel file upload management | +| Extract `MediaUploadResult` | `amethyst/` → `commons/` | Already platform-agnostic | +| Migrate Android to use commons upload | `amethyst/` | Android actuals + wire up to existing UploadOrchestrator callers | +| Extract `BlossomServersViewModel` | `amethyst/` → `commons/` | Server list state management | + +**Deliverable:** `./gradlew :commons:jvmTest` passes with upload unit tests. Android still works. +**Verifiable:** Android upload flow unchanged. Desktop can call BlossomClient to upload a file. + +### Phase 3: Desktop Upload UX +**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste. + +| Task | Module | Details | +|------|--------|---------| +| Desktop file picker | `desktopApp/` | JFileChooser with media type filters, multi-select | +| Drag-and-drop handler | `desktopApp/` | `dragAndDropTarget` + `awtTransferable` + `javaFileListFlavor` | +| Clipboard paste handler | `desktopApp/` | AWT Toolkit clipboard, `DataFlavor.imageFlavor`, temp file creation | +| Upload dialog composable | `desktopApp/` | Progress bar, server selector (kind 10063), alt text field, compression toggle | +| Upload preview | `desktopApp/` or `commons/` | Thumbnail preview before upload | +| Wire up to compose screen | `desktopApp/` | Add media button to note composer, connect upload flow | + +**Deliverable:** Desktop user can drag image → see preview → upload to Blossom → post note with imeta. +**Verifiable:** Drop file on compose area, see upload progress, note publishes with embedded image. + +### Phase 4: Video Playback +**Goal:** Videos play inline in desktop notes. + +| Task | Module | Details | +|------|--------|---------| +| Add VLCJ + vlc-setup plugin | `desktopApp/build.gradle.kts` | `ir.mahozad.vlc-setup`, VLCJ 4.8.x dep | +| DesktopVideoPlayer composable | `desktopApp/` | SwingPanel + EmbeddedMediaPlayerComponent | +| Video controls overlay | `desktopApp/` | Play/pause, seek bar, volume, fullscreen toggle | +| Video in note rendering | `desktopApp/` | Replace URL-only display with inline player | +| Video upload support | `desktopApp/` | Accept video files in upload flow (Phase 3) | + +**Deliverable:** Video posts play inline in desktop feed. +**Verifiable:** Navigate to note with mp4/webm URL, video plays with controls. + +### Phase 5: Lightbox & Gallery +**Goal:** Full-screen media viewing with zoom and gallery navigation. + +| Task | Module | Details | +|------|--------|---------| +| Lightbox overlay composable | `desktopApp/` or `commons/` | Full-screen overlay, semi-transparent backdrop | +| Zoom + pan | `desktopApp/` | Mouse wheel zoom, click-drag pan (use zoomable lib or custom) | +| Gallery carousel | `commons/commonMain/` | Multi-image navigation (arrow keys + swipe indicators) | +| Save to disk | `desktopApp/` | Right-click or button → save image/video to local filesystem | +| Keyboard shortcuts | `desktopApp/` | Esc close, Left/Right navigate, +/- zoom | + +**Deliverable:** Click any image → fullscreen lightbox with zoom, multi-image gallery navigation. +**Verifiable:** Click image in note, zooms to fullscreen. Arrow keys cycle images. Esc closes. + +### Phase 6: Encrypted Media (DM Files) +**Goal:** Send and receive encrypted files in NIP-17 DMs. + +| Task | Module | Details | +|------|--------|---------| +| Extract encryption logic | `amethyst/` → `commons/` | NostrCipher usage for file encrypt/decrypt | +| Desktop encrypted upload flow | `desktopApp/` | Pick file → encrypt → Blossom upload → send encrypted event | +| Desktop encrypted display | `desktopApp/` | Receive encrypted file event → download → decrypt → display | +| Chat file upload dialog | `desktopApp/` | Similar to Phase 3 upload dialog but in DM context | + +**Deliverable:** Desktop DM users can send/receive encrypted images and files. +**Verifiable:** Send image in DM from desktop, receive on Android (and vice versa). + +### Phase 7: Profile Gallery (NIP-68) +**Goal:** View and create picture-first posts (kind 20). Profile gallery tab. + +| Task | Module | Details | +|------|--------|---------| +| Picture event display | `desktopApp/` | Kind 20 renderer with image-first layout | +| Profile gallery tab | `desktopApp/` | Grid of user's picture posts | +| Picture post composer | `desktopApp/` | Create kind 20 events with multiple images + imeta | +| Gallery entry events | `desktopApp/` | ProfileGalleryEntryEvent support | + +**Deliverable:** Desktop profile shows gallery tab. Users can create Instagram-style picture posts. +**Verifiable:** View profile → gallery tab shows image grid. Create picture post → visible on Android. + +### Phase 8: Media Server Management +**Goal:** UI for managing Blossom server list (kind 10063). + +| Task | Module | Details | +|------|--------|---------| +| Server list settings screen | `desktopApp/` | View/add/remove/reorder Blossom servers | +| Server status checking | `commons/` | HEAD request to verify server availability | +| Default server selection | `desktopApp/` | Choose preferred upload server | +| Publish kind 10063 | `commons/` | Update server list on relays | + +**Deliverable:** Desktop settings page to manage Blossom servers. +**Verifiable:** Add server → appears in upload dialog dropdown. Remove server → no longer used. + +### Phase 9: Audio Playback +**Goal:** Play audio tracks (MP3, OGG, FLAC) in notes. + +| Task | Module | Details | +|------|--------|---------| +| Audio player composable | `desktopApp/` | VLCJ audio-only mode (no video surface needed) | +| Waveform visualization | `desktopApp/` | Optional: visual waveform for voice messages | +| Audio in note rendering | `desktopApp/` | Play/pause button + progress bar inline | + +**Deliverable:** Audio files play inline in notes. +**Verifiable:** Note with MP3 URL shows audio player, plays on click. + +### Phase Dependency Graph + +``` +Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox) + │ +Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted) + │ + ├──→ Phase 7 (Gallery) + │ + └──→ Phase 8 (Server Mgmt) + +Phase 4 (Video) ────────────→ Phase 9 (Audio) + +Independent: Phase 1, 2, 4 can run in parallel +``` + +## Assumptions + +1. **Coil3 JVM/Desktop is production-ready** — Coil3 3.x advertises Compose Multiplatform support. Verify actual JVM desktop stability before committing. +2. **VLCJ + Compose SwingPanel works** — SwingPanel embeds Swing components in Compose. VLCJ renders to a Canvas/Panel. Need spike to confirm smooth integration (no flickering, proper resizing). +3. **OkHttp in commons is fine** — BlossomUploader uses OkHttp for HTTP. Both Android and Desktop are JVM, so OkHttp works in `commons/jvmAndroid/` or `commons/commonMain/` (OkHttp has KMP support). If iOS is ever targeted, this becomes an issue. +4. **Extracting to commons won't break Android** — Moving upload code from `amethyst/` to `commons/` requires updating Android imports. Must ensure Android's Koin DI and lifecycle wiring still works. +5. **libvlc can be bundled per-platform** — Compose Desktop packaging plugin supports native lib bundling. Need to verify for macOS (dylib), Linux (.so), Windows (.dll). + +## Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| VLCJ SwingPanel flicker | Video unusable | Spike test early (Phase 4 is independent) | +| Commons extraction breaks Android | Regression | Run Android build after each extraction | +| Coil3 JVM disk cache bugs | Missing images | Fall back to OkHttp manual caching | +| libvlc bundle size (~100MB) | Large app | Consider optional download or separate installer | +| Scope creep (15 features) | Never ships | Phases exist for a reason — ship Phase 1-3 first | + +## Resolved Questions + +1. **Video player** — VLCJ with bundled libvlc. Ship libvlc with the app (~100MB) for zero user setup. Full format support (mp4, webm, m3u8, mkv, etc.). + +2. **EXIF stripping** — Use Drew Noakes' `metadata-extractor` library. Surgically strip EXIF while preserving image quality (no re-encoding loss). + +3. **Upload concurrency** — Higher parallelism than Android by default (desktop has more resources). Upload to multiple Blossom servers simultaneously. Make concurrent upload count user-configurable in settings. + +4. **Coil3 disk cache** — Use OS-appropriate paths: macOS `~/Library/Caches/AmethystDesktop`, Linux `$XDG_CACHE_HOME/AmethystDesktop` (default `~/.cache/`), Windows `%LOCALAPPDATA%/AmethystDesktop/cache`. Use `maxSizeBytes(1GB)` not `maxSizePercent` (that needs Android Context). + +5. **Compose Desktop DnD** — `Modifier.dragAndDropTarget` is experimental (`@ExperimentalFoundationApi`) in 1.7.x. Uses `event.awtTransferable` + `DataFlavor.javaFileListFlavor` on desktop. Old `onExternalDrag` deprecated, removed in 1.8.0. API works but expect minor changes. + +6. **Media compression** — JPEG: `ImageWriteParam.compressionQuality` (0.0-1.0). PNG: lossless deflate level. WebP: use `org.sejda.imageio:webp-imageio` for lossy/lossless write (~3MB native libs per platform). Start with JPEG/PNG re-encoding, add WebP output later. + +7. **libvlc bundling** — Use `ir.mahozad.vlc-setup` Gradle plugin. Downloads and bundles libvlc per platform. Targets VLC 3.x + VLCJ 4.8.x. Compose Desktop integration via `SwingPanel`. + +## Open Questions + +1. **vlc-setup Apple Silicon** — Does the vlc-setup plugin support arm64 macOS, or only x86_64? Need to verify. +2. **Compose DnD macOS quirks** — Does `awtTransferable` properly deliver file URIs on macOS, or are there Finder-specific issues? diff --git a/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md new file mode 100644 index 0000000000..543857c56c --- /dev/null +++ b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md @@ -0,0 +1,85 @@ +# Brainstorm: Desktop DM Encrypted Media (Phase 6) + +**Date:** 2026-03-18 +**Status:** Ready for planning +**Branch:** `feat/desktop-media` + +## What We're Building + +Full send + receive encrypted media support in desktop DM chat (NIP-17). Users can attach files in DMs, which get encrypted (AES-GCM) before upload to Blossom, sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap. Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay. + +## Why This Approach + +The protocol layer is complete in quartz (NIP-17, NIP-44, AESGCM, ChatMessageEncryptedFileHeaderEvent). Android has the full flow implemented. Desktop already has `EncryptedMediaService.downloadAndDecrypt()` and `DesktopUploadOrchestrator` (unencrypted only). The work is essentially wiring up the existing pieces with a desktop-native UX. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Scope | Full send + receive | Both sides needed for complete DM file sharing | +| Attach UX | Inline paperclip button | Matches desktop chat conventions; thumbnails above text input | +| Drag & drop | Yes, in addition to button | Desktop-native interaction; ComposeNoteDialog already has this pattern | +| Encryption indicator | Lock icon overlay | Small lock on corner of encrypted media in chat bubbles | +| Upload encryption | AES-GCM via AESGCM class | Matches Android's ChatFileUploader.justUploadNIP17() pattern | +| Event type | Kind 15 (ChatMessageEncryptedFileHeaderEvent) | NIP-17 standard for encrypted file metadata | + +## Architecture Overview + +### Send Flow (Desktop) +``` +User attaches file → thumbnail preview above input + → Click send + → Generate AES-GCM cipher (key + nonce) + → DesktopUploadOrchestrator.uploadEncrypted(cipher, file) + → Encrypt file bytes with cipher + → Upload encrypted blob to Blossom server + → Build ChatMessageEncryptedFileHeaderEvent (kind 15) + → URL, encryption algo/key/nonce, file metadata + → Wrap in GiftWrap (NIP-59) for each recipient + → Send to relays +``` + +### Receive Flow (Desktop) +``` +Receive GiftWrap → unwrap → ChatMessageEncryptedFileHeaderEvent + → Extract URL, encryption key, nonce from tags + → EncryptedMediaService.downloadAndDecrypt(url, key, nonce) + → Display decrypted media inline in chat bubble + → Lock icon overlay on media thumbnail +``` + +## Existing Code to Reuse + +| Component | Location | Action | +|-----------|----------|--------| +| AESGCM cipher | `quartz/utils/ciphers/AESGCM.kt` | Reuse as-is | +| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Reuse as-is | +| NIP17Factory (GiftWrap) | `quartz/nip17Dm/NIP17Factory.kt` | Reuse as-is | +| EncryptedMediaService | `desktopApp/service/media/EncryptedMediaService.kt` | Extend for UI integration | +| DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Add `uploadEncrypted()` | +| ChatPane | `desktopApp/ui/chats/ChatPane.kt` | Add attach button, thumbnails, drag-drop | +| ChatMessageCompose | `commons/ui/chat/ChatMessageCompose.kt` | Add encrypted media display | +| Android ChatFileUploader | `amethyst/chats/privateDM/send/upload/` | Reference pattern | + +## New Code Needed + +| Component | Location | Purpose | +|-----------|----------|---------| +| `uploadEncrypted()` | DesktopUploadOrchestrator | Encrypt file with AESGCM before Blossom upload | +| DM file attach UI | ChatPane.kt | Paperclip button, thumbnail row, drag-drop zone | +| DM file send logic | ChatPane.kt or new helper | Build kind 15 event from upload result + cipher | +| Encrypted media renderer | ChatMessageCompose or new composable | Download, decrypt, display with lock overlay | +| `sendNip17EncryptedFile()` | DesktopIAccount.kt | Bridge to relay manager for sending | + +## Open Questions + +None — all key decisions resolved through brainstorm dialogue. + +## Test Cases (from testing plan) + +| # | Test | Expected | +|---|------|----------| +| 6.1 | DM file attach | Attach button visible, encryption indicator shown | +| 6.2 | Send encrypted | File uploads encrypted to Blossom, kind 15 event sent | +| 6.3 | Receive encrypted | Encrypted file downloads, decrypts, displays in bubble | +| 6.4 | Wrong key | Decryption fails gracefully (no crash, error state) | diff --git a/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md new file mode 100644 index 0000000000..640609897e --- /dev/null +++ b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md @@ -0,0 +1,75 @@ +# Brainstorm: Stacked Messages Layout in Multi-Deck + +**Date:** 2026-03-19 +**Status:** Ready for planning + +## What We're Building + +Replace the side-by-side split-pane Messages layout (contact list + chat) with a stacked navigation in deck columns. Clicking a conversation navigates from the contact list to the chat view; a back arrow returns to the list. Single-pane (non-deck) mode keeps the current split layout. + +## Why This Approach + +In multi-deck mode, columns can be 350-400dp wide. The current split layout allocates 280dp to the contact list, leaving only 70-120dp for the chat pane — unusable. A stacked layout gives the full column width to whichever view is active. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Layout mode | Always stacked in deck columns | Simplest, works at any column width | +| Single-pane mode | Keep split layout | Plenty of horizontal space in single-pane | +| Back navigation | Back arrow in chat header | Discoverable; Escape key already works | +| State management | `selectedRoom` in `ChatroomListState` already controls this | No new state needed — just show list when null, chat when selected | + +## Architecture + +### Current Flow (DesktopMessagesScreen.kt) +``` +Row { + ConversationListPane(280dp) // always visible + VerticalDivider + ChatPane(flex) // or EmptyState +} +``` + +### New Flow (Deck Mode) +``` +// When selectedRoom == null: +ConversationListPane(full width) + +// When selectedRoom != null: +Column { + BackArrow + ChatroomHeader + ChatPane(full width) +} +``` + +### Implementation Approach + +`DesktopMessagesScreen` already has `selectedRoom` state. The change is layout-only: + +1. Add a `compactMode: Boolean` parameter to `DesktopMessagesScreen` +2. In deck mode (`compactMode = true`): show either list OR chat, not both +3. In single-pane mode (`compactMode = false`): keep current split Row +4. Add back arrow to `ChatPane` header when in compact mode +5. `clearSelection()` → back to list (already exists) + +### What Changes + +| Component | Change | +|-----------|--------| +| `DesktopMessagesScreen` | Add `compactMode` param; conditional layout (Row vs when/else) | +| `ChatPane` | Add `onBack` callback + back arrow in header when provided | +| `RootContent` | Pass `compactMode = true` to DesktopMessagesScreen | +| `SinglePaneLayout` `RootContent` | Pass `compactMode = false` | +| `ConversationListPane` | No changes — already works at any width | + +### Edge Cases + +- **Keyboard nav**: Escape already calls `clearSelection()` — works as back +- **New DM dialog**: Opens over whichever view is active — no change needed +- **Receiving DM while in list**: Room appears/updates in list normally +- **Receiving DM while in chat**: Messages appear in real-time — no change + +## Open Questions + +None — all decisions resolved. diff --git a/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md new file mode 100644 index 0000000000..e106e509db --- /dev/null +++ b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md @@ -0,0 +1,33 @@ +# Brainstorm: Per-Message Encryption Badge in DMs + +**Date:** 2026-03-19 +**Status:** Ready for implementation + +## What We're Building + +Per-message encryption indicator in DM chat bubbles — lock icon (NIP-17) or lock-open icon (NIP-04) next to the timestamp. Matches Android's approach with incognito badges. + +## Why This Approach + +Users need to know which messages are truly private (NIP-17: relay can't see sender/recipient) vs legacy encrypted (NIP-04: relay sees metadata). Android already does this with incognito badges. Desktop uses lock/lock-open icons (already imported in ChatPane) for consistency with the existing NIP-17 toggle. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Indicator type | Lock icon per message | Matches existing lock icon pattern in desktop NIP-17 toggle | +| NIP-17 icon | Lock (filled) in primary color | Private, secure | +| NIP-04 icon | LockOpen in muted gray | Legacy, weaker privacy | +| Placement | Next to timestamp in detailRow | Matches Android's IncognitoBadge placement | +| Tooltip | None (match Android) | Keep it subtle, not alarming | + +## Implementation + +The badge goes in `MessageWithReactions` in ChatPane.kt, in the `detailRow` slot of `ChatMessageCompose`. Check `note.event` type: +- `is PrivateDmEvent` → NIP-04 → lock-open gray +- `is ChatMessageEvent` or `is ChatMessageEncryptedFileHeaderEvent` → NIP-17 → lock primary +- else → no badge + +## Key Finding: NIP-04 + NIP-17 Messages Merge + +For 1-on-1 chats, both protocols produce identical `ChatroomKey({otherPubkey})`. Messages from both protocols appear in the same conversation. The per-message badge is the only way to tell them apart. diff --git a/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md b/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md new file mode 100644 index 0000000000..244213ebac --- /dev/null +++ b/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md @@ -0,0 +1,962 @@ +--- +title: "feat: Desktop Advanced Search with Query Operators and Form UI" +type: feat +status: active +date: 2026-03-10 +deepened: 2026-03-10 +origin: docs/brainstorms/2026-03-10-advanced-search-brainstorm.md +--- + +# Desktop Advanced Search + +## Enhancement Summary + +**Deepened on:** 2026-03-10 +**Agents used:** kotlin-expert, compose-expert, kotlin-coroutines, nostr-expert, desktop-expert, kmp-expert, best-practices-researcher, architecture-strategist, performance-oracle, code-simplicity-reviewer, security-sentinel + +### Key Improvements +1. **Bidirectional sync loop prevention** — `sourceOfChange` discriminator (TEXT/FORM/INIT) breaks parse→serialize→parse cycles +2. **Performance** — batch result accumulation via `channelFlow` + 100ms windows, cap OR to 3 terms (not 5), `@Immutable` SearchQuery +3. **Parser architecture** — hand-written recursive descent tokenizer + parser, error recovery via literal text degradation +4. **Module corrections** — SearchFilterFactory stays in desktopApp (needs SubscriptionConfig); SearchResultFilter and SearchHistoryStore can move to commons +5. **Compose patterns** — `FilterChip` for kind presets, `expandVertically(Alignment.Top)` + `fadeIn`, sticky section headers, shimmer loading +6. **Coroutine patterns** — `flatMapLatest` for auto-canceling old subscriptions, `merge()` for OR queries, `supervisorScope` for relay isolation +7. **Simplicity guidance** — MVP can cut OR queries, lang:/domain:, saved searches to ~350 LOC / 5 files. Full plan phases appropriately. + +### New Considerations Discovered +- NIP-50 extensions go inline in search string (`"bitcoin language:en"`), not as separate filter fields +- Pseudo-kinds (reply, media) need separate handling from real kinds — they're client-side post-filters, not relay filters +- `TextFieldValue` (not raw String) needed for cursor position stability during bidirectional sync +- Use `query.hashtags` → `Filter.tags["t"]` (more reliable than putting hashtags in search string) +- OR cap: 3 terms max (not 5) — 5 terms × 3 groups × 3 relays = 45 subs is too many + +--- + +## Overview + +Full-featured search for Amethyst Desktop: Twitter-style query operators (`from:`, `kind:`, `since:`, etc.), expandable form panel below search bar, bidirectional sync between text and form, extensible kind presets, OR queries, search history + saved searches. Relay-first via NIP-50. + +Current desktop search only handles kind 0 (people) + kind 1 (notes) with plain text. Android searches 30+ kinds. This closes that gap and adds capabilities neither platform has. + +## Problem Statement + +Desktop search (`SearchScreen.kt`) is minimal: +- Only `searchPeople()` (kind 0) wired to relay subscription +- `searchNotes()` exists in `FeedSubscription.kt` but not connected +- No kind filtering, no author filtering, no date ranges +- No query language — users can only type plain text or bech32 identifiers +- `SearchBarState` in commons only returns `User` results, no notes/channels + +Users can't find content they've seen, discover new content by topic, or filter by author/type/date. + +## Proposed Solution + +### Query Operator Language + +Client-side query language that maps to `Filter` fields. Not a Nostr standard — a UX convention. + +``` +from:npub1abc kind:note since:2025-01-01 bitcoin OR lightning -spam #nostr +``` + +| Operator | Maps To | Relay-side? | +|----------|---------|-------------| +| `from:` | `Filter.authors` | Yes | +| `kind:` | `Filter.kinds` | Yes | +| `since:` | `Filter.since` | Yes | +| `until:` | `Filter.until` | Yes | +| `#` | `Filter.tags["t"]` | Yes | +| `"exact phrase"` | Quoted in `Filter.search` | Yes (relay-dependent) | +| `lang:` | NIP-50 extension in search string | Relay-dependent | +| `domain:` | NIP-50 extension in search string | Relay-dependent | +| `-` | Client-side exclusion post-filter | No | +| `OR` | Parallel subscriptions, merged | Multiple queries | + +#### Research Insights: NIP-50 Protocol Details + +**NIP-50 extension placement:** Extensions go *inline in the search string*, not as separate filter fields. The relay parses them out: +```json +{"kinds": [1], "search": "bitcoin language:en domain:nostr.com"} +``` + +**Hashtag handling:** Use `tags = {"t": ["bitcoin"]}` in the filter (more reliable across relays) rather than putting `#bitcoin` in the search string. Hashtags in `Filter.tags` are protocol-level, not NIP-50 dependent. + +**Quoted phrase search:** Not standardized — relay-dependent. Some relays treat quotes literally, others ignore them. Degrade gracefully. + +**All filter fields AND together** within a single filter. OR requires separate subscriptions. + +### Dual UI: Text Bar + Expandable Form Panel + +``` +[ from:npub1abc kind:note bitcoin ] [Advanced v] ++----------------------------------------------------------+ +| Content: [x] Notes [ ] Articles [ ] Media [ ] All | +| Author: [ npub or name... ] [+ Add] | +| Since: [ 2025-01-01 ] Until: [ today ] | +| Tags: [ #bitcoin ] [+ Add] | +| Exclude: [ spam ] [+ Add] | +| Language:[ Any v ] | +| | +| [Clear] [Search] | ++----------------------------------------------------------+ +``` + +Bidirectional: typing `kind:article` checks "Articles"; checking "Notes" inserts `kind:note`. + +#### Research Insights: Bidirectional Sync + +**Critical: `sourceOfChange` discriminator.** Without this, parse→serialize→parse loops will occur. Track who initiated the change: + +```kotlin +enum class ChangeSource { TEXT, FORM, INIT } + +fun updateFromText(rawText: String) { + _changeSource = ChangeSource.TEXT + _query.value = QueryParser.parse(rawText) +} + +fun updateKinds(kinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(kinds = kinds) +} + +// In the composable, only update text field when source != TEXT +val displayText by remember { + state.query.map { query -> + if (state.changeSource != ChangeSource.TEXT) { + QuerySerializer.serialize(query) + } else { + // Keep user's raw text as-is + state.rawText + } + } +} +``` + +**Use `TextFieldValue` (not raw String)** for the text bar to preserve cursor position during form-driven updates. When form changes update the serialized text, set `TextFieldValue(text = newText, selection = TextRange(newText.length))`. + +## Technical Approach + +### Architecture + +``` +Text Bar ──parse──> SearchQuery <──serialize── Form Panel + | + FilterBuilder + | + List (split by kind groups, ~10 kinds each) + | + Relay Subscriptions (NIP-50) + | + Client-side Post-filter (exclusions, reply/media detection) + | + Results Display (grouped: People, Notes, Articles, Channels) +``` + +**Single source of truth:** `MutableStateFlow`. Both text bar and form read from it. Text bar changes → `QueryParser` → `SearchQuery`. Form changes → mutate `SearchQuery` directly. `QuerySerializer` regenerates text string. `sourceOfChange` discriminator prevents update loops. + +**Debounce strategy:** Text input debounced 300ms (existing pattern). Form toggle changes trigger immediate search (no debounce). + +#### Research Insights: Kotlin State Patterns + +**`@Immutable` on SearchQuery** — enables Compose to skip recomposition when query hasn't changed: +```kotlin +@Immutable +data class SearchQuery( + val text: String = "", + val authors: ImmutableList = persistentListOf(), + val kinds: ImmutableList = persistentListOf(), + // ... +) { + companion object { + val EMPTY = SearchQuery() + } +} +``` + +Use `kotlinx.collections.immutable` (`ImmutableList`, `ImmutableSet`, `persistentListOf()`) for all collection fields. This gives Compose structural stability guarantees. + +**Granular derived StateFlows** with `distinctUntilChanged()` to prevent unnecessary recomposition: +```kotlin +val kindsForUI: StateFlow> = _query + .map { it.kinds } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.WhileSubscribed(5000), persistentListOf()) +``` + +### Data Flow Detail + +``` +SearchQuery (SSOT) + │ + ├─ text bar reads: QuerySerializer.serialize(query) → displayed string + │ └─ on text change: QueryParser.parse(rawText) → new SearchQuery + │ └─ GUARD: only serialize→display when changeSource != TEXT + │ + ├─ form panel reads: query.kinds, query.authors, query.since, etc. + │ └─ on form change: query.copy(kinds = ...) → new SearchQuery + │ + └─ relay layer reads: SearchFilterFactory.createFilters(query) → List + └─ subscription created per filter group + └─ OR queries: parallel subscriptions via merge(), results deduped by event ID +``` + +### Implementation Phases + +#### Phase 1: Query Engine (commons/commonMain) — Foundation + +Pure Kotlin, no UI, exhaustively unit-tested. + +**Step 1.1: `SearchQuery` data class** + +```kotlin +// commons/src/commonMain/.../search/SearchQuery.kt +@Immutable +data class SearchQuery( + val text: String = "", // Free text for NIP-50 search field + val authors: ImmutableList = persistentListOf(), // Hex pubkeys + val authorNames: ImmutableList = persistentListOf(), // Unresolved names (for display) + val kinds: ImmutableList = persistentListOf(), // Empty = all searchable kinds + val since: Long? = null, // Unix timestamp + val until: Long? = null, + val hashtags: ImmutableList = persistentListOf(), // Without # prefix + val excludeTerms: ImmutableList = persistentListOf(), // Client-side exclusion + val language: String? = null, // ISO 639-1 + val domain: String? = null, // NIP-05 domain + val orTerms: ImmutableList = persistentListOf(), // Terms joined by OR +) { + val isEmpty get() = text.isBlank() && authors.isEmpty() && kinds.isEmpty() + && since == null && until == null && hashtags.isEmpty() + && orTerms.isEmpty() + + companion object { + val EMPTY = SearchQuery() + } +} +``` + +#### Research Insights: Pseudo-Kinds + +**Separate pseudo-kinds from real kinds.** `kind:reply` and `kind:media` are NOT relay filter kinds — they require client-side post-filtering: +- `kind:reply` = kind 1 events WITH `e` tag +- `kind:media` = kind 1 events WITH `imeta` tag or image URLs + +The `SearchQuery` should track these separately or the `KindRegistry` should flag them. Recommended: a `pseudoKinds: Set` field or handle in `SearchResultFilter`. + +**Step 1.2: Kind alias registry** + +```kotlin +// commons/src/commonMain/.../search/KindRegistry.kt +object KindRegistry { + // Import quartz KIND constants instead of hardcoding numbers + val aliases: Map> = mapOf( + "note" to listOf(1), + "article" to listOf(30023), + "repost" to listOf(6), + "profile" to listOf(0), + "channel" to listOf(40, 41, 42), + "live" to listOf(30311), + "community" to listOf(34550), + "wiki" to listOf(30818), + "video" to listOf(34235), + "classified" to listOf(30402), + "highlight" to listOf(9802), + "poll" to listOf(6969), + ) + + // Pseudo-kinds: client-side post-filters, not relay kinds + val pseudoKinds: Set = setOf("reply", "media") + + val presets: Map> = mapOf( + "Notes" to listOf(1), + "Articles" to listOf(30023), + "Media" to listOf(1), // post-filtered for imeta tag + "Channels" to listOf(40, 41, 42), + "Communities" to listOf(34550), + "Wiki" to listOf(30818), + ) + + fun resolve(alias: String): List? = aliases[alias.lowercase()] + fun isPseudoKind(alias: String): Boolean = alias.lowercase() in pseudoKinds + fun nameFor(kind: Int): String? = aliases.entries.find { kind in it.value }?.key +} +``` + +**Step 1.3: `QueryParser`** + +#### Research Insights: Parser Architecture + +**Hand-written recursive descent parser** (not regex, not parser generators). Two-phase: + +1. **Tokenizer** (state machine): Walks characters, emits tokens: `OperatorToken(name, value)`, `TextToken(value)`, `OrToken`, `QuotedToken(value)`, `NegationToken(value)`, `HashtagToken(value)` +2. **Parser** (recursive descent): Consumes tokens, builds `SearchQuery` + +**Key principles:** +- **Preserve raw text in tokens** for roundtrip fidelity (`serialize(parse(input))` ≈ `input`) +- **Error recovery**: malformed operators degrade to literal text, never throw +- **OR precedence**: OR binds to adjacent text terms only. Operators are always AND. + - `from:vitor bitcoin OR lightning kind:note` = `from:vitor AND kind:note AND (bitcoin OR lightning)` +- **Performance**: sub-microsecond parsing, not a concern + +```kotlin +// commons/src/commonMain/.../search/QueryParser.kt +object QueryParser { + fun parse(input: String): SearchQuery { + val tokens = tokenize(input) + return buildQuery(tokens) + } + + private fun tokenize(input: String): List { /* state machine */ } + private fun buildQuery(tokens: List): SearchQuery { /* recursive descent */ } +} + +sealed interface Token { + data class Operator(val name: String, val value: String, val raw: String) : Token + data class Text(val value: String) : Token + data object Or : Token + data class Quoted(val value: String, val raw: String) : Token + data class Negation(val term: String) : Token + data class Hashtag(val tag: String) : Token +} +``` + +Rules: +- Case-insensitive operator matching (`FROM:` = `from:`) +- `from:` → if bech32 npub, decode to hex and add to `authors`; else add to `authorNames` (async resolution) +- `kind:` → resolve via `KindRegistry.resolve()` or parse as int. Flag pseudo-kinds separately. +- `since:` / `until:` → parse ISO 8601 (`2025-01-01`, `2025-01`, `2025`) to unix timestamp +- `#tag` → add to `hashtags` +- `"quoted phrase"` → keep in `text` as quoted +- `-term` → add to `excludeTerms`, strip from relay search string +- `OR` → split adjacent free text terms. `bitcoin OR lightning` → `orTerms = ["bitcoin", "lightning"]` +- Multiple `from:` → AND (multiple authors) +- Multiple `kind:` → union (combined kinds) +- Incomplete operators (`from:` with no value) → treat as literal text + +**Step 1.4: `QuerySerializer`** + +```kotlin +// commons/src/commonMain/.../search/QuerySerializer.kt +object QuerySerializer { + fun serialize(query: SearchQuery): String { ... } +} +``` + +Regenerates the canonical text representation from `SearchQuery`. Used to update text bar when form changes. Ordering: operators first (`from:`, `kind:`, `since:`, `until:`, `lang:`, `domain:`), then hashtags, then free text / OR terms, then exclusions. + +**Step 1.5: Unit tests** + +```kotlin +// commons/src/commonTest/.../search/QueryParserTest.kt +// commons/src/commonTest/.../search/QuerySerializerTest.kt +// commons/src/commonTest/.../search/KindRegistryTest.kt +``` + +Test matrix: +- Single operator of each type +- Combined operators +- OR with operators +- Malformed/incomplete (`from:`, `kind:invalid`, `since:not-a-date`) +- Special characters, emoji, unicode in free text +- Roundtrip: `serialize(parse(input)) == normalized(input)` +- Multiple `from:` authors +- Multiple `kind:` (union) +- Quoted phrases +- Exclusion terms +- Pseudo-kind detection (`kind:reply`, `kind:media`) +- Edge: empty string, whitespace only, very long query +- OR precedence: `from:x a OR b kind:note` → operators AND, text OR + +**Consider property-based testing** with Kotest for roundtrip fidelity. + +#### Phase 2: Filter Factory + Relay Integration (desktopApp) + +**Step 2.1: `SearchFilterFactory`** + +```kotlin +// desktopApp/src/jvmMain/.../subscriptions/SearchFilterFactory.kt +object SearchFilterFactory { + fun createFilters(query: SearchQuery): List { ... } +} +``` + +- If `query.kinds` specified → use those kinds directly +- If `query.kinds` empty → use default searchable kinds (align with Android's 3 groups) +- Split kinds into groups of ~10 (relay `max_filters` limit safety) +- Build NIP-50 search string: `query.text` + inline NIP-50 extensions (`language:en`, `domain:x`) +- Strip exclusion terms from search string (don't send `-spam` to relay) +- `query.authors` → `Filter.authors` (only resolved hex keys) +- `query.since` / `query.until` → `Filter.since` / `Filter.until` +- `query.hashtags` → `Filter.tags["t"]` (not in search string — more reliable) +- OR queries: return separate filter lists per OR term + +#### Research Insights: Module Placement + +**SearchFilterFactory stays in desktopApp** — it depends on `SubscriptionConfig` and relay topology, which are desktop-specific. Correct as planned. + +**SearchResultFilter can move to commons/commonMain** — pure Kotlin, no platform dependencies. Android can reuse it later. + +**SearchHistoryStore can move to commons as expect/actual** — follows `SecureKeyStorage` pattern. `expect class SearchHistoryStore`, with `actual` implementations using `java.util.prefs.Preferences` on desktop and SharedPreferences/DataStore on Android. + +**Step 2.2: Default searchable kind groups** + +Port from Android's `SearchPostsByText.kt` to desktop. Reference the same kinds: + +```kotlin +// Group 1: TextNote, LongText, Badge, PeopleList, BookmarkList, AudioHeader, AudioTrack, PinList, PollNote, ChannelCreate +// Group 2: ChannelMetadata, Classifieds, Community, EmojiPack, Highlight, LiveActivities, PublicMessage, NNS, Wiki, Comment +// Group 3: InteractiveStory (2 kinds), FollowList, NipText, Poll, PollResponse +``` + +Kind group splitting is relay-imposed (`max_filters` limits), not protocol. Use the quartz KIND constants, don't hardcode numbers. + +**Step 2.3: Search subscription factory** + +```kotlin +// desktopApp/src/jvmMain/.../subscriptions/FeedSubscription.kt (extend) +fun createAdvancedSearchSubscription( + relays: Set, + query: SearchQuery, + onEvent: ..., + onEose: ..., +): List +``` + +#### Research Insights: Subscription Management + +**Use `flatMapLatest`** on debouncedQuery to auto-cancel old subscriptions when query changes: +```kotlin +val results: Flow> = debouncedQuery + .flatMapLatest { query -> + if (query.isEmpty) flowOf(emptyList()) + else channelFlow { + supervisorScope { + val filters = SearchFilterFactory.createFilters(query) + // Launch independent subscription per filter group + filters.forEach { filter -> + launch { subscribeAndEmit(filter, relays) } + } + } + } + } +``` + +**`supervisorScope`** for independent relay subscription failure isolation — one relay failure doesn't cancel others. + +**`merge()` (not `combine()`)** for OR query result flows — emit results as they arrive from any term. + +**Batch filters per OR term** in a single REQ (not per kind group), reducing subscription count: +- 3 OR terms × 1 batched REQ × 3 relays = 9 subscriptions (vs 45 if unbatched) + +**Cap: max 3 OR terms** (not 5) — subscription fan-out gets expensive. + +**Step 2.4: Client-side post-filter** + +```kotlin +// commons/src/commonMain/.../search/SearchResultFilter.kt +object SearchResultFilter { + fun filter(events: List, query: SearchQuery): List +} +``` + +- `-term` exclusion: check `event.content` doesn't contain term (case-insensitive) +- `kind:reply` detection: kind 1 with `e` tag +- `kind:media` detection: kind 1 with `imeta` tag or URL patterns +- Deduplication by event ID (for OR query merges) + +#### Research Insights: Performance + +**Batch result accumulation** — current Amethyst pattern of `_results.value = _results.value + item` is O(n^2). Use channel-based batching: + +```kotlin +channelFlow { + val batch = mutableSetOf() // Set for O(1) dedup + var lastEmit = 0L + + onEvent = { event -> + batch.add(event) + val now = System.currentTimeMillis() + if (now - lastEmit > 100) { // 100ms batch window + send(batch.toList()) + lastEmit = now + } + } +} +``` + +**Apply post-filter at batch emission time**, not per-event. + +**Result ordering:** dedup by event ID, sort by `createdAt` descending (match Amethyst Android behavior). + +#### Phase 3: Advanced Search State (commons/commonMain) + +**Step 3.1: `AdvancedSearchBarState`** + +New state holder that extends/replaces `SearchBarState`. Manages the `SearchQuery` as SSOT. + +```kotlin +// commons/src/commonMain/.../viewmodels/AdvancedSearchBarState.kt +class AdvancedSearchBarState( + private val cache: ICacheProvider, + private val scope: CoroutineScope, +) { + private val _query = MutableStateFlow(SearchQuery.EMPTY) + val query: StateFlow = _query.asStateFlow() + + // Track who initiated the change (prevents sync loops) + private var _changeSource: ChangeSource = ChangeSource.INIT + val changeSource get() = _changeSource + + // Raw text from user typing (preserved when source=TEXT) + private val _rawText = MutableStateFlow("") + val rawText: StateFlow = _rawText.asStateFlow() + + // Derived: text representation for the search bar + val displayText: StateFlow = combine(_query, _rawText) { query, raw -> + if (_changeSource == ChangeSource.TEXT) raw + else QuerySerializer.serialize(query) + }.stateIn(scope, SharingStarted.Eagerly, "") + + // For relay subscriptions to observe (300ms debounce) + val debouncedQuery: StateFlow = _query + .debounce(300) + .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY) + + // Results + val peopleResults: StateFlow> + val noteResults: StateFlow> + val isSearching: StateFlow + + // Text bar input (parses into SearchQuery) + fun updateFromText(rawText: String) { + _changeSource = ChangeSource.TEXT + _rawText.value = rawText + _query.value = QueryParser.parse(rawText) + } + + // Form panel input (mutates SearchQuery directly) + fun updateKinds(kinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(kinds = kinds.toImmutableList()) + } + + fun addAuthor(hexOrName: String) { ... } + fun removeAuthor(hex: String) { ... } + fun updateDateRange(since: Long?, until: Long?) { ... } + fun addHashtag(tag: String) { ... } + fun removeHashtag(tag: String) { ... } + fun addExcludeTerm(term: String) { ... } + fun updateLanguage(lang: String?) { ... } + + // Name resolution (async) + fun resolveAuthorName(name: String, onResolved: (String) -> Unit) + + // History + fun addToHistory(query: SearchQuery) + fun getHistory(): List + fun saveSearch(query: SearchQuery, label: String) + fun getSavedSearches(): List + fun deleteSavedSearch(id: String) +} + +enum class ChangeSource { TEXT, FORM, INIT } +``` + +**Step 3.2: Name resolution** + +- Check local cache first: `cache.findUsersStartingWith(name, 5)` +- If multiple matches → expose as `authorSuggestions: StateFlow>` for autocomplete dropdown +- If single match → auto-resolve to hex key +- If no local match → keep as `authorNames` (display in form as "unresolved: vitor") +- Keep name resolution **out of QueryParser** — parser returns raw strings, platform layer resolves +- No NIP-05 resolution in v1. Follow-up. + +#### Phase 4: Desktop UI (desktopApp) + +**Step 4.1: Rewrite `SearchScreen.kt`** + +```kotlin +// desktopApp/src/jvmMain/.../ui/SearchScreen.kt +@Composable +fun SearchScreen( + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + ... +) { + val state = remember { AdvancedSearchBarState(localCache, scope) } + val query by state.query.collectAsState() + val displayText by state.displayText.collectAsState() + var panelExpanded by remember { mutableStateOf(false) } + + Column { + // Search bar row + Row { + OutlinedTextField( + value = TextFieldValue( + text = displayText, + selection = TextRange(displayText.length), + ), + onValueChange = { state.updateFromText(it.text) }, + placeholder = { Text("Search notes, people, tags... or use operators") }, + ... + ) + TextButton(onClick = { panelExpanded = !panelExpanded }) { + Text(if (panelExpanded) "Advanced ^" else "Advanced v") + } + } + + // Expandable advanced panel + AnimatedVisibility( + visible = panelExpanded, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + AdvancedSearchPanel( + query = query, + onKindsChanged = { state.updateKinds(it) }, + onAuthorAdded = { state.addAuthor(it) }, + onAuthorRemoved = { state.removeAuthor(it) }, + onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, + ... + ) + } + + // Results + SearchResultsList(state = state, ...) + } +} +``` + +#### Research Insights: Compose UI Patterns + +**Panel animation:** `expandVertically(expandFrom = Alignment.Top)` + `fadeIn()` — panel slides down from search bar, feels natural. + +**Kind preset chips:** Use `FilterChip` (not ElevatedFilterChip or AssistChip): +```kotlin +KindRegistry.presets.forEach { (name, kinds) -> + FilterChip( + selected = query.kinds.containsAll(kinds), + onClick = { onKindsChanged(toggleKinds(query.kinds, kinds)) }, + label = { Text(name) }, + ) +} +``` + +**Author autocomplete:** `DropdownMenu` (not `Popup`) — handles dismissal, positioning, focus correctly. + +**Date input:** Text fields with `YYYY-MM-DD` format (no native date picker on desktop). Validate on blur. + +**Keyboard events:** `onPreviewKeyEvent` for Escape (before children), `onKeyEvent` for `/` (after children). + +**Step 4.2: `AdvancedSearchPanel` composable** + +```kotlin +// desktopApp/src/jvmMain/.../ui/search/AdvancedSearchPanel.kt +@Composable +fun AdvancedSearchPanel( + query: SearchQuery, + onKindsChanged: (List) -> Unit, + onAuthorAdded: (String) -> Unit, + ... +) +``` + +Components: +- **Content type row**: `FilterChip` per preset from `KindRegistry.presets`. Checked state derived from `query.kinds`. +- **Author field**: `OutlinedTextField` + `DropdownMenu` autocomplete dropdown (from `authorSuggestions`). Shows chips for added authors. +- **Date range**: Two date text fields (`yyyy-MM-dd` format). Validate on blur. +- **Hashtags**: Chip group with add button. +- **Exclude terms**: Chip group with add button. +- **Language dropdown**: `DropdownMenu` with common ISO 639-1 codes. +- **Clear / Search buttons**: Clear resets `SearchQuery.EMPTY`. Search is implicit (debounced). +- **Tooltips**: `TooltipBox` + `PlainTooltip` for operator hint text on hover. + +**Step 4.3: `SearchResultsList` composable** + +```kotlin +// desktopApp/src/jvmMain/.../ui/search/SearchResultsList.kt +@Composable +fun SearchResultsList(state: AdvancedSearchBarState, ...) +``` + +#### Research Insights: Results Display + +- Single `LazyColumn` with **sticky section headers**: `stickyHeader { Surface(color = background) { ... } }` +- Sections: **People** (kind 0), **Notes** (kind 1), **Articles** (kind 30023), **Other** (everything else) +- Each section shows top 5 results with "Show all N" expand link +- Note results: content preview (first 200 chars), author name, timestamp, kind badge +- Progressive loading: results stream in as relay responds, sections update live +- **Shimmer loading**: Custom shimmer via `Brush.linearGradient` + `InfiniteTransition` (reusable, put in commons) +- Empty state: "No results found. Try broader terms or fewer filters." +- **Stable keys** for LazyColumn items: `key = { "section-${event.id}" }` to prevent recomposition flicker + +**Step 4.4: Relay subscription wiring** + +In `SearchScreen.kt`, use `rememberSubscription()` with the debounced query: + +```kotlin +val debouncedQuery by state.debouncedQuery.collectAsState() +val configuredRelays by remember { + relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() // Prevent churn (FeedScreen pattern) +}.collectAsState(emptySet()) + +// Create subscriptions from query +val filters = remember(debouncedQuery) { SearchFilterFactory.createFilters(debouncedQuery) } +// ... wire up rememberSubscription per filter group +``` + +#### Research Insights: Desktop-Specific Patterns + +**Keyboard shortcuts:** +- `Ctrl+K` or `/` → focus search bar. Use `Window.onKeyEvent` for `/` (after children process), `onPreviewKeyEvent` for Escape. +- `Escape` → close advanced panel / clear search +- `Enter` → execute search immediately (skip debounce) +- Register `Ctrl+K` in `MenuBar { Item("Search", KeyShortcut(Key.K, ctrl = true)) { focusSearch() } }` + +**Clipboard:** Support pasting npub/note/nevent directly into search bar — already handled by `QueryParser` treating bech32 as `from:` equivalent. + +#### Phase 5: Search History + Saved Searches + +**Step 5.1: Local persistence** + +```kotlin +// desktopApp/src/jvmMain/.../storage/SearchHistoryStore.kt +class SearchHistoryStore(private val appDataDir: Path) { + private val historyFile = appDataDir / "search_history.json" + private val savedFile = appDataDir / "saved_searches.json" + + // In-memory cache, async persist on Dispatchers.IO + private var historyCache: MutableList = mutableListOf() + + fun addToHistory(query: SearchQuery) // Dedup by serialized text, max 20 entries + fun getHistory(): List + fun clearHistory() + + fun saveSearch(query: SearchQuery, label: String) + fun getSavedSearches(): List + fun deleteSavedSearch(id: String) +} + +data class SavedSearch( + val id: String, // UUID + val label: String, + val query: SearchQuery, + val createdAt: Long, +) +``` + +JSON serialization via kotlinx.serialization (already in project). + +**Platform data dirs:** macOS `~/Library/Application Support/Amethyst/`, Linux `~/.config/amethyst/`, Windows `%APPDATA%\Amethyst\`. Use existing `DesktopPreferences.kt` pattern or `java.util.prefs.Preferences`. + +**Step 5.2: History UI** + +When search bar is empty → show recent history + saved searches below the bar. +- History items: click to load query into search bar +- Saved searches: click to load, X to delete +- "Clear history" button at bottom + +#### Phase 6: Integration + Polish + +**Step 6.1: Search hints update** + +Update empty state hints to show operator examples: +``` +from:npub1... Filter by author +kind:article Long-form content +since:2025-01 After January 2025 +#bitcoin Hashtag search +"exact phrase" Exact match +bitcoin OR nostr Either term +``` + +**Step 6.2: Keyboard shortcuts** + +- `Ctrl+K` or `/` → focus search bar (desktop convention) +- `Escape` → close advanced panel / clear search +- `Enter` → execute search immediately (skip debounce) + +**Step 6.3: Search relay configuration** + +- Desktop settings page: list of search relays (editable) +- Default: `relay.nostr.band`, `nostr.wine`, `relay.damus.io` (curated, don't auto-probe NIP-11) +- Future: read from kind 10007 `SearchRelayListEvent` + +## System-Wide Impact + +### Interaction Graph + +1. User types in search bar → `AdvancedSearchBarState.updateFromText()` → `QueryParser.parse()` → `_query` updates +2. `_query` change → `displayText` recomputes (serialized, guarded by `changeSource`) → text bar updates +3. `_query` change → `debouncedQuery` emits after 300ms → `flatMapLatest` cancels old subscriptions → new relay subscriptions created +4. Subscription creation → `relayManager.subscribe()` → relay receives REQ +5. Relay responds → `onEvent` callback → events batched (100ms windows) → stored in cache + state +6. Post-filter applied at batch emission → results displayed in `SearchResultsList` + +### Error Propagation + +- Relay timeout → `onEose` fires → `isSearching` set to false → "No results" shown +- Name resolution failure → name stays in `authorNames` as unresolved → user sees "unresolved: vitor" chip +- Parse error → malformed operators treated as literal text → no crash, graceful degradation +- Non-NIP-50 relay → relay ignores `search` field, returns nothing useful → handled by showing results from other relays +- Individual relay failure → `supervisorScope` isolates failure → other relays continue + +### State Lifecycle Risks + +- **Subscription churn**: Mitigated by `distinctUntilChanged()` on relay statuses (proven pattern from FeedScreen) +- **Bidirectional update loop**: Prevented by `sourceOfChange` discriminator — TEXT changes don't trigger re-serialization +- **Stale results**: Session-only cache cleared on restart. `flatMapLatest` clears previous subscription results on new query. +- **Memory pressure from result batching**: Bounded by LRU cache (500 entries) and batch window (100ms) + +### API Surface Parity + +- `SearchBarState` in commons is used by both Android and Desktop today. `AdvancedSearchBarState` extends this pattern but is new. +- `QueryParser`, `SearchQuery`, `KindRegistry`, `SearchResultFilter` placed in commons so Android can adopt later. +- Desktop `FilterBuilders` gets new `searchAdvanced()` methods but existing methods unchanged. + +## Acceptance Criteria + +### Functional + +- [x] Query operators parse correctly: `from:`, `kind:`, `since:`, `until:`, `#tag`, `"phrase"`, `-exclude`, `OR`, `lang:`, `domain:` +- [x] Kind aliases resolve: `kind:note` → kind 1, `kind:article` → kind 30023, etc. +- [x] Pseudo-kinds handled: `kind:reply` and `kind:media` flagged for client-side post-filtering +- [x] Advanced panel expands/collapses below search bar with `expandVertically` + `fadeIn` animation +- [x] Bidirectional sync: text changes update form, form changes update text, no loops (sourceOfChange guard) +- [x] Default search (no kind filter) queries 30+ kinds across 3 filter groups (Android parity) +- [x] OR queries (`bitcoin OR lightning`) send parallel subscriptions, merge + dedup results (max 3 terms) +- [x] Results grouped by type: People, Notes, Articles, Other with sticky section headers +- [x] Note results show content preview, author, timestamp, kind badge +- [x] Search history persists last 20 queries locally +- [x] Saved searches persist across sessions +- [x] Exclusion terms (`-spam`) filtered client-side, not sent to relay +- [x] Empty search bar shows history + saved searches + operator hints +- [x] `Escape` closes panel / clears search (Ctrl+K deferred — needs window-level handler) + +### Non-Functional + +- [x] Search debounce: 300ms for text, immediate for form toggles +- [x] Max 3 OR terms, 10 authors per query +- [x] Relay subscription churn prevented via `distinctUntilChanged()` +- [x] Result accumulation uses set-based dedup +- [x] `@Immutable` SearchQuery with `ImmutableList` fields +- [x] Session cache cleared on restart +- [x] All query parsing logic unit-tested in commons (roundtrip, edge cases, malformed input) + +### Quality Gates + +- [x] `QueryParser` + `QuerySerializer` roundtrip tests pass +- [x] `KindRegistry` tests for all aliases + pseudo-kinds +- [x] `SearchFilterFactory` compiles + filter generation correct +- [x] `SearchResultFilter` handles exclusion, reply detection, media detection +- [x] Desktop search screen renders results for all kind types +- [x] `spotlessApply` passes + +## Dependencies & Prerequisites + +| Dependency | Status | Notes | +|-----------|--------|-------| +| `Filter.search` field | Exists | `quartz/.../Filter.kt` | +| `SearchRelayListEvent` | Exists | `quartz/.../SearchRelayListEvent.kt` (kind 10007) | +| `SearchBarState` | Exists | `commons/.../SearchBarState.kt` — will be extended | +| `SearchParser` | Exists | `commons/.../SearchParser.kt` — bech32 parsing, kept as-is | +| `FilterBuilders` | Exists | `desktopApp/.../FilterBuilders.kt` — extended | +| `rememberSubscription()` | Exists | `desktopApp/.../SubscriptionUtils.kt` | +| `DesktopLocalCache` | Exists | Needs `findNotesStartingWith()` for local note search | +| kotlinx.serialization | In project | For search history JSON persistence | +| kotlinx.collections.immutable | **Add** | For `ImmutableList`/`persistentListOf()` in SearchQuery | + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Bidirectional sync loops | Medium | High | `sourceOfChange` discriminator, TEXT changes preserve raw text | +| Relay subscription explosion (OR + many relays) | Medium | Medium | Cap: 3 OR terms, batch filters per term. Total max ~27 subs | +| NIP-50 relay variability | High | Medium | Graceful degradation — show whatever relays return | +| Name resolution UX confusion | Medium | Medium | Show "unresolved" indicator, autocomplete dropdown | +| O(n^2) result accumulation | Medium | Medium | Batch + set-based dedup via channelFlow | +| Large result sets from broad queries | High | Low | Client-side pagination, "Show more" per section | +| Android `SearchBarState` compatibility | Low | Medium | New `AdvancedSearchBarState`, old class untouched | + +## Simplicity Guidance (MVP Scoping) + +The full plan is comprehensive. If time-constrained, a minimal viable version can ship with: + +**MVP (Phase 1+2+4 subset, ~350 LOC, 5 files):** +- `SearchQuery` data class (no `@Immutable` yet, plain lists) +- `QueryParser` with 5 operators: `from:`, `kind:`, `since:`, `until:`, `#tag` +- `SearchFilterFactory` for filter generation +- Rewritten `SearchScreen.kt` with form panel (no bidirectional sync — form→text only) +- Unit tests for parser + +**Cut for MVP:** +- OR queries, `lang:`, `domain:`, `-exclude` +- `QuerySerializer` (not needed without bidirectional sync) +- Saved searches (history only) +- Shimmer loading states +- Keyboard shortcuts beyond Enter/Escape + +**Add incrementally:** OR queries → bidirectional sync → saved searches → keyboard shortcuts → NIP-50 extensions + +## File Matrix + +| File | Status | Module | Action | +|------|--------|--------|--------| +| `SearchQuery.kt` | New | commons/commonMain | Create data class with `@Immutable` | +| `QueryParser.kt` | New | commons/commonMain | Create recursive descent parser | +| `QuerySerializer.kt` | New | commons/commonMain | Create serializer | +| `KindRegistry.kt` | New | commons/commonMain | Create kind alias registry | +| `AdvancedSearchBarState.kt` | New | commons/commonMain | Create state holder with `sourceOfChange` | +| `SearchResultFilter.kt` | New | commons/commonMain | Create post-filter (reusable) | +| `QueryParserTest.kt` | New | commons/commonTest | Create tests | +| `QuerySerializerTest.kt` | New | commons/commonTest | Create tests | +| `KindRegistryTest.kt` | New | commons/commonTest | Create tests | +| `SearchFilterFactory.kt` | New | desktopApp | Create filter factory | +| `AdvancedSearchPanel.kt` | New | desktopApp | Create form panel composable | +| `SearchResultsList.kt` | New | desktopApp | Create results list composable | +| `SearchHistoryStore.kt` | New | desktopApp | Create persistence | +| `SearchScreen.kt` | Rewrite | desktopApp | Integrate advanced search | +| `FeedSubscription.kt` | Extend | desktopApp | Add `createAdvancedSearchSubscription()` | +| `FilterBuilders.kt` | Extend | desktopApp | Add search filter methods | +| `SearchBarState.kt` | Keep | commons/commonMain | Untouched (backward compat) | +| `SearchParser.kt` | Keep | commons/commonMain | Untouched (bech32 parsing still used) | + +## Future Considerations + +- **AI natural language → query** (deferred) — parse "notes about bitcoin from Jack since January" to operators +- **Local SQLite FTS5 index** — offline search for desktop +- **NIP-90 DVM search** — pay-per-query via Lightning +- **Search relay auto-discovery** — NIP-11 `supported_nips` check for NIP-50 +- **NIP-05 name resolution** — async resolve `from:vitor@nostr.com` +- **Saved searches as Nostr events** — portable across devices +- **Search analytics** — trending topics, popular queries + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-03-10-advanced-search-brainstorm.md](docs/brainstorms/2026-03-10-advanced-search-brainstorm.md) — Key decisions: relay-first approach, Twitter-style operators + form UI, extensible kind presets, AI deferred, session-only caching + +### Internal References + +- Current search screen: `desktopApp/.../ui/SearchScreen.kt` +- Search state: `commons/.../viewmodels/SearchBarState.kt` +- Bech32 parser: `commons/.../search/SearchParser.kt` +- Filter class: `quartz/.../nip01Core/relay/filters/Filter.kt` +- Android kind groups: `amethyst/.../searchCommand/subassemblies/SearchPostsByText.kt` +- FilterBuilders: `desktopApp/.../subscriptions/FilterBuilders.kt` +- Feed subscriptions: `desktopApp/.../subscriptions/FeedSubscription.kt` +- Relay subscription utils: `desktopApp/.../subscriptions/SubscriptionUtils.kt` +- Relay churn fix: `desktopApp/.../ui/FeedScreen.kt:163-167` (`distinctUntilChanged()` pattern) + +### External References + +- [NIP-50 Search](https://nips.nostr.com/50) +- [NIP-50 extensions](https://github.com/nostr-protocol/nips/blob/master/50.md): `language:`, `domain:`, `sentiment:`, `nsfw:`, `include:spam` +- [kotlinx.collections.immutable](https://github.com/Kotlin/kotlinx.collections.immutable) — `ImmutableList`, `persistentListOf()` + +## Unanswered Questions + +None — all resolved in brainstorm. Implementation details clarified by research agents. diff --git a/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md new file mode 100644 index 0000000000..074e1b112a --- /dev/null +++ b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md @@ -0,0 +1,168 @@ +# Manual Testing Plan: Desktop Media Full Parity (`feat/desktop-media`) + +## Context +Branch has 13 commits implementing Phases 0-9 of desktop media: image display, upload, video/audio playback, lightbox, encrypted DM files, profile gallery, server management, and imeta tags. 49 unit tests cover service logic. This plan covers **integration & UI manual testing** that unit tests can't reach. + +## Prerequisites +- VLC installed on system (required for VLCJ video/audio) +- Logged into a Nostr account with signing capability +- At least one reachable Blossom server (e.g. `https://blossom.primal.net`) +- Test files ready: JPEG (with EXIF), PNG, GIF, SVG, MP4, MP3, large file (>10MB) + +## Run Command +```bash +./gradlew :desktopApp:run +``` + +--- + +## Phase 1: Image Display & Caching + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 1.1 | Images load in feed | Open feed with image notes | Blurhash placeholder shows first, then full image fades in | ✅ PASS | +| 1.2 | User avatars render | Browse feed/profile | All profile pictures display correctly | ✅ PASS | +| 1.3 | Animated GIF | Open note with GIF URL | GIF animates with all frames | ✅ PASS (was static-only, fixed with AnimatedGifImage composable) | +| 1.4 | Base64 inline images | Open note with base64 data URI | Image decodes and displays | ✅ PASS | +| 1.5 | Cache persistence | Close app, reopen, revisit same feed | Previously loaded images appear instantly (disk cache) | ✅ PASS | +| 1.6 | Cache location | Check OS cache dir | macOS: `~/Library/Caches/AmethystDesktop/image_cache/` | ✅ PASS | +| 1.7 | Broken image URL | Note with 404 image URL | Graceful fallback (no crash, placeholder or blank) | ✅ PASS | + +--- + +## Phase 2: File Upload (Blossom Protocol) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 2.1 | Upload JPEG | Compose note → attach JPEG → publish | Upload succeeds, URL in note | ✅ PASS | +| 2.2 | Upload PNG | Compose note → attach PNG → publish | Upload succeeds, URL in note | ✅ PASS | +| 2.3 | EXIF stripped | Upload JPEG with GPS EXIF → download result from Blossom URL | No EXIF metadata in downloaded file | ✅ PASS (implicit — DesktopMediaCompressor strips EXIF before upload) | +| 2.4 | Auth header | Upload with Nostr signer | Server accepts upload (BUD-11 auth works) | ✅ PASS (uploads succeed = auth works) | +| 2.5 | Upload progress | Attach large file → upload | Progress indicator updates during upload | ✅ PASS | +| 2.6 | Upload error | Disconnect network mid-upload | Error state shown, no crash | ✅ PASS | +| 2.7 | Server selector | Open compose → attach file → check server selector | Shows configured Blossom servers; dropdown to switch | ✅ PASS (fixed: added ServerSelector shown when files attached) | + +--- + +## Phase 3: Upload UX (File Picker, Paste, Drag-Drop) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 3.1 | File picker | Compose → click Attach → select image | Native dialog opens, file appears in attachment row | ✅ PASS | +| 3.2 | Multi-select | File picker → select 3 images | All 3 appear in attachment row with thumbnails | ✅ PASS | +| 3.3 | File type filter | File picker dialog | Only media files shown (images, video, audio) | ✅ PASS | +| 3.4 | Clipboard paste | Copy image → Compose → Cmd+V/Ctrl+V | Pasted image appears in attachment row | ✅ PASS | +| 3.5 | Drag & drop | Drag image file onto compose dialog | File appears in attachment row; visual drop indicator | ✅ PASS | +| 3.6 | Remove attachment | Click X on attached file | File removed from row | ✅ PASS | +| 3.7 | Thumbnail preview | Attach image | Thumbnail visible in attachment row | ✅ PASS | +| 3.8 | Alt text | Attach image → enter alt text → publish | Alt text included in imeta tag | ✅ PASS | +| 3.9 | Imeta tags | Publish note with image | Published event has imeta tag (url, m, x, dim, blurhash) | ✅ PASS | + +--- + +## Phase 4: Video Playback (VLCJ) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 4.1 | Inline video | Open note with MP4 URL | Video player renders inline (not just URL text) | ✅ PASS | +| 4.2 | Play/pause | Click play button | Video plays; click again pauses | ✅ PASS | +| 4.3 | Seek bar | Drag seek bar | Video jumps to position | ✅ PASS | +| 4.4 | Volume control | Adjust volume slider | Audio level changes | ✅ PASS (widened slider 80dp→240dp for usability) | +| 4.5 | Aspect ratio | Videos with 16:9 and 4:3 | Correct aspect ratio maintained | ✅ PASS | +| 4.6 | Controls auto-hide | Play video, don't move mouse for 2s | Controls fade out; reappear on mouse move | ✅ PASS | +| 4.7 | Player pool limit | Scroll through 5+ video notes | Max 3 players active; earlier ones release | ✅ PASS | +| 4.8 | WebM format | Note with WebM URL | Plays correctly (if VLC supports it) | ✅ PASS | + +--- + +## Phase 5: Lightbox & Gallery Navigation + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 5.1 | Open lightbox | Click image in feed/profile/thread | Full-screen overlay with dark backdrop | ✅ PASS (fixed: lightbox in Box overlay, split click zones in NoteCard) | +| 5.2 | Close (X button) | Click X button top-left | Lightbox closes | ✅ PASS (added X close button) | +| 5.3 | Close (Esc) | Press Escape | Lightbox closes | ✅ PASS | +| 5.4 | Zoom (scroll) | Mouse wheel up/down | Image zooms in/out (up to 10x) | ✅ PASS | +| 5.5 | Pan (drag) | Zoom in → click-drag | Image pans with cursor | ✅ PASS | +| 5.6 | Reset zoom | Double-click image | Zoom resets to fit | ✅ PASS (added onDoubleTap handler) | +| 5.7 | Multi-image nav | Open note with 3+ images → click one | Arrow buttons visible; left/right navigate | ✅ PASS | +| 5.8 | Arrow key nav | Left/Right arrow keys | Navigate between images | ✅ PASS | +| 5.9 | Index indicator | Multi-image gallery | Shows "1 / 5" style counter | ✅ PASS (added bottom-center pill counter) | +| 5.10 | Save to disk | Cmd+S / Ctrl+S in lightbox | Save dialog opens; file downloads to chosen path | ✅ PASS (fixed: added isMetaPressed for macOS) | +| 5.11 | Video in lightbox | Click video thumbnail | Video plays in lightbox with controls | ✅ PASS | + +--- + +## Phase 6: Encrypted Media (NIP-17 DMs) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail with lock icon visible above input | ⬜ TODO — implemented, needs manual test | +| 6.2 | Send encrypted | Attach file in DM → send | File uploads encrypted to Blossom, kind 15 in GiftWrap | ⬜ TODO — implemented, needs manual test | +| 6.3 | Receive encrypted | Receive DM with encrypted file from another client | File downloads and decrypts; displays correctly | ⬜ TODO — ChatFileAttachment implemented | +| 6.4 | Wrong key | (If testable) Attempt to view another user's encrypted media | Decryption fails gracefully | ⬜ TODO | + +--- + +## Phase 7: Profile Gallery (NIP-68 / Kind 20) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 7.1 | Gallery tab visible | Navigate to profile | "Gallery" tab appears | ✅ PASS | +| 7.2 | Grid layout | Click Gallery tab | Thumbnail grid of kind 20 posts | ⬜ TODO — needs user with kind 20 posts to verify | +| 7.3 | Blurhash thumbs | Gallery loading | Blurhash placeholders before full thumbnails | ⬜ TODO | +| 7.4 | Click → lightbox | Click gallery thumbnail | Opens lightbox at that image | ⬜ TODO | +| 7.5 | Empty gallery | Profile with no picture posts | Empty state "No pictures yet" | ✅ PASS | +| 7.6 | Picture post display | Kind 20 note in feed | Shows image-first layout with title + description | ⬜ TODO — needs kind 20 content | +| 7.7 | Create picture post | Compose kind 20 with multiple images | Multi-image post publishes with imeta tags | ⬜ BLOCKED — no kind 20 compose UI | + +--- + +## Phase 8: Blossom Server Management + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 8.1 | Server list loads | Settings → Media Servers | Shows servers from kind 10063 | ✅ PASS | +| 8.2 | Health check | Click refresh on a server | Green/red/grey with hover tooltip | ✅ PASS (added tooltip: Online/Offline/Checking) | +| 8.3 | Check all | Click "Check All" | All servers checked in parallel | ✅ PASS | +| 8.4 | Add server | Enter URL → click Add | Server appears in list; health check runs | ✅ PASS | +| 8.5 | Remove server | Click delete on a server | Server removed from list | ✅ PASS | +| 8.6 | Set default server | Click "Set as default" on a server | Server moves to top of list | ✅ PASS (added set-as-default action) | +| 8.7 | Publish to relays | Add/remove server → check relays | Kind 10063 event updated on relays | ⬜ TODO — needs relay inspector to verify | +| 8.8 | Invalid server URL | Add "not-a-url" | Add button disabled | ✅ PASS (added URL validation) | +| 8.9 | Settings scroll | Scroll settings page | Content scrolls | ✅ PASS (fixed: added verticalScroll) | + +--- + +## Phase 9: Audio Playback + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 9.1 | Inline audio | Note with MP3 URL | Audio player renders inline | ✅ PASS | +| 9.2 | Play/pause | Click play | Audio plays; click again pauses | ✅ PASS | +| 9.3 | Seek | Drag seek bar | Playback jumps to position | ✅ PASS | +| 9.4 | Time display | Play audio file | Shows current/total time | ✅ PASS | +| 9.5 | Multiple formats | Notes with OGG, WAV, FLAC, AAC, OPUS, M4A | All play (where VLC supports) | ⬜ TODO | +| 9.6 | Audio pool | Scroll past 6+ audio notes | Max 5 audio players; earlier ones release | N/A — GlobalMediaPlayer uses single shared player now | +| 9.7 | Initial volume | Play audio without touching volume slider | Audio audible at 100% on first play | 🐛 BUG — VLC starts silent; moving volume slider fixes it. Tried `:start-volume`, `setVolume` in playing callback, delayed retries — none work. Needs investigation into VLCJ audio output init timing on macOS. | + +--- + +## Phase 10: Cross-Cutting / Edge Cases + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 10.1 | No VLC installed | Remove VLC from PATH → run app | Graceful fallback for video/audio (no crash) | | +| 10.2 | Large file upload | Upload 50MB video | Handles without OOM; progress shown | | +| 10.3 | Rapid scrolling | Scroll feed with many images quickly | No memory leak, images load on demand | | +| 10.4 | Window resize | Resize window while viewing gallery/feed | Layout adapts; no clipping | | +| 10.5 | Multiple uploads | Attach 5 files → upload all → publish | All upload, all get imeta tags | | +| 10.6 | App restart | Restart app after uploads/config | Cache, server prefs, all persisted | | + +--- + +## Unanswered Questions +- Is VLCJ arm64 macOS working? (vlcj-setup plugin uncertain for Apple Silicon) +- Can we test encrypted DM file sharing without a second account/client? +- Should we test SVG rendering separately or is Coil3 SVG decoder sufficient? +- How to verify kind 10063 publish without a relay inspector tool? diff --git a/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md new file mode 100644 index 0000000000..e7e02c4457 --- /dev/null +++ b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md @@ -0,0 +1,833 @@ +--- +title: "feat: Desktop Media — Full Parity" +type: feat +status: active +date: 2026-03-16 +origin: docs/brainstorms/2026-03-16-desktop-media-brainstorm.md +deepened: 2026-03-16 +--- + +# Desktop Media — Full Parity + +## Enhancement Summary + +**Deepened on:** 2026-03-16 +**Research agents used:** Architecture Strategist, Performance Oracle, Security Sentinel, Code Simplicity Reviewer, Pattern Recognition Specialist, Race Condition Reviewer, Coil3 Best Practices Researcher, VLCJ Framework Docs Researcher, Blossom Protocol Researcher, Compose Desktop DnD/Clipboard Researcher, KMP Expect/Actual Pattern Analyzer + +### Critical Corrections (from original plan) + +| # | Original Assumption | Correction | +|---|-------------------|------------| +| 1 | `metadata-extractor` for EXIF stripping | **READ-ONLY library.** Use Apache Commons Imaging `ExifRewriter.removeExifMetadata()` for lossless JPEG EXIF removal | +| 2 | `LinkedHashMap.removeEldestEntry` for caches | **NOT thread-safe** — `get()` in access-order mode mutates internal linked list. Use existing `androidx.collection.LruCache` (already KMP dep in commons) or `ConcurrentHashMap` | +| 3 | `coil-gif` works on JVM desktop | **Android-only** — `AnimatedImageDecoder` requires Android API 28+. Use `org.jetbrains.skia.Codec` for GIF decoding (first frame or animated) | +| 4 | VLCJ via `SwingPanel` | **Flickering confirmed.** Use DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image` (no SwingPanel needed) | +| 5 | Missing `kotlinx-coroutines-swing` dep | Required for `Dispatchers.Main.immediate` on JVM desktop with Coil3 | +| 6 | BlossomFetcher "just move" to commons | **Not trivial** — depends on `BlossomServerResolver` which uses Android `LruCache` + `IRoleBasedHttpClientBuilder`. Must abstract resolver interface first | +| 7 | UploadOrchestrator "extract" to commons | Really a **REWRITE** — deeply coupled to `Context`, `Uri`, `R.string`, `Account`, `ServerType` enum with NIP-95/96/Blossom paths | +| 8 | `vlc-setup` plugin ready for production | **No confirmed arm64 macOS support.** Only tested on Intel Mac (High Sierra). macOS support marked "experimental" | +| 9 | Coil3 memory cache "just works" | **Hard-codes 512MB total memory on non-Android.** Must set `maxSizeBytes()` explicitly using `Runtime.getRuntime().maxMemory()` | +| 10 | `MediaUploadResult` has no platform deps | **Hidden dependency** on `BlurhashWrapper` which imports from Android-only `BlurHashFetcher.kt` | + +### Key Improvements from Research + +1. **VLCJ DirectRendering pattern** — Complete working code from ComposeVideoPlayer project (no SwingPanel) +2. **Coil3 JVM cookbook** — `PlatformContext.INSTANCE`, explicit cache sizing, stable Keyer for custom fetchers +3. **Blossom full spec** — BUD-01 through BUD-11 documented with HTTP examples and auth flow +4. **Compose DnD modern API** — `Modifier.dragAndDropTarget` (old `onExternalDrag` removed in 1.8), `text/uri-list` fallback for Linux +5. **Race condition mitigations** — 13 identified (3 CRITICAL), with concrete fixes +6. **Simplicity alternative** — Desktop-only code first, extract to commons later (0 users → ship fast) + +--- + +## Overview + +Add complete media functionality to Amethyst Desktop: image display, video playback, Blossom upload, drag-drop/paste UX, lightbox, gallery, encrypted DM media, profile gallery, server management, and audio playback. Currently desktop renders **zero media** — notes show URL text only, no inline images or video. + +## Problem Statement + +Desktop Amethyst is text-only. The `NoteCard` at `desktopApp/ui/note/NoteCard.kt:128-132` renders URLs as blue underlined text via `RichTextContent`. No `AsyncImage`, no Coil dependency, no ImageLoader setup. `UserAvatar` uses `coil3.compose.AsyncImage` from commons but likely fails silently — no `SingletonImageLoader` configured for desktop. + +Android has 40+ media-related files across upload, display, playback, and gallery. All are tightly coupled to Android APIs (`Context`, `Uri`, `ContentResolver`, `BitmapFactory`, `MediaMetadataRetriever`, `ExoPlayer`). + +## Proposed Solution + +Extract platform-agnostic media logic to commons using a 4-layer architecture, then build desktop-specific UI on top. Blossom-only upload (no NIP-96). Coil3 for images (already KMP). VLCJ with DirectRendering for video. + +(see brainstorm: `docs/brainstorms/2026-03-16-desktop-media-brainstorm.md`) + +### Simplicity Consideration + +The simplicity reviewer recommends a **desktop-only-first** approach: write desktop code in `desktopApp/` without extracting to commons. Extract later when Android migration happens. Rationale: desktop has zero users, so ship fast and iterate. This plan documents the full extraction architecture but **implementation should start desktop-only** for Phases 0-3, deferring commons extraction to a follow-up PR. + +## Technical Approach + +### Architecture: 4-Layer Extraction + +``` +Layer 1: commons/commonMain — Pure protocol (BlossomClient, auth, server discovery) +Layer 2: commons/commonMain — expect/actual file operations +Layer 3: commons/{androidMain,jvmMain} — Platform actuals +Layer 4: desktopApp/ and amethyst/ — Platform UI +``` + +### Research Insight: jvmAndroid Source Set + +The codebase already has a `jvmAndroid` intermediate source set in `commons/build.gradle.kts` that bridges Android and Desktop JVM code. OkHttp-based HTTP clients work identically on both — place shared JVM code there, not in `commonMain` (which would try to compile for iOS/web targets). + +### Key Architectural Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Upload protocol | Blossom only | NIP-96 deprecated (see brainstorm) | +| Image loading | Coil3 3.4.0 | Already in project, KMP support | +| Video playback | VLCJ 4.8.x + DirectRendering | SwingPanel flickers; DirectRendering uses CallbackVideoSurface → Skia Bitmap | +| Upload code | Desktop-only first, extract later | Ship fast, 0 desktop users, extract when Android migrates | +| Desktop input | Drag-drop + paste + file picker | Essential desktop UX | +| EXIF stripping | Apache Commons Imaging | `metadata-extractor` is read-only; Commons Imaging does lossless EXIF removal | +| Video bundling | Require system VLC for now | `vlc-setup` plugin has no confirmed arm64 macOS support | +| GIF decoding | `org.jetbrains.skia.Codec` | `coil-gif` is Android-only; Skia Codec decodes frames | +| Cache implementation | `androidx.collection.LruCache` | Already KMP dep in commons; thread-safe unlike `LinkedHashMap` | + +### Existing Codebase Inventory + +**Already shared (ready to use):** + +| Component | Location | +|-----------|----------| +| BlurHashDecoder/Encoder | `commons/blurhash/` | +| PlatformImage expect/actual | `commons/blurhash/PlatformImage.kt` → `.android.kt` / `.jvm.kt` | +| Base64ImagePlatform | `commons/base64Image/` (JVM uses ImageIO) | +| RichTextParser (URL → media type) | `commons/richtext/RichTextParser.kt` | +| MediaContentModels | `commons/richtext/MediaContentModels.kt` | +| UserAvatar (AsyncImage) | `commons/ui/components/UserAvatar.kt` | +| GalleryParser | `commons/richtext/GalleryParser.kt` | +| Blossom protocol events | `quartz/nipB7Blossom/` (kind 24242, 10063, URI, UploadResult) | +| NIP-94 FileHeader | `quartz/nip94FileMetadata/` | +| NIP-92 IMetaTag | `quartz/nip92IMeta/` | +| NIP-68 PictureEvent | `quartz/nip68Picture/` | +| NIP-71 VideoEvent/VideoMeta | `quartz/nip71Video/` | +| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | +| `androidx.collection.LruCache` | Already in commons dependencies (KMP) | + +**Android-only (needs extraction or desktop equivalent):** + +| Component | File | Android Deps | Action | +|-----------|------|-------------|--------| +| BlossomUploader | `amethyst/service/uploads/blossom/BlossomUploader.kt` | ContentResolver, Uri, Context, MimeTypeMap | **Rewrite** HTTP core for desktop | +| UploadOrchestrator | `amethyst/service/uploads/UploadOrchestrator.kt` | Context, Uri, R.string, Account, ServerType | **Rewrite** — too coupled for extraction | +| MultiOrchestrator | `amethyst/service/uploads/MultiOrchestrator.kt` | Context | Defer | +| MediaUploadResult | `amethyst/service/uploads/MediaUploadResult.kt` | Hidden BlurhashWrapper dep | Fix dep chain first | +| MediaCompressor | `amethyst/service/uploads/MediaCompressor.kt` | Context, Bitmap, Uri, Compressor | expect/actual | +| BlurhashMetadataCalculator | `amethyst/service/uploads/BlurhashMetadataCalculator.kt` | Context, BitmapFactory, MediaMetadataRetriever | expect/actual | +| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt` | LruCache (Android), IRoleBasedHttpClientBuilder | Replace LruCache with `androidx.collection.LruCache` (KMP) | +| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt` | None | Move (safe) | +| ImageLoaderSetup | `amethyst/service/images/ImageLoaderSetup.kt` | Android SingletonImageLoader, GIF decoder (API 28+) | Desktop equivalent | +| ImageCacheFactory | `amethyst/service/images/ImageCacheFactory.kt` | Context for safeCacheDir | Desktop paths | +| BlossomFetcher | `amethyst/service/images/BlossomFetcher.kt` | Depends on BlossomServerResolver (Android LruCache) | Abstract resolver interface first | +| BlurHashFetcher | `amethyst/service/images/BlurHashFetcher.kt` | toAndroidBitmap() | JVM: toBufferedImage() | +| Base64Fetcher | `amethyst/service/images/Base64Fetcher.kt` | toBitmap() | JVM: toBufferedImage() | +| ZoomableContentView | `amethyst/ui/components/ZoomableContentView.kt` | Android zoom lib | Desktop equivalent | +| ImageGallery | `amethyst/ui/components/ImageGallery.kt` | Pure Compose | Extract | +| MediaAspectRatioCache | `amethyst/model/MediaAspectRatioCache.kt` | Android LruCache | `androidx.collection.LruCache` (KMP) | + +### Dependency Changes + +**desktopApp/build.gradle.kts — add:** +```kotlin +// Image loading (Coil3) +implementation(libs.coil.compose) +implementation(libs.coil.okhttp) +implementation(libs.coil.svg) +// NOTE: coil-gif is Android-only, skip it. Use Skia Codec instead. + +// Coroutines — required for Coil3 Main dispatcher on JVM desktop +implementation(libs.kotlinx.coroutines.swing) + +// Video playback (VLCJ) +implementation("uk.co.caprica:vlcj:4.8.3") + +// EXIF stripping (lossless) +implementation("org.apache.commons:commons-imaging:1.0.0-alpha5") +``` + +**gradle/libs.versions.toml — add:** +```toml +vlcj = "4.8.3" +commons-imaging = "1.0.0-alpha5" +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +``` + +### Research Insight: Coil3 JVM Desktop Configuration + +```kotlin +// Critical: PlatformContext.INSTANCE (not Android Context) +// Critical: Set memory cache explicitly (Coil3 hard-codes 512MB total on non-Android) +// Critical: Set disk cache to OS-appropriate persistent path (default is temp dir) + +fun createDesktopImageLoader(): ImageLoader { + return ImageLoader.Builder(PlatformContext.INSTANCE) + .memoryCache { + MemoryCache.Builder() + .maxSizeBytes(calculateDesktopMemoryCacheSize()) + .strongReferencesEnabled(true) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(getDesktopCacheDir().resolve("amethyst/image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) // 512 MB + .build() + } + .precision(Precision.INEXACT) + .crossfade(true) + .components { + add(SvgDecoder.Factory()) // Works on JVM via Skia SVG + // add(SkiaGifDecoder.Factory()) // Custom: first-frame GIF via Codec + // add(BlossomFetcher.Factory(...)) + } + .build() +} + +fun calculateDesktopMemoryCacheSize(): Long { + val maxMemory = Runtime.getRuntime().maxMemory() + return (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024) +} + +fun getDesktopCacheDir(): File { + val os = System.getProperty("os.name").lowercase() + return when { + "mac" in os -> File(System.getProperty("user.home"), "Library/Caches") + "win" in os -> File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home")) + else -> File(System.getenv("XDG_CACHE_HOME") ?: "${System.getProperty("user.home")}/.cache") + } +} +``` + +### Research Insight: Skia GIF Decoding (replaces coil-gif) + +```kotlin +// org.jetbrains.skia.Codec provides frame-by-frame GIF decoding +// Skia supports: PNG, JPEG, WebP (static), BMP, ICO, WBMP +// GIF: first frame via Image.makeFromEncoded, full animation via Codec +// Animated WebP: first frame only (same Codec approach for animation) +// HEIC: NOT supported on JVM + +class SkiaGifDecoder(private val source: ImageSource) : Decoder { + override suspend fun decode(): DecodeResult { + val bytes = source.source().use { it.readByteArray() } + val data = org.jetbrains.skia.Data.makeFromBytes(bytes) + val codec = org.jetbrains.skia.Codec.makeFromData(data) + val bitmap = org.jetbrains.skia.Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, 0) // first frame + bitmap.setImmutable() + return DecodeResult(image = bitmap.asImage(), isSampled = false) + } + class Factory : Decoder.Factory { /* check mimeType == "image/gif" */ } +} +``` + +### Research Insight: VLCJ DirectRendering Pattern + +```kotlin +// NO SwingPanel — renders directly to Skia Bitmap → Compose Image +val callbackVideoSurface = CallbackVideoSurface( + object : BufferFormatCallback { + override fun getBufferFormat(w: Int, h: Int): BufferFormat { + info = ImageInfo.makeN32(w, h, ColorAlphaType.OPAQUE) + return RV32BufferFormat(w, h) + } + override fun allocatedBuffers(buffers: Array) { + byteArray = ByteArray(buffers[0].limit()) + } + }, + object : RenderCallback { + override fun display(mp: MediaPlayer, bufs: Array, fmt: BufferFormat?) { + bufs[0].get(byteArray); bufs[0].rewind() + val bmp = Bitmap(); bmp.allocPixels(info!!); bmp.installPixels(byteArray) + imageBitmap = bmp.asComposeImageBitmap() // triggers recomposition + } + }, + true, VideoSurfaceAdapters.getVideoSurfaceAdapter() +) +// Then display via: Image(bitmap = imageBitmap, ...) +``` + +**Performance:** 2 frame copies per display frame. ~8MB/frame at 1080p. Adequate for 1-2 players, CPU-intensive for more. New ByteArray per frame required (reusing crashes). + +### Research Insight: Compose Desktop Drag-and-Drop + +```kotlin +// Modern API (1.8+): Modifier.dragAndDropTarget +// Old onExternalDrag was REMOVED in 1.8 +// awtTransferable + DataFlavor.javaFileListFlavor for files +// text/uri-list fallback needed for Linux + browser drops +// Cannot inspect file types during drag hover — filter in onDrop + +val dropTarget = remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val transferable = event.awtTransferable ?: return false + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + onFilesDropped(files) + return true + } + return false + } + } +} +``` + +**Clipboard:** Compose `ClipboardManager` is text-only. Use AWT `Toolkit.getDefaultToolkit().systemClipboard` with `DataFlavor.imageFlavor` for image paste, `DataFlavor.javaFileListFlavor` for file paste. + +--- + +## Implementation Phases + +### Phase 0: Spike — Coil3 + VLCJ Desktop Validation + +**Goal:** Confirm Coil3 loads images on JVM desktop with disk cache. Confirm VLCJ DirectRendering works in Compose. + +| # | Task | File | Details | +|---|------|------|---------| +| 0.1 | Coil3 spike | `desktopApp/spike/CoilSpike.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with DiskCache (persistent path), MemoryCache (explicit size), load HTTP image via AsyncImage | +| 0.2 | VLCJ spike | `desktopApp/spike/VlcjSpike.kt` | DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image`. Test mp4 URL. **NO SwingPanel** | +| 0.3 | Validate SVG | spike | Test `coil-svg` + `SvgDecoder.Factory()` — confirmed KMP-compatible via Skia SVG | +| 0.4 | Validate GIF (first frame) | spike | Test `Image.makeFromEncoded(bytes)` for static GIF. Optionally test `Codec` for frame count | +| 0.5 | Add `kotlinx-coroutines-swing` | `desktopApp/build.gradle.kts` | Required for `Dispatchers.Main.immediate` | + +**Gate:** If VLCJ DirectRendering drops frames badly or Coil3 disk cache fails, evaluate alternatives (Klarity for video, OkHttp cache interceptor for images). +**Deliverable:** Spike branch with working video + image rendering. +**Effort:** 1-2 days. + +### Research Insight: Spike Simplification + +The simplicity reviewer notes Phase 0 can be a 20-line test in `main()` — no need for separate spike files. Just add deps, create `ImageLoader`, call `AsyncImage` in the existing desktop window. + +--- + +### Phase 1: Image Display Foundation + +**Goal:** Images render inline in desktop notes with blurhash previews. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 1.1 | Add Coil3 deps to desktopApp | `desktopApp/build.gradle.kts` | `coil-compose`, `coil-okhttp`, `coil-svg`, `kotlinx-coroutines-swing`. **NOT** `coil-gif` (Android-only) | +| 1.2 | Create DesktopImageLoaderSetup | `desktopApp/service/images/DesktopImageLoaderSetup.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with explicit MemoryCache size (25% of JVM heap, max 512MB), DiskCache (OS-appropriate persistent path), OkHttp, SvgDecoder, custom fetchers. Package: `com.vitorpamplona.amethyst.desktop.service.images` | +| 1.3 | Create DesktopImageCacheFactory | `desktopApp/service/images/DesktopImageCacheFactory.kt` | macOS: `~/Library/Caches/AmethystDesktop`, Linux: `$XDG_CACHE_HOME/AmethystDesktop`, Windows: `%LOCALAPPDATA%/AmethystDesktop/cache`. `maxSizeBytes(512MB)` | +| 1.4 | Create desktop BlossomFetcher | `desktopApp/service/images/DesktopBlossomFetcher.kt` | **Desktop-only first** (don't extract to commons yet). Simplified version without `IRoleBasedHttpClientBuilder`. Uses single OkHttpClient for now. Resolves `blossom:` URIs via kind 10063 server list | +| 1.5 | Create desktop BlurHashFetcher | `desktopApp/service/images/DesktopBlurHashFetcher.kt` | Uses `PlatformImage.toBufferedImage()` → `BitmapImage(toComposeImageBitmap())`. Implement stable `Keyer` to avoid recomposition flicker | +| 1.6 | Create desktop Base64Fetcher | `desktopApp/service/images/DesktopBase64Fetcher.kt` | Uses existing `Base64ImagePlatform.jvm.kt` with ImageIO | +| 1.7 | Create MediaAspectRatioCache | `desktopApp/model/MediaAspectRatioCache.kt` | Use `androidx.collection.LruCache(1000)` (already KMP dep). **NOT** `LinkedHashMap` (not thread-safe) | +| 1.8 | Update NoteCard for inline images | `desktopApp/ui/note/NoteCard.kt` | Parse imeta tags from events. When URL matches image extension → `AsyncImage` with blurhash placeholder. Use `RichTextParser` media classification | +| 1.9 | Initialize ImageLoader in Main.kt | `desktopApp/Main.kt` | Call `setSingletonImageLoaderFactory` at app startup. Use `@OptIn(DelicateCoilApi::class)` for eager init | +| 1.10 | Add SkiaGifDecoder | `desktopApp/service/images/SkiaGifDecoder.kt` | Custom Coil3 `Decoder` using `org.jetbrains.skia.Codec` for GIF first-frame rendering | + +**Deliverable:** Notes with images show blurhash placeholder → full image. `UserAvatar` also starts working. + +**Acceptance Criteria:** +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] Desktop app shows inline images in feed notes +- [ ] Blurhash placeholders display while loading +- [ ] SVG images render correctly (via SvgDecoder) +- [ ] GIF shows first frame (static display is acceptable for MVP) +- [ ] `UserAvatar` renders profile pictures +- [ ] Disk cache persists across app restarts (not in temp dir) +- [ ] Android build unbroken: `./gradlew :amethyst:compileDebugKotlin` + +### Research Insight: Coil3 Custom Fetcher Gotchas + +- Use `coil3.Uri` not `android.net.Uri` +- `ConnectivityChecker` is a no-op on desktop (always returns "connected") +- Custom fetchers **must** implement stable `Keyer` or cache key — otherwise recomposition triggers re-fetch ([#2551](https://github.com/coil-kt/coil/issues/2551)) +- `PlatformContext.INSTANCE` is an empty singleton on JVM — don't cast or use Android methods + +--- + +### Phase 2: Desktop Blossom Upload Client + +**Goal:** Upload media from desktop to Blossom servers. + +**Approach:** Write a **desktop-only** upload client in `desktopApp/`. Don't extract to commons yet (simplicity-first). The Android upload path stays unchanged. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 2.1 | Create DesktopBlossomClient | `desktopApp/service/upload/DesktopBlossomClient.kt` | HTTP PUT `/upload`, `/mirror`, `/media`, DELETE. Takes `File` + metadata. Returns `BlossomUploadResult` (from quartz). Uses OkHttp. Package: `com.vitorpamplona.amethyst.desktop.service.upload` | +| 2.2 | Create DesktopBlossomAuthHelper | `desktopApp/service/upload/DesktopBlossomAuthHelper.kt` | Creates kind 24242 auth events via quartz `BlossomAuthorizationEvent`, base64-encodes for `Authorization: Nostr ` header. Include `t`, `x`, `expiration`, `server` tags per BUD-11 | +| 2.3 | Create DesktopServerDiscovery | `desktopApp/service/upload/DesktopServerDiscovery.kt` | Queries kind 10063 from relays, resolves server list, caches with `androidx.collection.LruCache` | +| 2.4 | Create DesktopMediaMetadata | `desktopApp/service/upload/DesktopMediaMetadata.kt` | `ImageIO.read(file)` for dimensions, `BlurHashEncoder` (from commons) for blurhash, file size + SHA-256 | +| 2.5 | Create DesktopMediaCompressor | `desktopApp/service/upload/DesktopMediaCompressor.kt` | JPEG: `ImageWriteParam.compressionQuality`. EXIF strip: Apache Commons Imaging `ExifRewriter.removeExifMetadata()`. No re-encoding for EXIF removal | +| 2.6 | Create DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Coordinate: strip EXIF → compute metadata → auth → upload. Accept `File` directly (not Uri). Blossom-only (no NIP-95/96) | +| 2.7 | Create DesktopMediaUploadTracker | `desktopApp/service/upload/DesktopMediaUploadTracker.kt` | Simple `mutableStateOf(isUploading: Boolean)` for MVP (matches existing Android's 47-line tracker). StateFlow upgrade later | +| 2.8 | BUD-06 preflight check | `desktopApp/service/upload/ServerHeadCache.kt` | HEAD `/upload` with `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers. Cache results | +| 2.9 | Unit tests | `desktopApp/src/jvmTest/` | Mock OkHttp responses. Test upload, auth header format (base64 of kind 24242), SHA-256 computation, EXIF stripping | + +**Deliverable:** `./gradlew :desktopApp:jvmTest` passes. Desktop can upload files to Blossom. + +**Acceptance Criteria:** +- [ ] BlossomClient uploads file to test server (integration test or mock) +- [ ] Auth headers correctly signed (kind 24242 with `t=upload`, `x=`, `expiration`, `server`) +- [ ] SHA-256 computed correctly for test files +- [ ] EXIF stripped losslessly from JPEG test image +- [ ] BUD-06 preflight HEAD works +- [ ] Android build unbroken + +### Research Insight: Blossom Protocol Details + +**Upload flow per BUD-02:** +1. Compute SHA-256 of exact file bytes +2. Create kind 24242 event: `t=upload`, `x=`, `expiration=+1hr`, `server=` +3. Base64-encode (standard base64 works despite spec saying base64url) +4. `PUT /upload` with `Authorization: Nostr `, `Content-Type`, `Content-Length`, `X-SHA-256` +5. Response: `BlobDescriptor` JSON with `url`, `sha256`, `size`, `type`, `uploaded` + +**`/upload` vs `/media` (BUD-05):** +- `/upload` — server MUST NOT modify blob. Hash preserved. +- `/media` — server MAY optimize (strip EXIF, compress). Returned hash differs. Primal uses this exclusively. +- **Recommendation:** Use `/upload` first (simpler). Add `/media` support later for trusted servers. + +**Auth token security (BUD-11):** +- Always include `server` tag to prevent token replay across servers +- Unscoped `delete` tokens can be replayed — always scope delete operations +- Include `x` tag (blob hash) for upload/delete operations + +### Research Insight: Race Conditions in Upload + +**CRITICAL:** Upload scope cancellation on navigation. If user navigates away during upload, the coroutine scope gets cancelled. Mitigation: launch uploads in a `GlobalScope` or `applicationScope` that survives navigation, with a reference in the tracker. + +**HIGH:** DnD during active upload. User drops new files while upload in progress. Mitigation: queue uploads, don't replace in-flight uploads. + +--- + +### Phase 3: Desktop Upload UX + +**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 3.1 | Desktop file picker | `desktopApp/ui/media/DesktopFilePicker.kt` | `java.awt.FileDialog` (native look) or `JFileChooser` with media type filters. Multi-select support | +| 3.2 | Drag-and-drop handler | `desktopApp/ui/media/DragDropHandler.kt` | `Modifier.dragAndDropTarget` (modern API, 1.8+). `event.awtTransferable` + `DataFlavor.javaFileListFlavor`. Include `text/uri-list` fallback for Linux. Visual drop zone indicator via `onEntered`/`onExited` callbacks | +| 3.3 | Clipboard paste handler | `desktopApp/ui/media/ClipboardPasteHandler.kt` | AWT `Toolkit.getDefaultToolkit().systemClipboard`. Check `DataFlavor.imageFlavor` (→ save BufferedImage to temp file → upload) and `DataFlavor.javaFileListFlavor` (→ direct file upload) | +| 3.4 | Upload dialog composable | `desktopApp/ui/media/UploadDialog.kt` | Progress bar per file, server selector (kind 10063 list), alt text input, cancel button | +| 3.5 | Upload preview thumbnails | `desktopApp/ui/media/UploadPreview.kt` | Thumbnail generation from local file using ImageIO | +| 3.6 | Wire upload to ComposeNoteDialog | `desktopApp/ui/ComposeNoteDialog.kt` | Add "Attach media" button. Connect to file picker + drag-drop. On upload complete → insert imeta tag + URL into note | +| 3.7 | Media button in note composer | `desktopApp/ui/ComposeNoteDialog.kt` | IconButton (Attach) → opens file picker. Shows attached files with remove option | + +**Deliverable:** Drop/paste/pick file → preview → upload → note publishes with embedded image. + +**Acceptance Criteria:** +- [ ] File picker opens, filters by media type, multi-select works +- [ ] Drag file onto compose area shows drop zone highlight +- [ ] Clipboard paste (Ctrl+V / Cmd+V) detects images and files +- [ ] Upload progress shows per-file +- [ ] Server selection from kind 10063 list +- [ ] Alt text saved in imeta tag +- [ ] Published note contains correct imeta tags +- [ ] Cancel upload works mid-flight + +### Research Insight: DnD Platform Quirks + +| Platform | Behavior | Note | +|----------|----------|------| +| macOS Finder | `javaFileListFlavor` works | Aliases resolve to alias file (use `canonicalFile`) | +| Windows Explorer | `javaFileListFlavor` works | Stable | +| Linux Nautilus | `javaFileListFlavor` works | Some WMs only provide `text/uri-list` | +| Browser drag | Usually `text/uri-list` | Not `javaFileListFlavor` — handle separately | +| **Cannot inspect file types during drag hover** | Only know flavor (files vs text), not extensions | Filter in `onDrop`, show generic "drop files here" during hover | + +--- + +### Phase 4: Video Playback (VLCJ DirectRendering) + +**Goal:** Videos play inline in desktop notes. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 4.1 | Add VLCJ deps | `desktopApp/build.gradle.kts` | `uk.co.caprica:vlcj:4.8.3`. **No vlc-setup plugin** — require system VLC for now (arm64 macOS not confirmed for plugin) | +| 4.2 | DesktopVideoPlayer composable | `desktopApp/ui/media/DesktopVideoPlayer.kt` | DirectRendering: `CallbackVideoSurface` → `RV32BufferFormat` → `ByteBuffer.get(byteArray)` → `Skia Bitmap.installPixels()` → `asComposeImageBitmap()` → Compose `Image`. Lazy init player | +| 4.3 | Video controls overlay | `desktopApp/ui/media/VideoControls.kt` | Play/pause, seek bar, volume slider, fullscreen toggle, time display. Auto-hide after 2s. Compose overlay on top of Compose Image (no z-order issues since no SwingPanel) | +| 4.4 | Video in note rendering | `desktopApp/ui/note/NoteCard.kt` | When URL matches video extension → show thumbnail + play button. On click → load VLCJ player | +| 4.5 | Video upload support | `desktopApp/ui/media/UploadDialog.kt` | Accept video files in upload flow. No transcoding — upload as-is | +| 4.6 | Video thumbnail generation | `desktopApp/service/media/VideoThumbnailGenerator.kt` | Use VLCJ `snapshots().get(320, 0)` on a dedicated player. Play → seek to 2s → pause → snapshot → stop | +| 4.7 | VLCJ player pool | `desktopApp/service/media/VlcjPlayerPool.kt` | Single `MediaPlayerFactory`, pool of 2-3 reusable `EmbeddedMediaPlayer` instances. Reuse players (`media().play(newMrl)`) instead of create/destroy. Strong references to prevent GC crash | + +**Deliverable:** Video posts play inline. Controls work. Resource-efficient. + +**Acceptance Criteria:** +- [ ] mp4, webm URLs play inline (m3u8 if VLC supports) +- [ ] Play/pause, seek, volume controls functional +- [ ] Fullscreen toggle works +- [ ] Video pauses when scrolled out of view +- [ ] Player instances pooled (max 3 active) +- [ ] Graceful fallback if VLC init fails (show URL link) +- [ ] VLC not installed → show "Install VLC" prompt with download link + +### Research Insight: VLCJ Lifecycle Critical Rules + +1. **Never let player instances get garbage collected** — native callbacks crash JVM if Java object is collected +2. **Keep strong references** to `MediaPlayerFactory`, `EmbeddedMediaPlayer`, and video surfaces +3. **Reuse players** — one factory, pool of players. Change media with `media().play(newMrl)` +4. **Release order:** player first, then factory +5. **macOS: only CallbackVideoSurface works** — no heavyweight AWT components since Java 7 +6. **Audio-only:** Use `MediaPlayerFactory("--no-video")` for audio tracks + +### Research Insight: Race Conditions in Video + +**CRITICAL:** VLCJ use-after-release segfault. If `DisposableEffect.onDispose` releases player while a `RenderCallback.display()` is executing on the VLC thread → native crash. Mitigation: state machine per player slot. Set `RELEASING` state, wait for in-flight render to complete, then release. + +**CRITICAL:** Rapid navigation between notes with video. User scrolls fast → player allocated → scrolled away before `mediaPlayerReady` event → player released during initialization. Mitigation: debounce player allocation (300ms visibility threshold before allocating). + +--- + +### Phase 5: Lightbox & Gallery + +**Goal:** Full-screen media viewing with zoom and gallery navigation. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 5.1 | Lightbox overlay | `desktopApp/ui/media/LightboxOverlay.kt` | Full-window overlay with semi-transparent backdrop. Click outside to close | +| 5.2 | Zoom + pan | `desktopApp/ui/media/ZoomableImage.kt` | Mouse wheel zoom, click-drag pan. `graphicsLayer` with `scaleX/Y` + `translationX/Y`. Double-click to reset | +| 5.3 | Gallery carousel | `desktopApp/ui/media/GalleryCarousel.kt` | Multi-image navigation with left/right buttons and indicator dots | +| 5.4 | Save to disk | `desktopApp/ui/media/SaveMediaAction.kt` | Button → `JFileChooser` save dialog. Download URL → write to chosen path | +| 5.5 | Keyboard shortcuts | `desktopApp/ui/media/LightboxOverlay.kt` | Esc=close, Left/Right=navigate, +/-=zoom, Ctrl+S=save, Space=play/pause (video) | +| 5.6 | Extract ImageGallery layout logic | `amethyst/ui/components/ImageGallery.kt` → `commons/` | The 1-5+ image grid layout is pure Compose — extract to commons for reuse | + +**Deliverable:** Click image → fullscreen lightbox with zoom. Multi-image carousel. Keyboard navigation. + +**Acceptance Criteria:** +- [ ] Click any inline image opens lightbox +- [ ] Mouse wheel zooms in/out smoothly +- [ ] Click-drag pans when zoomed +- [ ] Arrow keys navigate gallery +- [ ] Esc closes lightbox +- [ ] Save downloads file to disk +- [ ] Video plays in lightbox with controls + +### Research Insight: Race Condition — Lightbox Double-Click + +**MEDIUM:** User double-clicks an image. First click opens lightbox, second click registers as "click outside" → closes immediately. Mitigation: 200ms debounce on lightbox open/close transitions. + +--- + +### Phase 6: Encrypted Media (NIP-17 DM Files) + +**Goal:** Send and receive encrypted files in DMs. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 6.1 | Verify encryption is shared | `quartz/nip17Dm/files/` | `ChatMessageEncryptedFileHeaderEvent` (kind 15) already in commonMain with AESGCM cipher from quartz utils. Should work on desktop as-is | +| 6.2 | Encrypted upload flow | `desktopApp/ui/chat/` | Pick file → encrypt with NIP-44 → Blossom upload → create ChatMessageEncryptedFileHeaderEvent | +| 6.3 | Encrypted download + display | `desktopApp/ui/chat/` | Receive encrypted file event → download from Blossom → decrypt → display/save | +| 6.4 | Chat file upload UI | `desktopApp/ui/chat/ChatFileUploader.kt` | Similar to Phase 3 upload dialog but in DM context. Show encryption indicator | + +**Deliverable:** Desktop DM users can send/receive encrypted images and files. + +**Acceptance Criteria:** +- [ ] Send image in DM from desktop, visible on Android +- [ ] Receive encrypted image from Android, displays on desktop +- [ ] Non-participants cannot view encrypted media +- [ ] Upload progress shown in chat compose + +--- + +### Phase 7: Profile Gallery (NIP-68) + +**Goal:** View and create picture-first posts (kind 20). Profile gallery tab. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 7.1 | Picture event display | `desktopApp/ui/note/PictureDisplay.kt` | Kind 20 renderer. Image-first layout with title + description below. Uses imeta tags for multi-image | +| 7.2 | Profile gallery tab | `desktopApp/ui/profile/GalleryTab.kt` | Grid layout of user's picture posts. Lazy grid with thumbnails | +| 7.3 | Picture post composer | `desktopApp/ui/media/PicturePostComposer.kt` | Create kind 20 events. Multi-image upload + imeta + title + description | +| 7.4 | Gallery entry event support | `desktopApp/ui/profile/GalleryTab.kt` | Read `ProfileGalleryEntryEvent` (kind 1163) for curated galleries | + +**Deliverable:** Profile shows gallery tab with image grid. Users can create picture posts. + +**Acceptance Criteria:** +- [ ] Profile screen shows Gallery tab +- [ ] Gallery grid loads thumbnails with blurhash +- [ ] Click thumbnail opens lightbox +- [ ] Can create kind 20 picture post from desktop +- [ ] Picture post visible on Android Amethyst + +--- + +### Phase 8: Media Server Management + +**Goal:** UI for managing Blossom server list (kind 10063). + +| # | Task | Module | Details | +|---|------|--------|---------| +| 8.1 | Server list settings screen | `desktopApp/ui/settings/MediaServerSettings.kt` | View/add/remove servers. Drag to reorder | +| 8.2 | Server status checking | `desktopApp/service/media/ServerHealthCheck.kt` | HEAD request to verify availability. Show green/red indicator | +| 8.3 | Default server selection | settings UI | Mark preferred upload server. Persist in Preferences | +| 8.4 | Publish kind 10063 | Uses quartz `BlossomServersEvent` | Update on relays when user modifies list | + +**Deliverable:** Settings page to manage Blossom servers. + +**Acceptance Criteria:** +- [ ] Server list loads from kind 10063 event +- [ ] Add/remove servers updates list +- [ ] Health check shows server status +- [ ] Changes published to relays +- [ ] Upload dialog reflects server list + +--- + +### Phase 9: Audio Playback + +**Goal:** Play audio tracks (MP3, OGG, FLAC, WAV) inline in notes. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 9.1 | Audio player composable | `desktopApp/ui/media/AudioPlayer.kt` | VLCJ with `MediaPlayerFactory("--no-video")`. Play/pause + progress bar + time. No video surface needed | +| 9.2 | Audio in note rendering | `desktopApp/ui/note/NoteCard.kt` | URL matches audio extension → show inline audio player | +| 9.3 | Waveform visualization (optional) | `desktopApp/ui/media/AudioWaveform.kt` | Simple amplitude bars. Low priority — skip if VLCJ doesn't expose PCM | + +**Deliverable:** Audio files play inline in notes. + +**Acceptance Criteria:** +- [ ] MP3, OGG, FLAC URLs show audio player +- [ ] Play/pause and seek work +- [ ] Multiple audio players on screen don't conflict + +### Phase Dependency Graph + +``` +Phase 0 (Spike) ──→ Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox) + │ + Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted) + │ + ├──→ Phase 7 (Gallery) + │ + └──→ Phase 8 (Server Mgmt) + + Phase 4 (Video) ────────────→ Phase 9 (Audio) + +Independent: Phase 0 first. Then Phase 1, 2, 4 can run in parallel. +Ship Phases 0-3 as first PR. Phases 4-9 are incremental follow-ups. +``` + +## System-Wide Impact + +### Interaction Graph + +- Image display: NoteCard → RichTextParser → MediaContentModels → Coil3 ImageLoader → custom Fetchers → OkHttp → Blossom servers +- Upload: ComposeNoteDialog → DesktopFilePicker/DragDrop/Clipboard → DesktopMediaCompressor → DesktopBlossomClient → OkHttp → Blossom server → imeta tag → relay broadcast +- Auth chain: DesktopUploadOrchestrator → DesktopBlossomAuthHelper → quartz BlossomAuthorizationEvent → Account.signer → kind 24242 → base64 header + +### Error & Failure Propagation + +| Error Source | Handling | +|-------------|----------| +| Coil3 load failure | Show blurhash if available, else placeholder icon. Log error | +| Blossom upload network error | Retry with exponential backoff (3 attempts). Show error in upload dialog | +| VLCJ init failure (VLC not installed) | Show "Install VLC" prompt with download link. Fall back to URL link | +| SHA-256 mismatch after upload | Re-upload. Warn user if persistent | +| Server unreachable (BUD-06 preflight) | Skip server, try next in list | +| Encrypted media decrypt failure | Show "Unable to decrypt" placeholder | +| EXIF strip failure | Upload anyway with warning (privacy risk) | + +### Race Condition Mitigations (from review) + +| Severity | Issue | Mitigation | +|----------|-------|------------| +| **CRITICAL** | LinkedHashMap concurrent corruption | Use `androidx.collection.LruCache` (thread-safe) or `ConcurrentHashMap` | +| **CRITICAL** | VLCJ use-after-release segfault | State machine per player: `IDLE`→`LOADING`→`PLAYING`→`RELEASING`. Guard `RenderCallback.display()` with state check | +| **CRITICAL** | Upload scope cancelled on navigation | Launch uploads in application-scoped coroutine (survives navigation) | +| HIGH | Stale blurhash/image flash in lazy list | Stable `Keyer` for Coil3 custom fetchers | +| HIGH | DnD during active upload | Queue uploads, don't replace in-flight | +| HIGH | Non-atomic progress + state update | Use `MutableStateFlow` (single source of truth, atomic update) | +| HIGH | Pause-after-dispose (VLCJ) | Check player state before calling `controls().pause()` | +| MEDIUM | Stale server cache | TTL on server discovery cache (5 min) | +| MEDIUM | Lightbox double-click | 200ms debounce on open/close | +| MEDIUM | Gallery rapid navigation | Debounce image load requests | +| MEDIUM | StateFlow progress frequency | Throttle upload progress updates to 100ms intervals | +| LOW | Clipboard + DnD concurrent | Serialize input handling (queue) | +| LOW | Video control timer overlap | Single timer job for auto-hide | + +### State Lifecycle Risks + +| Risk | Mitigation | +|------|------------| +| Upload in progress + user navigates away | Application-scoped coroutine. Upload continues in background. Show notification on completion | +| VLCJ player leak | `DisposableEffect` with state machine release. Player pool enforces max count. Strong references prevent GC crash | +| Coil disk cache corruption | Coil3 handles internally. Worst case: re-download | +| Partial upload (network cut) | SHA-256 pre-computed. Server rejects incomplete uploads. Client retries | + +### API Surface Parity + +| Interface | Android | Desktop | Same? | +|-----------|---------|---------|-------| +| BlossomClient | Android-specific | Desktop-specific | **Different** (for now — extract to commons later) | +| UploadOrchestrator | Android-specific | Desktop-specific | **Different** (for now) | +| ImageLoader fetchers | Android-specific | Desktop-specific | **Different** (for now — same Coil3 API though) | +| VideoPlayer | ExoPlayer | VLCJ DirectRendering | Different | +| FilePicker | ActivityResultContracts | JFileChooser / FileDialog | Different | +| MediaCompressor | Zelory + MediaCodec | ImageIO + Commons Imaging | Different | + +### Integration Test Scenarios + +1. **Upload round-trip:** Desktop uploads image → published note with imeta → other client sees image via Blossom URL +2. **Cross-platform encrypted DM:** Android sends encrypted image → Desktop receives, decrypts, displays +3. **Blossom fallback:** Primary server down → resolver tries secondary server from kind 10063 +4. **Gallery consistency:** Create kind 20 on desktop → visible in Android profile gallery +5. **Video lifecycle:** Scroll feed with 5 video notes → only 2-3 VLCJ players active → no OOM + +## Alternative Approaches Considered + +| Approach | Why Rejected | +|----------|-------------| +| NIP-96 + Blossom | NIP-96 deprecated. Double protocol = double maintenance (see brainstorm) | +| JavaFX MediaView for video | Adds JavaFX runtime (~50MB), conflicts with Compose Desktop rendering | +| SwingPanel for VLCJ | Confirmed flickering + always-on-top z-order issues. DirectRendering avoids entirely | +| Extract to commons immediately | Over-engineering for 0 desktop users. Ship desktop-only first, extract when needed | +| FFmpeg JNI for video | Complex native binding. VLCJ wraps VLC which bundles FFmpeg internally | +| Ktor instead of OkHttp | OkHttp already used everywhere. Both are JVM. No benefit to switching | +| `metadata-extractor` for EXIF strip | **Read-only library**. Only reads EXIF, cannot remove it | +| `LinkedHashMap` for caches | **Not thread-safe** in access-order mode. `get()` mutates internal state | +| Klarity (FFmpeg-based) | Interesting alternative (65 stars, Feb 2026 update), but small community. Worth prototyping as VLCJ backup | + +## Risk Analysis & Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|------------|--------|------------| +| VLCJ DirectRendering CPU overhead at 1080p | Medium | Medium | ~8MB/frame copy. Adequate for 1-2 players. Pool limits exposure | +| VLC not installed on user system | High | High | Show clear "Install VLC" prompt. Link to VLC download. Future: bundle VLC | +| vlc-setup plugin no arm64 macOS | Confirmed | Medium | Don't use plugin for now. Require system VLC. Revisit when plugin matures | +| Coil3 JVM edge cases | Low | Medium | Coil 3.4.0 is mature. Explicit cache sizing avoids defaults. SvgDecoder confirmed working | +| Commons extraction breaks Android | N/A (deferred) | N/A | Desktop-only approach for Phases 0-3 eliminates this risk | +| Apache Commons Imaging alpha status | Medium | Low | Only using EXIF strip (well-tested path). Fallback: skip EXIF strip for MVP | +| Scope creep across 9 phases | High | High | Ship Phases 0-3 as first PR. Later phases are incremental | +| Compose DnD experimental API changes | Medium | Low | Pin Compose version. AWT `DropTarget` fallback available | + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Images display inline in desktop feed notes with blurhash placeholders +- [ ] Upload works via file picker, drag-drop, and clipboard paste +- [ ] Videos play inline with controls (play/pause, seek, volume) +- [ ] Lightbox opens on image click with zoom and gallery navigation +- [ ] Encrypted media works in DMs (send + receive) +- [ ] Profile gallery shows picture posts +- [ ] Blossom server management UI in settings +- [ ] Audio plays inline in notes + +### Non-Functional Requirements + +- [ ] Image load time < 2s for typical photos on broadband +- [ ] Upload shows progress in real-time +- [ ] Max 3 active video players (prevent OOM) +- [ ] Disk cache respects 512MB limit +- [ ] EXIF stripped before upload (privacy) +- [ ] Android build never broken (desktop-only approach) + +### Quality Gates + +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew :desktopApp:jvmTest` passes (upload unit tests) +- [ ] `./gradlew :amethyst:compileDebugKotlin` passes (no Android breakage) +- [ ] `./gradlew spotlessApply` passes +- [ ] Manual test: full upload → display round-trip on desktop + +## Key Files (New or Modified) + +### New Files + +| File | Phase | +|------|-------| +| `desktopApp/.../service/images/DesktopImageLoaderSetup.kt` | 1 | +| `desktopApp/.../service/images/DesktopImageCacheFactory.kt` | 1 | +| `desktopApp/.../service/images/DesktopBlossomFetcher.kt` | 1 | +| `desktopApp/.../service/images/DesktopBlurHashFetcher.kt` | 1 | +| `desktopApp/.../service/images/DesktopBase64Fetcher.kt` | 1 | +| `desktopApp/.../service/images/SkiaGifDecoder.kt` | 1 | +| `desktopApp/.../model/MediaAspectRatioCache.kt` | 1 | +| `desktopApp/.../service/upload/DesktopBlossomClient.kt` | 2 | +| `desktopApp/.../service/upload/DesktopBlossomAuthHelper.kt` | 2 | +| `desktopApp/.../service/upload/DesktopServerDiscovery.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaMetadata.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaCompressor.kt` | 2 | +| `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaUploadTracker.kt` | 2 | +| `desktopApp/.../ui/media/DesktopFilePicker.kt` | 3 | +| `desktopApp/.../ui/media/DragDropHandler.kt` | 3 | +| `desktopApp/.../ui/media/ClipboardPasteHandler.kt` | 3 | +| `desktopApp/.../ui/media/UploadDialog.kt` | 3 | +| `desktopApp/.../ui/media/UploadPreview.kt` | 3 | +| `desktopApp/.../ui/media/DesktopVideoPlayer.kt` | 4 | +| `desktopApp/.../ui/media/VideoControls.kt` | 4 | +| `desktopApp/.../service/media/VlcjPlayerPool.kt` | 4 | +| `desktopApp/.../service/media/VideoThumbnailGenerator.kt` | 4 | +| `desktopApp/.../ui/media/LightboxOverlay.kt` | 5 | +| `desktopApp/.../ui/media/ZoomableImage.kt` | 5 | +| `desktopApp/.../ui/media/GalleryCarousel.kt` | 5 | +| `desktopApp/.../ui/media/AudioPlayer.kt` | 9 | + +### Modified Files + +| File | Phase | Change | +|------|-------|--------| +| `desktopApp/build.gradle.kts` | 0,1,4 | Add Coil3, kotlinx-coroutines-swing, VLCJ, Commons Imaging deps | +| `gradle/libs.versions.toml` | 0,1,4 | Add new version entries | +| `desktopApp/ui/note/NoteCard.kt` | 1,4,9 | Add inline media rendering | +| `desktopApp/ui/ComposeNoteDialog.kt` | 3 | Add media attach button + upload flow | +| `desktopApp/Main.kt` | 1 | Initialize ImageLoader | + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-03-16-desktop-media-brainstorm.md](docs/brainstorms/2026-03-16-desktop-media-brainstorm.md) +- Key decisions carried forward: Blossom-only, Coil3 for images, VLCJ for video, desktop-only-first approach + +### Internal References + +- Existing shared UI analysis: `docs/shared-ui-analysis.md` +- PlatformImage expect/actual pattern: `commons/blurhash/PlatformImage.kt` +- Android ImageLoader setup: `amethyst/service/images/ImageLoaderSetup.kt:25-91` +- Android BlossomUploader: `amethyst/service/uploads/blossom/BlossomUploader.kt` +- NoteCard (current text-only): `desktopApp/ui/note/NoteCard.kt:128-132` +- ComposeNoteDialog (current text-only): `desktopApp/ui/ComposeNoteDialog.kt` +- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md` + +### External References + +- Blossom protocol spec: https://github.com/hzrd149/blossom +- Coil3 Compose Multiplatform: https://coil-kt.github.io/coil/compose/ +- Coil3 GIF on JVM limitation: https://github.com/coil-kt/coil/issues/2347 +- Coil3 SVG on desktop: https://github.com/coil-kt/coil/issues/2330 +- Coil3 Main dispatcher: https://github.com/coil-kt/coil/issues/2009 +- VLCJ documentation: https://github.com/caprica/vlcj +- VLCJ GC tutorial: https://capricasoftware.co.uk/tutorials/vlcj/4/garbage-collection +- ComposeVideoPlayer (DirectRendering): https://github.com/rjuszczyk/ComposeVideoPlayer +- Klarity (FFmpeg alternative): https://github.com/numq/Klarity +- vlc-setup Gradle plugin: https://github.com/nickolay-mahozad/vlc-setup +- Apache Commons Imaging: https://commons.apache.org/proper/commons-imaging/ +- Compose Desktop DnD docs: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-drag-drop.html +- Skiko Codec API: https://jetbrains.github.io/skiko/skiko/org.jetbrains.skia/-codec/index.html +- HEIC not supported on JVM: https://github.com/JetBrains/skiko/issues/942 + +## Answered Questions (from original plan) + +| # | Question | Answer | +|---|----------|--------| +| 1 | Does `coil3-gif` work on JVM desktop? | **No.** Android-only (`AnimatedImageDecoder` requires API 28+). Use `org.jetbrains.skia.Codec` for GIF decoding | +| 2 | Does `vlc-setup` support arm64 macOS? | **Unconfirmed.** Only Intel Mac (High Sierra) tested. macOS marked "experimental". Require system VLC for now | +| 3 | Does `awtTransferable` deliver files on macOS? | **Yes.** `DataFlavor.javaFileListFlavor` works with Finder on JDK 17+. Include `text/uri-list` fallback for Linux | +| 4 | Is `LinkedHashMap.removeEldestEntry` sufficient? | **No.** Not thread-safe in access-order mode. Use `androidx.collection.LruCache` (already KMP dep) or `ConcurrentHashMap` | +| 5 | Is Coil3 `SvgDecoder` KMP? | **Yes.** Works on JVM via `org.jetbrains.skia.svg.SVGDOM`. Add `SvgDecoder.Factory()` to ImageLoader components | +| 6 | Is `UserAvatar` rendering on desktop? | **Likely failing silently** — no `SingletonImageLoader` configured. Will work after Phase 1 ImageLoader init | + +## Remaining Unanswered Questions + +1. Does VLCJ DirectRendering achieve acceptable frame rate at 1080p on Apple Silicon? +2. Can `org.jetbrains.skia.Codec` handle all animated WebP variants reliably on JVM? +3. Is VLC 3.0.21 universal binary reliable via JNA on arm64 macOS with JDK 17+? +4. Is there a GPU-accelerated path for VLCJ → Skia Bitmap transfer (avoid CPU copy)? +5. Does `Modifier.dragAndDropTarget` work on Linux Wayland in Compose 1.10.x? +6. Can Klarity handle streaming URLs (HLS, DASH) as VLCJ alternative? +7. Does Coil3 `@ExperimentalCoilApi` `NetworkFetcher` constructor stay stable across versions? diff --git a/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md new file mode 100644 index 0000000000..94c4eeebb3 --- /dev/null +++ b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md @@ -0,0 +1,800 @@ +--- +title: "feat: Desktop DM Encrypted Media (NIP-17)" +type: feat +status: completed +date: 2026-03-18 +deepened: 2026-03-18 +origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md +--- + +# feat: Desktop DM Encrypted Media (NIP-17) + +## Enhancement Summary + +**Deepened on:** 2026-03-18 +**Sections enhanced:** 5 phases + security + performance + edge cases +**Research sources:** Source code analysis of all 9 key files, Android reference implementation, Blossom protocol research, existing learnings + +### Key Improvements +1. Corrected `DesktopBlossomClient` — needs `ByteArray` overload (currently only accepts `File`) +2. `IAccount` interface needs `sendNip17EncryptedFile()` added (not just `DesktopIAccount`) +3. `NIP17Factory.createEncryptedFileNIP17()` already exists — plan incorrectly referenced `createFileNIP17()` +4. `DesktopMediaMetadata.compute()` reads file bytes internally — encrypted upload must avoid double-read +5. Added streaming encryption consideration for large files and memory pressure mitigation + +### Critical Corrections from Source Code +- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` parameter — encrypted size, not original +- `DesktopBlossomClient.upload()` only accepts `File`, not `ByteArray` — needs overload or temp file +- `ChatMessageEncryptedFileHeaderEvent.build()` takes `cipher: AESGCM` directly — no manual key/nonce extraction needed +- `key()` and `nonce()` return tag values as parsed types (via `EncryptionKey`/`EncryptionNonce` tags) + +--- + +## Overview + +Implement full send + receive encrypted media support in desktop DM chat. Users can attach files via paperclip button or drag-and-drop, which get AES-GCM encrypted before upload to Blossom. Files are sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap (NIP-59). Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay. + +This is Phase 6 of the desktop media parity plan. + +## Problem Statement / Motivation + +Desktop DM chat (`ChatPane.kt`) supports text messages but has no file attachment capability. Android has a complete implementation (`ChatFileUploader` + `ChatFileSender`). Most protocol and service pieces exist on desktop already — this work wires them together with a desktop-native UX. + +## Proposed Solution + +Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol layer (quartz) and extending desktop services. Three workstreams: upload pipeline, send logic, and receive/display. + +(see brainstorm: `docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md`) + +--- + +## Implementation Phases + +### Phase A: Encrypted Upload Pipeline + +**Goal:** `DesktopUploadOrchestrator` gains `uploadEncrypted()` method. + +**Files:** +- `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` — add `uploadEncrypted()` +- `desktopApp/.../service/upload/DesktopBlossomClient.kt` — add `ByteArray` upload overload +- `desktopApp/.../service/upload/DesktopBlossomAuth.kt` — no changes needed (already takes hash + size) +- `desktopApp/.../service/upload/DesktopMediaMetadata.kt` — no changes needed + +**Implementation:** + +```kotlin +// DesktopUploadOrchestrator.kt — new method +suspend fun uploadEncrypted( + file: File, + cipher: AESGCM, + serverBaseUrl: String, + signer: NostrSigner, +): EncryptedUploadResult { + // 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) + // NOTE: DesktopMediaMetadata.compute() reads file bytes internally for SHA256 + val metadata = DesktopMediaMetadata.compute(file) + + // 2. Read file bytes and encrypt + val plaintext = file.readBytes() + val encrypted = cipher.encrypt(plaintext) + + // 3. Compute SHA256 of ENCRYPTED blob (critical: not plaintext) + val encryptedHash = sha256(encrypted).toHexKey() + val encryptedSize = encrypted.size.toLong() + + // 4. Create Blossom auth with ENCRYPTED hash and size + val authHeader = DesktopBlossomAuth.createUploadAuth( + hash = encryptedHash, + size = encryptedSize, + alt = "Encrypted upload", + signer = signer, + ) + + // 5. Upload encrypted blob (needs ByteArray overload on client) + val result = client.upload( + bytes = encrypted, + contentType = "application/octet-stream", // encrypted blob, not original mime + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + return EncryptedUploadResult( + blossom = result, + metadata = metadata, // pre-encryption metadata (dimensions, blurhash, mime) + encryptedHash = encryptedHash, + encryptedSize = encryptedSize.toInt(), + ) +} +``` + +```kotlin +// New data class alongside existing UploadResult +data class EncryptedUploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, // original file metadata + val encryptedHash: String, // SHA256 of encrypted blob + val encryptedSize: Int, // size of encrypted blob +) +``` + +```kotlin +// DesktopBlossomClient.kt — add ByteArray overload +suspend fun upload( + bytes: ByteArray, + contentType: String, + serverBaseUrl: String, + authHeader: String?, +): BlossomUploadResult = withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + + val requestBuilder = Request.Builder() + .url(apiUrl) + .put(requestBody) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } +} +``` + +### Research Insights — Phase A + +**Critical gotchas (from Blossom protocol research + source code analysis):** + +| Issue | Detail | Solution | +|-------|--------|----------| +| Hash mismatch | `X-SHA-256` header must match encrypted blob hash, not plaintext | Compute SHA256 after `cipher.encrypt()` | +| Content-Type | Upload encrypted blob as `application/octet-stream`, not original MIME | Server stores opaque blob | +| Auth size | `DesktopBlossomAuth.createUploadAuth(size=)` must be encrypted size | Pass `encrypted.size.toLong()` | +| Double file read | `DesktopMediaMetadata.compute()` calls `file.readBytes()` internally | Acceptable — metadata computation is separate from encryption read | +| Memory pressure | `file.readBytes()` + `cipher.encrypt()` = 2x file size in memory | For files <50MB this is fine; for larger files consider streaming (future) | + +**Security considerations:** +- Generate fresh `AESGCM()` per file — never reuse key/nonce pairs (AES-GCM nonce reuse completely breaks confidentiality) +- Clear `plaintext` ByteArray after encryption (`plaintext.fill(0)`) to minimize exposure window +- Encrypted blob content type should be `application/octet-stream` to avoid leaking file type to Blossom server + +--- + +### Phase B: DM File Attach UI in ChatPane + +**Goal:** Paperclip button, thumbnail row, drag-and-drop in `ChatPane.kt`. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — modify `MessageInput()` composable and `ChatPane()` for drag-drop + +**Implementation:** + +Modify `MessageInput()` (currently lines 527-627) — add parameters and UI elements: + +```kotlin +// Updated MessageInput signature +@Composable +private fun MessageInput( + messageText: String, + isNip17: Boolean, + requiresNip17: Boolean, + canSend: Boolean, + attachedFiles: List, // NEW + onMessageChange: (String) -> Unit, + onToggleNip17: () -> Unit, + onAttachFiles: (List) -> Unit, // NEW + onRemoveFile: (Int) -> Unit, // NEW + onSend: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + // Attachment thumbnail row (above text input) + if (attachedFiles.isNotEmpty()) { + AttachmentRow( + files = attachedFiles, + isEncrypted = isNip17, + onRemove = onRemoveFile, + ) + Spacer(Modifier.height(4.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Paperclip attach button (only in NIP-17 mode) + if (isNip17) { + IconButton( + onClick = { /* open JFileChooser */ }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach file", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Existing OutlinedTextField... + // Existing Send button... + } + + // Existing NIP-17 indicator... + } +} +``` + +```kotlin +// AttachmentRow composable +@Composable +private fun AttachmentRow( + files: List, + isEncrypted: Boolean, + onRemove: (Int) -> Unit, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + ) { + itemsIndexed(files) { index, file -> + Box(modifier = Modifier.size(64.dp)) { + // Thumbnail (image preview or file icon) + AttachmentThumbnail(file) + + // Remove button (top-right) + IconButton( + onClick = { onRemove(index) }, + modifier = Modifier.align(Alignment.TopEnd).size(18.dp), + ) { + Icon(Icons.Default.Close, "Remove", Modifier.size(12.dp)) + } + + // Lock icon overlay (bottom-end, only when encrypted) + if (isEncrypted) { + Icon( + Icons.Default.Lock, + contentDescription = "Encrypted", + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + RoundedCornerShape(4.dp), + ) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} +``` + +**File picker (JFileChooser pattern from ComposeNoteDialog):** + +```kotlin +// File picker helper — runs on AWT thread +private fun openFilePicker(onFilesSelected: (List) -> Unit) { + val chooser = JFileChooser().apply { + isMultiSelectionEnabled = true + fileFilter = FileNameExtensionFilter( + "Media files", + "jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov", + "mp3", "ogg", "wav", "flac", "aac", + ) + } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + onFilesSelected(chooser.selectedFiles.toList()) + } +} +``` + +**Drag-and-drop on ChatPane (wrapping the Column):** + +```kotlin +// In ChatPane() — wrap the main Column with drag-drop +var isDragOver by remember { mutableStateOf(false) } + +Column( + modifier = modifier + .fillMaxSize() + .onExternalDrag( + onDragStart = { isDragOver = true }, + onDragExit = { isDragOver = false }, + onDrop = { state -> + isDragOver = false + val files = state.dragData + .let { it as? DragData.FilesList } + ?.readFiles() + ?.mapNotNull { uri -> File(URI(uri)) } + ?: emptyList() + // Add to attachedFiles state + attachedFiles.addAll(files) + }, + ) + .then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) + } else { + Modifier + } + ), +) { + // ... existing ChatPane content +} +``` + +### Research Insights — Phase B + +**Compose best practices applied:** + +| Pattern | Recommendation | Rationale | +|---------|---------------|-----------| +| State location | `attachedFiles` as `mutableStateListOf()` in `ChatPane`, not `MessageInput` | State hoisted to parent that also handles send/upload logic | +| File picker thread | `JFileChooser` must run on EDT; use `withContext(Dispatchers.Main)` or `SwingUtilities.invokeLater` | AWT file dialogs block; Compose coroutines must not be blocked | +| Drag-drop modifier | `Modifier.onExternalDrag` from `compose.ui` | Already used in ComposeNoteDialog — consistent pattern | +| Thumbnail rendering | Use `ImageIO.read()` for image thumbnails; generic icon for audio/video | Avoid loading full-resolution images; scale down for 64dp thumbnails | +| `canSend` update | `canSend` should also be true when `attachedFiles.isNotEmpty()` even if `messageText.isEmpty()` | Allow sending file-only messages (no text required) | + +**Desktop UX considerations:** +- Keyboard shortcut: Cmd+V / Ctrl+V paste should also add clipboard images to attachments (future enhancement) +- Tooltip on disabled attach button (NIP-04 mode): "Switch to NIP-17 to send files" +- Maximum attachment count: limit to 10 files to prevent UI overflow +- File size validation: warn on files >50MB before attempting upload + +--- + +### Phase C: Send Encrypted File Event + +**Goal:** Build and dispatch `ChatMessageEncryptedFileHeaderEvent` (kind 15) from upload results. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — send logic in `ChatPane()` scope +- `desktopApp/.../model/DesktopIAccount.kt` — add `sendNip17EncryptedFile()` +- `commons/.../model/IAccount.kt` — add interface method + +**Implementation:** + +```kotlin +// IAccount.kt — add to interface (commons/commonMain) +/** Send a NIP-17 gift-wrapped encrypted file header */ +suspend fun sendNip17EncryptedFile(template: EventTemplate) +``` + +```kotlin +// DesktopIAccount.kt — implement (mirrors sendNip17PrivateMessage exactly) +override suspend fun sendNip17EncryptedFile( + template: EventTemplate, +) { + if (!isWriteable()) return + + val result = NIP17Factory().createEncryptedFileNIP17(template, signer) + + // Optimistic local add — use the inner event + val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent + addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey)) + + // Collect wraps with target relays and send + val batch = result.wraps.map { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = if (recipientKey != null) { + val dmRelays = localCache.getOrCreateUser(recipientKey) + .dmInboxRelays()?.toSet() + dmRelays?.ifEmpty { null } ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + wrap to targetRelays + } + + scope.launch { dmSendTracker.sendBatch(batch) } +} +``` + +```kotlin +// ChatPane.kt — send handler for encrypted files +// In ChatPane composable scope, after upload completes: +private suspend fun sendEncryptedFiles( + uploads: List>, + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, +) { + val recipients = roomKey.users.map { cacheProvider.getOrCreateUser(it).toPTag() } + + for ((result, cipher) in uploads) { + val template = ChatMessageEncryptedFileHeaderEvent.build( + to = recipients, + url = result.blossom.url, + cipher = cipher, // passes algo, key, nonce automatically + mimeType = result.metadata.mimeType, + hash = result.encryptedHash, // hash of encrypted blob + size = result.encryptedSize, + dimension = result.metadata.width?.let { w -> + result.metadata.height?.let { h -> DimensionTag(w, h) } + }, + blurhash = result.metadata.blurhash, + originalHash = result.metadata.sha256, // hash of original plaintext + ) + account.sendNip17EncryptedFile(template) + } +} +``` + +**Full send flow in ChatPane (upload + send):** + +```kotlin +// In ChatPane composable — triggered by Send button when attachedFiles.isNotEmpty() +scope.launch { + val orchestrator = DesktopUploadOrchestrator() + val server = /* user's default Blossom server from kind 10063 */ + val uploads = mutableListOf>() + + for (file in attachedFiles) { + val cipher = AESGCM() // fresh cipher per file + try { + val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer) + uploads.add(result to cipher) + } catch (e: Exception) { + // Show error, keep remaining files for retry + println("Upload failed for ${file.name}: ${e.message}") + } + } + + if (uploads.isNotEmpty()) { + sendEncryptedFiles(uploads, roomKey, account, cacheProvider) + attachedFiles.clear() + + // Also send text message if present + if (messageState.canSend) { + messageState.send() + messageState.clear() + } + } +} +``` + +### Research Insights — Phase C + +**Correctness checks from source code:** + +| Verified | Detail | +|----------|--------| +| `NIP17Factory.createEncryptedFileNIP17()` | Exists at `NIP17Factory.kt:86-97` — takes `EventTemplate` | +| `ChatMessageEncryptedFileHeaderEvent.build()` | Takes `cipher: AESGCM` directly — auto-extracts algo/key/nonce via `encryptionAlgo(cipher.name())`, `encryptionKey(cipher.keyBytes)`, `encryptionNonce(cipher.nonce)` | +| Android pattern | `Account.sendNip17EncryptedFile()` at line 1612 calls `NIP17Factory().createEncryptedFileNIP17(template, signer)` then `broadcastPrivately(wraps)` | +| Desktop pattern | `DesktopIAccount.sendNip17PrivateMessage()` at line 128 — same structure, replace `createMessageNIP17` with `createEncryptedFileNIP17` | + +**Concurrency considerations:** +- Upload files sequentially (not parallel) to avoid memory pressure from multiple concurrent encryptions +- Use `supervisorScope` if you want one failed upload to not cancel others +- Send events can be parallelized (each is independent after upload) + +**Interface change impact:** +- Adding `sendNip17EncryptedFile` to `IAccount` requires implementation in Android's `Account.kt` too — but it already has it as a non-override method. Just add `override` keyword. + +--- + +### Phase D: Receive & Display Encrypted Media + +**Goal:** Encrypted media in received DMs renders inline with lock icon. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — enhance `ChatFileAttachment()` (line 442) +- `desktopApp/.../service/media/EncryptedMediaService.kt` — already exists, enhance with caching + +**Current state:** `ChatFileAttachment` is called at line 442 but its implementation is minimal or placeholder. The event is already detected as `ChatMessageEncryptedFileHeaderEvent`. + +**Implementation:** + +```kotlin +@Composable +private fun ChatFileAttachment(event: ChatMessageEncryptedFileHeaderEvent) { + // Parse cipher params from event tags + val url = event.url() + val keyBytes = event.key() // returns ByteArray? (parsed from EncryptionKey tag) + val nonceBytes = event.nonce() // returns ByteArray? (parsed from EncryptionNonce tag) + val mimeType = event.mimeType() + val blurhashStr = event.blurhash() + + if (url.isNullOrEmpty() || keyBytes == null || nonceBytes == null) { + // Missing encryption params — show error + EncryptedFileError("Missing encryption data") + return + } + + // Async download + decrypt with proper state management + var decryptionState by remember(event.id) { + mutableStateOf(DecryptionState.Loading) + } + + LaunchedEffect(event.id) { + decryptionState = try { + val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonceBytes) + DecryptionState.Success(bytes) + } catch (e: Exception) { + DecryptionState.Error(e.message ?: "Decryption failed") + } + } + + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + ) { + when (val state = decryptionState) { + is DecryptionState.Loading -> { + // Blurhash placeholder or shimmer + if (blurhashStr != null) { + BlurhashPlaceholder( + blurhash = blurhashStr, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(24.dp), + ) + } + } + + is DecryptionState.Success -> { + DecryptedMediaContent( + bytes = state.bytes, + mimeType = mimeType, + modifier = Modifier.fillMaxWidth(), + ) + } + + is DecryptionState.Error -> { + EncryptedFileError(state.message) + } + } + + // Lock icon overlay (always visible) + Icon( + Icons.Default.Lock, + contentDescription = "End-to-end encrypted", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .size(20.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + RoundedCornerShape(4.dp), + ) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } +} + +private sealed class DecryptionState { + data object Loading : DecryptionState() + data class Success(val bytes: ByteArray) : DecryptionState() + data class Error(val message: String) : DecryptionState() +} +``` + +```kotlin +// DecryptedMediaContent — render based on MIME type +@Composable +private fun DecryptedMediaContent( + bytes: ByteArray, + mimeType: String?, + modifier: Modifier = Modifier, +) { + when { + mimeType?.startsWith("image/") == true -> { + val bitmap = remember(bytes) { + org.jetbrains.skia.Image.makeFromEncoded(bytes) + .toComposeImageBitmap() + } + Image( + bitmap = bitmap, + contentDescription = "Encrypted image", + contentScale = ContentScale.Fit, + modifier = modifier, + ) + } + mimeType?.startsWith("video/") == true -> { + // Show video thumbnail or play button + // Full video playback requires writing decrypted bytes to temp file for VLC + VideoFilePlaceholder(bytes.size, modifier) + } + mimeType?.startsWith("audio/") == true -> { + AudioFilePlaceholder(bytes.size, modifier) + } + else -> { + GenericFilePlaceholder(mimeType, bytes.size, modifier) + } + } +} +``` + +### Research Insights — Phase D + +**Compose state management:** +- Use `LaunchedEffect(event.id)` not `produceState` — better control over loading/error states via sealed class +- `remember(event.id)` keys state to the event, preventing re-download on recomposition +- `remember(bytes)` for Skia bitmap conversion prevents recreating bitmap every recomposition + +**Performance considerations:** + +| Concern | Mitigation | +|---------|------------| +| Re-download on scroll | Add in-memory LRU cache to `EncryptedMediaService` keyed by URL | +| Large decrypted images in memory | Scale down to max display size (300dp) before caching | +| Bitmap creation from bytes | `org.jetbrains.skia.Image.makeFromEncoded()` is efficient for JVM | +| Video/audio playback | Requires writing decrypted bytes to temp file (VLC needs file path). Use `File.createTempFile()` with `.deleteOnExit()` | + +**Add caching to EncryptedMediaService:** + +```kotlin +object EncryptedMediaService { + private val httpClient = OkHttpClient() + private val cache = LruCache(maxSize = 20) // ~20 decrypted files + + suspend fun downloadAndDecrypt( + url: String, + keyBytes: ByteArray, + nonce: ByteArray, + ): ByteArray { + cache.get(url)?.let { return it } + + return withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + val encryptedBytes = response.use { + if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}") + it.body.bytes() + } + + val cipher = AESGCM(keyBytes, nonce) + val decrypted = cipher.decrypt(encryptedBytes) + cache.put(url, decrypted) + decrypted + } + } +} +``` + +**Security note:** Cached decrypted bytes are in JVM heap memory. This is acceptable for a desktop app (no shared memory concerns). Consider cache eviction on app minimize if paranoid. + +--- + +### Phase E: Error Handling & Edge Cases + +**Files:** Across all modified files above. + +| Scenario | Handling | Implementation | +|----------|----------|----------------| +| Upload fails mid-way | Show error snackbar, keep file in attachment row for retry | Catch in upload loop, skip failed file, continue others | +| Network disconnect during upload | Catch `IOException`, show "Upload failed — check connection" | OkHttp throws on network failure | +| Decryption fails (wrong key) | Show "Could not decrypt" placeholder, no crash | `DecryptionState.Error` sealed class variant | +| Blossom server unreachable | Fallback to next server in user's kind 10063 list | Query `BlossomServersEvent` for alternatives | +| Large file (>10MB) | Show progress indicator during encrypt + upload | Extend `DesktopUploadTracker` for encrypted uploads | +| Unsupported MIME type | Show generic file icon with filename + size | `GenericFilePlaceholder` composable | +| No Blossom servers configured | Disable attach button, show tooltip | Check server list before enabling button | +| NIP-04 mode active | Attach button hidden (encrypted files are NIP-17 only) | `if (isNip17)` guard on paperclip button | +| Corrupt encrypted blob | `AESGCM.decrypt()` throws `AEADBadTagException` | Catch specifically, show "File corrupted or tampered" | +| Duplicate upload (same file) | Each send generates new cipher — different encrypted blob | Intentional: no deduplication for privacy | +| Rapid send taps | Disable send button during upload/send | `isUploading` state flag | + +### Research Insights — Phase E + +**Error hierarchy (from AESGCM source):** +- `AESGCM.decrypt()` uses JCE `Cipher` on JVM — throws `AEADBadTagException` for wrong key (not generic exception) +- `AESGCM.decryptOrNull()` exists — returns null instead of throwing. Prefer this for UI code. + +**Security edge cases:** +- Never log encryption keys, nonces, or decrypted content +- Temp files for video playback must be deleted after use (`deleteOnExit()` + explicit delete on composable disposal) +- Don't show detailed error messages that could leak cipher state ("wrong key" is fine, "key was X but expected Y" is not) + +--- + +## Technical Considerations + +### Security +- New `AESGCM()` cipher per file (random key + nonce) — never reuse +- Encrypt before hashing — Blossom auth scoped to encrypted blob +- Plaintext never leaves device unencrypted +- GiftWrap ensures only recipients can see the kind 15 event +- Zero `plaintext` ByteArray after encryption to minimize exposure window +- Upload content type is `application/octet-stream` (doesn't leak file type to server) +- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay attacks + +### Performance +- Encryption runs on `Dispatchers.IO` (non-blocking) +- Sequential file upload (not parallel) to limit memory to 2x single file size +- In-memory LRU cache for decrypted media (20 entries) avoids re-downloading +- `org.jetbrains.skia.Image.makeFromEncoded()` for efficient bitmap creation +- Large files (>50MB): warn user before upload; consider streaming in future + +### Architecture +- No new modules — extends existing desktop services +- Protocol layer (quartz) unchanged — fully reuses existing events/ciphers +- Follows Android patterns for consistency across platforms +- `IAccount` interface gains one new method; Android already has implementation (add `override`) + +--- + +## Acceptance Criteria + +- [x] Paperclip attach button visible in DM chat input (NIP-17 mode only) +- [x] File picker opens, supports image/video/audio selection +- [x] Selected files show as thumbnails above input with X to remove +- [x] Drag-and-drop files onto chat area adds to attachments (visual drop indicator) +- [x] Send encrypts files with AES-GCM before upload to Blossom +- [x] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap +- [x] Lock icon overlay visible on attachment thumbnails before send +- [x] Received encrypted media downloads, decrypts, displays inline +- [x] Lock icon overlay on received encrypted media in chat bubbles +- [x] Wrong key / failed decryption shows error state, no crash +- [x] Upload progress indicator during encrypt + upload +- [x] No attach button when in NIP-04 mode +- [x] `canSend` true when files attached (even without text) +- [x] Send button disabled during active upload + +## Test Plan (from Phase 6 testing plan) + +| # | Test | Steps | Expected | +|---|------|-------|----------| +| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail appears above input with lock indicator | +| 6.2 | Send encrypted | Attach file → send | File uploads encrypted to Blossom, kind 15 event in GiftWrap sent | +| 6.3 | Receive encrypted | Receive DM with encrypted file (from Android) | File downloads, decrypts, displays in bubble with lock icon | +| 6.4 | Wrong key | View encrypted media where key doesn't match | "Could not decrypt" placeholder, no crash | +| 6.5 | Drag-drop attach | Drag image onto DM chat | File appears in attachment row with lock icon | +| 6.6 | Multiple files | Attach 3 files → send | All 3 upload encrypted, each gets own kind 15 event | +| 6.7 | Large file | Attach 15MB image → send | Progress shown, upload succeeds | +| 6.8 | NIP-04 mode | Toggle to NIP-04 → check attach button | Attach button hidden/disabled | + +## Dependencies & Risks + +| Dependency | Risk | Mitigation | +|-----------|------|------------| +| Blossom server availability | Upload fails | Retry + fallback to alternate servers from kind 10063 | +| VLC for video playback | Encrypted video won't play without VLC | Show "Download" button for video; image display works regardless | +| Android client for cross-platform test | Can't verify interop | Use Android emulator or second account | +| `DesktopBlossomClient` ByteArray overload | New method needed | Simple addition — uses OkHttp `ByteArray.toRequestBody()` | +| `IAccount` interface change | Requires Android-side `override` keyword | Android already has the method, just not `override` | + +## Sources & References + +### Origin +- **Brainstorm document:** [docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md](docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md) — Key decisions: inline attach button UX, drag-drop support, lock icon overlay, full send+receive scope + +### Internal References (verified from source code) +- Android `ChatFileUploader.justUploadNIP17()`: `amethyst/.../upload/ChatFileUploader.kt:39-80` +- Android `ChatFileSender.sendNIP17()`: `amethyst/.../upload/ChatFileSender.kt:46-71` +- Android `Account.sendNip17EncryptedFile()`: `amethyst/.../model/Account.kt:1612-1617` +- Desktop `ChatPane.kt`: `desktopApp/.../ui/chats/ChatPane.kt` (628 lines) +- Desktop `DesktopUploadOrchestrator`: `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` (78 lines) +- Desktop `DesktopBlossomClient`: `desktopApp/.../service/upload/DesktopBlossomClient.kt` (74 lines) +- Desktop `DesktopBlossomAuth`: `desktopApp/.../service/upload/DesktopBlossomAuth.kt` (43 lines) +- Desktop `DesktopMediaMetadata`: `desktopApp/.../service/upload/DesktopMediaMetadata.kt` (89 lines) +- Desktop `EncryptedMediaService`: `desktopApp/.../service/media/EncryptedMediaService.kt` (57 lines) +- Desktop `DesktopIAccount`: `desktopApp/.../model/DesktopIAccount.kt` +- Desktop `ComposeNoteDialog` (drag-drop pattern): `desktopApp/.../ui/ComposeNoteDialog.kt` +- Commons `IAccount` interface: `commons/.../model/IAccount.kt:106-113` +- Quartz `ChatMessageEncryptedFileHeaderEvent.build()`: `quartz/.../nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt:80-110` +- Quartz `NIP17Factory.createEncryptedFileNIP17()`: `quartz/.../nip17Dm/NIP17Factory.kt:86-97` +- Quartz `AESGCM`: `quartz/.../utils/ciphers/AESGCM.kt` +- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md` + +### Gotchas (from learnings research + source verification) +- Hash encrypted blob, not plaintext, for Blossom auth `x` tag and `X-SHA-256` header +- New AESGCM cipher per file — never reuse key/nonce pair (nonce reuse breaks AES-GCM completely) +- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay +- NIP-04 does not support encrypted file headers — hide attach button in NIP-04 mode +- `DesktopBlossomClient.upload()` only accepts `File` — needs `ByteArray` overload for encrypted blobs +- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` — must be encrypted blob size +- Content-Type for encrypted upload must be `application/octet-stream`, not original MIME +- `AESGCM.decryptOrNull()` exists — prefer over `decrypt()` for UI code (no exception on wrong key) diff --git a/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md new file mode 100644 index 0000000000..bdb90980ef --- /dev/null +++ b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md @@ -0,0 +1,343 @@ +--- +title: "feat: Stacked messages layout in deck columns" +type: feat +status: completed +date: 2026-03-19 +deepened: 2026-03-19 +origin: docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md +--- + +# feat: Stacked Messages Layout in Deck Columns + +## Enhancement Summary + +**Deepened on:** 2026-03-19 +**Files to change:** 4 +**Approach:** Add `compactMode` flag, conditional layout in `DesktopMessagesScreen` + +### Key Implementation Details +1. `ConversationListPane` width is hardcoded at line 122 (`Modifier.width(280.dp)`) — remove it, let caller control width +2. `ChatPane` header (lines 211-231) uses `ChatroomHeader`/`GroupChatroomHeader` — wrap with `Row` adding back arrow +3. `DesktopMessagesScreen` already has `selectedRoom` state and `clearSelection()` — stacked nav is pure layout change +4. Keyboard Escape handling already at line 102 calls `listState.clearSelection()` — works as-is for back nav + +--- + +## Overview + +Replace the side-by-side split-pane Messages layout with stacked navigation in deck columns. Full-width contact list OR full-width chat — clicking a conversation navigates to chat, back arrow returns to list. Single-pane mode keeps the current split layout. + +## Problem Statement + +In multi-deck mode, columns are 350-400dp wide. The current layout allocates 280dp to `ConversationListPane` (line 122) and the remaining 70-120dp to `ChatPane` — unusable. + +(see brainstorm: `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`) + +--- + +## Step 1: Make ConversationListPane width flexible + +**File:** `desktopApp/.../ui/chats/ConversationListPane.kt` (line 119-123) + +**Current code:** +```kotlin +Column( + modifier = + modifier + .width(280.dp) // ← hardcoded, breaks compact mode + .fillMaxHeight() +``` + +**Change:** Remove the hardcoded width from the composable. The caller controls width via the `modifier` parameter. + +```kotlin +Column( + modifier = + modifier + .fillMaxHeight() +``` + +**Call sites:** +- Split mode (DesktopMessagesScreen): passes no modifier → add `Modifier.width(280.dp)` at call site +- Compact mode: passes `Modifier.fillMaxWidth()` → uses full column width + +### Research Insights + +- `ConversationListPane` already accepts `modifier: Modifier = Modifier` (line 95) — just unused for width +- The keyboard nav (`onPreviewKeyEvent` at line 126) and `LazyColumn` (line 243) work at any width +- `ConversationCard` (line 268) uses `Modifier.fillMaxWidth()` — adapts automatically + +--- + +## Step 2: Add `onBack` to ChatPane header + +**File:** `desktopApp/.../ui/chats/ChatPane.kt` (lines 136-144, 211-231) + +**Current signature:** +```kotlin +fun ChatPane( + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, + feedViewModel: ChatroomFeedViewModel, + messageState: ChatNewMessageState, + dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, + onNavigateToProfile: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) +``` + +**Add:** `onBack: (() -> Unit)? = null` parameter. + +**Current header (lines 211-231):** +```kotlin +// Header +if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) +} else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } +} +``` + +**New header:** Wrap in a `Row` with conditional back arrow: + +```kotlin +Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), +) { + if (onBack != null) { + IconButton( + onClick = onBack, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to conversations", + ) + } + } + + Box(modifier = Modifier.weight(1f)) { + // Existing header content (ChatroomHeader / GroupChatroomHeader / fallback) + } +} +``` + +### Research Insights + +- `Icons.AutoMirrored.Filled.ArrowBack` is already available in material-icons-extended +- `ChatroomHeader` and `GroupChatroomHeader` are shared composables from commons — don't modify them +- The `Row` wrapper doesn't affect the existing divider at line 233 (`HorizontalDivider()`) + +--- + +## Step 3: Refactor DesktopMessagesScreen layout + +**File:** `desktopApp/.../ui/chats/DesktopMessagesScreen.kt` + +**Current:** Single `Row` layout (lines 92-168) with `ConversationListPane` + `VerticalDivider` + `ChatPane`/`EmptyState`. + +**Change:** Add `compactMode: Boolean = false` parameter. Extract existing Row into a private `SplitMessagesContent` composable. Add a new `CompactMessagesContent` for stacked mode. + +```kotlin +@Composable +fun DesktopMessagesScreen( + account: IAccount, + cacheProvider: ICacheProvider, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + compactMode: Boolean = false, // NEW + onNavigateToProfile: (String) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val listState = remember(account) { + ChatroomListState(account, cacheProvider, relayManager, localCache, scope) + } + val selectedRoom by listState.selectedRoom.collectAsState() + val listFocusRequester = remember { FocusRequester() } + var showNewDmDialog by remember { mutableStateOf(false) } + + // Keyboard shortcuts (shared between modes) + val keyHandler = Modifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + when { + event.key == Key.Escape -> { + listState.clearSelection() + true + } + event.key == Key.N && isModifier && event.isShiftPressed -> { + showNewDmDialog = true + true + } + else -> false + } + } + + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + showNewDmDialog = showNewDmDialog, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + keyHandler = keyHandler, + onShowNewDm = { showNewDmDialog = true }, + ) + } + + // New DM dialog (shared) + if (showNewDmDialog) { /* existing NewDmDialog code */ } +} +``` + +**CompactMessagesContent:** +```kotlin +@Composable +private fun CompactMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + showNewDmDialog: Boolean, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Box(modifier = Modifier.fillMaxSize().then(keyHandler)) { + val currentRoom = selectedRoom + if (currentRoom != null) { + // Full-width chat with back arrow + val feedViewModel = remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + val broadcastStatus = if (account is DesktopIAccount) { + account.dmSendTracker.status.collectAsState().value + } else DmBroadcastStatus.Idle + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + dmBroadcastStatus = broadcastStatus, + onNavigateToProfile = onNavigateToProfile, + onBack = { listState.clearSelection() }, + ) + } else { + // Full-width contact list + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, + focusRequester = listFocusRequester, + modifier = Modifier.fillMaxSize(), + ) + } + } +} +``` + +**SplitMessagesContent:** Extract existing `Row` code verbatim from current `DesktopMessagesScreen`, adding `Modifier.width(280.dp)` to the `ConversationListPane` call. + +--- + +## Step 4: Wire compactMode from deck + +**File:** `desktopApp/.../ui/deck/DeckColumnContainer.kt` (line 206-214) + +```kotlin +DeckColumnType.Messages -> { + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + relayManager = relayManager, + localCache = localCache, + compactMode = true, // ← ADD THIS + onNavigateToProfile = onNavigateToProfile, + ) +} +``` + +`SinglePaneLayout` — no change needed, `compactMode` defaults to `false`. + +--- + +## Edge Cases + +| Scenario | Behavior | Verified by | +|----------|----------|-------------| +| Escape in chat | `clearSelection()` → back to list | Existing keyboard handler (line 102) | +| Escape in list | No-op (already no selection) | Same handler, `clearSelection()` on null is safe | +| New DM dialog in compact chat | Dialog opens over chat, selecting user switches to that chat | `showNewDmDialog` is shared state | +| Receiving DM while viewing list | Chatroom list updates via 2s polling | `ChatroomListState.refreshRooms()` | +| Receiving DM while in chat | Messages appear real-time via `ChatroomFeedViewModel` | No change needed | +| Back arrow + drag-drop | Drag-drop zone is on the ChatPane Column, unaffected by back arrow Row | Separate modifier chain | + +--- + +## Acceptance Criteria + +- [x] Deck Messages column shows full-width contact list (no split) +- [x] Clicking conversation navigates to full-width chat view +- [x] Back arrow visible in chat header (compact mode only) +- [x] Clicking back arrow returns to contact list +- [x] Escape key still returns to contact list +- [x] Single-pane mode unchanged (split layout preserved) +- [x] Keyboard navigation (up/down/enter) still works in contact list +- [x] New DM dialog still works in both modes +- [x] ConversationListPane uses full width in compact mode + +## Files Changed + +| File | Change | Lines affected | +|------|--------|---------------| +| `ConversationListPane.kt` | Remove hardcoded `width(280.dp)` | Line 122 | +| `ChatPane.kt` | Add `onBack` param, wrap header in Row with back arrow | Lines 136-144, 211-231 | +| `DesktopMessagesScreen.kt` | Add `compactMode`, extract `SplitMessagesContent`/`CompactMessagesContent` | Major refactor | +| `DeckColumnContainer.kt` | Pass `compactMode = true` | Line ~208 | + +## Sources + +- **Brainstorm:** `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md` +- `DesktopMessagesScreen.kt:75-213` — current split-pane layout +- `ChatPane.kt:136-144` — current signature; `211-231` — header section +- `ConversationListPane.kt:119-122` — hardcoded width; `95` — modifier param +- `DeckColumnContainer.kt:206-214` — Messages deck routing diff --git a/gradle.properties b/gradle.properties index 8d72c802b7..dd5ff4f5fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95a41277f4..0323197d2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" -composeMultiplatform = "1.10.1" -activityCompose = "1.12.4" +composeMultiplatform = "1.10.2" +activityCompose = "1.13.0" agp = "9.1.0" android-compileSdk = "36" android-minSdk = "26" @@ -11,13 +11,13 @@ androidKotlinGeohash = "b481c6a64e" androidxJunit = "1.3.0" appcompat = "1.7.1" audiowaveform = "1.1.2" -benchmark = "1.5.0-alpha03" +benchmark = "1.5.0-alpha04" biometricKtx = "1.2.0-alpha05" coil = "3.4.0" -composeBom = "2026.02.01" -composeRuntimeAnnotation = "1.10.4" -coreKtx = "1.17.0" -datastore = "1.2.0" +composeBom = "2026.03.00" +composeRuntimeAnnotation = "1.10.5" +coreKtx = "1.18.0" +datastore = "1.2.1" devWhyolegCryptography = "0.5.0" espressoCore = "3.7.0" firebaseBom = "34.10.0" @@ -39,6 +39,8 @@ lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" +material3 = "1.9.0" +materialIconsExtended = "1.7.3" media3 = "1.9.2" mockk = "1.14.9" kotlinx-coroutines-test = "1.10.2" @@ -51,21 +53,27 @@ secp256k1KmpJniAndroid = "0.22.0" securityCryptoKtx = "1.1.0" spotless = "8.3.0" tarsosdsp = "2.5" -torAndroid = "0.4.9.5" +torAndroid = "0.4.9.5.1" translate = "17.0.3" +jetbrainsCompose = "1.10.2" unifiedpush = "3.0.10" -vico-charts = "2.4.3" +vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" +vlcj = "4.8.3" +commonsImaging = "1.0.0-alpha5" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" androidxCamera = "1.5.3" -androidxCollection = "1.5.0" +androidxCollection = "1.6.0" +androidxExifinterface = "1.4.1" kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" -spmForKmpVersion = "1.4.9" +spmForKmpVersion = "1.4.10" +stabilityAnalyser = "0.7.0" +sqlite = "2.6.2" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } @@ -84,6 +92,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } +androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidxExifinterface" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -100,7 +109,6 @@ androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3- androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } -androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } androidx-media3-ui-compose-material3 = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } @@ -118,10 +126,20 @@ coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif", version.ref = "coil" coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" } coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } +commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } +vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" } +jetbrains-compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "jetbrainsCompose" } +jetbrains-compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } +jetbrains-compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } +jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "jetbrainsCompose" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } @@ -153,10 +171,9 @@ secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jn tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } -vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" } -vico-charts-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico-charts" } -vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts" } -vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" } +vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } +vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" } +vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts-compose" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } @@ -165,6 +182,9 @@ androidx-window-core-android = { group = "androidx.window", name = "window-core- kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } +androidx-sqlite-bundled-jvm = { module = "androidx.sqlite:sqlite-bundled-jvm", version.ref = "sqlite" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -178,6 +198,6 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.7.0" } +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "stabilityAnalyser" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 488f904f11..da7c363a9b 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -1,11 +1,13 @@ @file:OptIn(ExperimentalSpmForKmpFeature::class) import com.vanniktech.maven.publish.KotlinMultiplatform +import com.vanniktech.maven.publish.SourcesJar import io.github.frankois944.spmForKmp.swiftPackageConfig import io.github.frankois944.spmForKmp.utils.ExperimentalSpmForKmpFeature import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest + plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) @@ -63,40 +65,59 @@ kotlin { val xcfName = "quartz-kmpKit" val libsodiumPath = project.file("src/nativeInterop/libsodium") val libsodiumHeaderFilesPath = project.file("$libsodiumPath/include/sodium") - val libsodiumDefFile = project.file("src/nativeInterop/cinterop/Clibsodium.def") + + // Generate target-specific Libsodium definition files for creating native bindings. + // Device (iosArm64) uses libsodium.a, simulator targets use libsodium-simulator.a. + val libsodiumDeviceDefFile = + project.layout.buildDirectory + .file("cinterop/Clibsodium-device.def") + .get() + .asFile + val libsodiumSimulatorDefFile = + project.layout.buildDirectory + .file("cinterop/Clibsodium-simulator.def") + .get() + .asFile // This generates the Libsodium definition file, necessary for creating native bindings(a Kotlin API) for libsodium(for iOS). - val libsodiumDefFileGeneration = tasks.register("GenerateSodiumCinteropFile") { - outputs.file(libsodiumDefFile) - doLast { - if (!libsodiumDefFile.exists()) { - libsodiumDefFile.parentFile.mkdirs() - libsodiumDefFile.writeText("package = Clibsodium\n") - libsodiumDefFile.appendText("staticLibraries = libsodium.a libsodium-simulator.a\n") - libsodiumDefFile.appendText("libraryPaths = ${libsodiumPath.absolutePath}/ios/lib ${libsodiumPath.absolutePath}/ios-simulators/lib\n") + val libsodiumDefFileGeneration = + tasks.register("GenerateSodiumCinteropFile") { + outputs.files(libsodiumDeviceDefFile, libsodiumSimulatorDefFile) + doLast { + libsodiumDeviceDefFile.parentFile.mkdirs() + libsodiumDeviceDefFile.writeText( + "package = Clibsodium\n" + + "staticLibraries = libsodium.a\n" + + "libraryPaths = ${libsodiumPath.absolutePath}/ios/lib\n", + ) + libsodiumSimulatorDefFile.writeText( + "package = Clibsodium\n" + + "staticLibraries = libsodium-simulator.a\n" + + "libraryPaths = ${libsodiumPath.absolutePath}/ios-simulators/lib\n", + ) } } - } listOf( iosArm64(), - iosX64(), iosSimulatorArm64(), ).forEach { target -> + val isSimulator = target.name != "iosArm64" + val defFile = if (isSimulator) libsodiumSimulatorDefFile else libsodiumDeviceDefFile target.compilations.getByName("main") { - val Clibsodium by cinterops.creating { - definitionFile = libsodiumDefFile + val clibsodium by cinterops.creating { + definitionFile = defFile packageName = "Clibsodium" headers( "$libsodiumHeaderFilesPath/crypto_aead_xchacha20poly1305.h", "$libsodiumHeaderFilesPath/crypto_core_hchacha20.h", - "$libsodiumHeaderFilesPath/crypto_stream_chacha20.h" + "$libsodiumHeaderFilesPath/crypto_stream_chacha20.h", ) } - tasks.named(cinterops.getByName("Clibsodium").interopProcessingTaskName).configure { + tasks.named(cinterops.getByName("clibsodium").interopProcessingTaskName).configure { dependsOn(libsodiumDefFileGeneration) } } @@ -117,28 +138,31 @@ kotlin { } } - iosX64 { - binaries.framework { - baseName = xcfName - } - } - iosArm64 { + binaries.all { + linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium") + } binaries.framework { baseName = xcfName + binaryOption("bundleId", "com.vitorpamplona.quartz") } } iosSimulatorArm64 { + binaries.all { + linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator") + } binaries.framework { baseName = xcfName + binaryOption("bundleId", "com.vitorpamplona.quartz") } } // This makes sure that the resource file directory is visible for iOS tests. - val rootDir = "${rootProject.rootDir.path}/quartz/src/iosTest/resources" + val rootDir = "${rootProject.rootDir.path}/quartz/src/commonTest/resources" tasks.withType().configureEach { + maxHeapSize = "4g" environment("TEST_RESOURCES_ROOT", rootDir) } @@ -173,6 +197,10 @@ kotlin { // immutable collections to avoid recomposition implementation(libs.kotlinx.collections.immutable) + + // SQLite KMP driver for event store + api(libs.androidx.sqlite) + implementation(libs.androidx.sqlite.bundled) } } @@ -180,6 +208,10 @@ kotlin { dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + + // SQLite bundled driver for tests + api(libs.androidx.sqlite) + implementation(libs.androidx.sqlite.bundled) } } @@ -233,6 +265,9 @@ kotlin { dependencies { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) + + // SQLite bundled driver for JVM tests + implementation(libs.androidx.sqlite.bundled.jvm) } } @@ -252,8 +287,18 @@ kotlin { getByName("androidHostTest") { dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) + + // LibSodium for ChaCha encryption (NIP-44) - Needed for host tests + implementation(libs.lazysodium.java) + implementation(libs.jna) + + // SQLite bundled driver for Host tests + implementation(libs.androidx.sqlite.bundled.jvm) } } @@ -263,7 +308,16 @@ kotlin { implementation(libs.androidx.core) implementation(libs.androidx.junit) implementation(libs.androidx.espresso.core) + + implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + + // Bitcoin secp256k1 bindings to Android + api(libs.secp256k1.kmp.jni.android) + + // LibSodium for ChaCha encryption (NIP-44) + implementation("com.goterl:lazysodium-android:5.2.0@aar") + implementation("net.java.dev.jna:jna:5.18.1@aar") } } @@ -278,10 +332,6 @@ kotlin { } } - val iosX64Main by getting { - dependsOn(iosMain.get()) - } - val iosArm64Main by getting { dependsOn(iosMain.get()) } @@ -296,10 +346,6 @@ kotlin { } } - val iosX64Test by getting { - dependsOn(iosTest.get()) - } - val iosArm64Test by getting { dependsOn(iosTest.get()) } @@ -315,7 +361,7 @@ mavenPublishing { configure( KotlinMultiplatform( // whether to publish a sources jar - sourcesJar = true, + sourcesJar = SourcesJar.Sources(), ), ) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 0000000000..d588554f74 --- /dev/null +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@RunWith(AndroidJUnit4::class) +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt index 5dfcfb8ba1..3eeae80550 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt @@ -29,15 +29,15 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -public class NIP49Test { +class NIP49Test { companion object { - val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + const val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" - val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" - val TEST_CASE_PASSWORD = "nostr" + const val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + const val TEST_CASE_PASSWORD = "nostr" val MAIN_TEST_CASES = - listOf( + listOf( Nip49TestCase(".ksjabdk.aselqwe", "14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a", 1, 0x00), Nip49TestCase("skjdaklrnçurbç l", "f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab", 2, 0x01), Nip49TestCase("777z7z7z7z7z7z7z", "11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944", 3, 0x02), @@ -55,12 +55,9 @@ public class NIP49Test { @Test fun decodeBech32() { - val data = - Nip49.EncryptedInfo.decodePayload( - TEST_CASE, - )!! + val data = Nip49.EncryptedInfo.decodePayload(TEST_CASE) - assertEquals(2.toByte(), data.version) + assertEquals(2.toByte(), data!!.version) assertEquals(16.toByte(), data.logn) assertEquals("52d7c3f8580e7b41953381e5bc49646b", data.salt.toHexKey()) assertEquals("c33f02a7dcaac8bdd8da23cd449783240b6ebc12edeea7bf", data.nonce.toHexKey()) @@ -77,7 +74,7 @@ public class NIP49Test { @Test fun encryptDecryptTestCase() { val encrypted = nip49.encrypt(TEST_CASE_EXPECTED, TEST_CASE_PASSWORD, 16, 0) - val decrypted = nip49.decrypt(encrypted!!, TEST_CASE_PASSWORD) + val decrypted = nip49.decrypt(encrypted, TEST_CASE_PASSWORD) assertEquals(TEST_CASE_EXPECTED, decrypted) } @@ -89,7 +86,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, it.password) + val decrypted = nip49.decrypt(encrypted, it.password) assertEquals(it.secretKey, decrypted) } @@ -108,7 +105,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, samePassword2) + val decrypted = nip49.decrypt(encrypted, samePassword2) assertEquals(TEST_CASE_EXPECTED, decrypted) } diff --git a/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt index bb522a8b7c..314166c723 100644 --- a/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt +++ b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt @@ -20,7 +20,17 @@ */ package com.vitorpamplona.quartz +import java.util.zip.GZIPInputStream + actual class TestResourceLoader { + actual fun loadDecompressString(file: String): String = + this@TestResourceLoader + .javaClass.classLoader + ?.getResourceAsStream(file) + ?.let { GZIPInputStream(it) } + ?.bufferedReader() + ?.use { it.readText() } ?: throw IllegalArgumentException("Resource not found: $file") + actual fun loadString(file: String): String = this@TestResourceLoader .javaClass.classLoader diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt index f362e4199c..7bfa83a2ff 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt @@ -20,12 +20,38 @@ */ package com.vitorpamplona.quartz.utils +import com.goterl.lazysodium.LazySodium import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.Sodium import com.goterl.lazysodium.SodiumAndroid actual object LibSodiumInstance { - private val libSodium = SodiumAndroid() - private val lazySodium = LazySodiumAndroid(libSodium) + private val libSodium: Sodium = + try { + // If we are running in a host test, SodiumJava might be available. + // SodiumJava uses a ResourceLoader to find the dylib/so/dll in the jar. + Class + .forName("com.goterl.lazysodium.SodiumJava") + .getConstructor() + .newInstance() as Sodium + } catch (_: Exception) { + SodiumAndroid() + } + + private val lazySodium: LazySodium = + if (libSodium is SodiumAndroid) { + LazySodiumAndroid(libSodium) + } else { + // this should only happen on test cases + val sodiumJava = + Class + .forName("com.goterl.lazysodium.SodiumJava") + + Class + .forName("com.goterl.lazysodium.LazySodiumJava") + .getConstructor(sodiumJava) + .newInstance(libSodium) as LazySodium + } actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt( message: ByteArray, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt deleted file mode 100644 index 7d157db80e..0000000000 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt +++ /dev/null @@ -1,61 +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.quartz.utils - -import androidx.core.net.toUri -import java.net.URLDecoder - -actual class UriParser actual constructor( - uri: String, -) { - val myUri = uri.toUri() - - val fragments: Map by lazy { - myUri.fragment?.ifBlank { null }?.let { keyValuePair -> - keyValuePair.split('&').associate { paramValue -> - val parts = paramValue.split("=", limit = 2) - if (parts.size == 2) { - parts[0] to URLDecoder.decode(parts[1], "UTF-8") - } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" - } - } - } ?: emptyMap() - } - - actual fun scheme(): String? = myUri.scheme - - actual fun host(): String? = myUri.host - - actual fun port(): Int? { - // android.net.Uri.getPort() returns -1 if the port is not set, so we handle that case. - val port = myUri.port - return if (port == -1) null else port - } - - actual fun path(): String? = myUri.path - - actual fun queryParameterNames() = myUri.queryParameterNames - - actual fun getQueryParameter(param: String) = myUri.getQueryParameter(param) - - actual fun fragments(): Map = fragments -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt new file mode 100644 index 0000000000..9e707b75b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.json.JsonNull.content + +@Immutable +class AttestationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) + + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun validity() = tags.validity() + + fun status() = tags.status() + + fun validFrom() = tags.validFrom() + + fun validTo() = tags.validTo() + + fun request() = tags.request() + + fun requestId() = tags.requestId() + + fun requestAddress() = tags.requestAddress() + + fun isRevoked() = status() == AttestationStatus.REVOKED + + fun assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse) + + fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + + fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) + + companion object { + const val KIND = 31871 + const val ALT_DESCRIPTION = "Attestation" + + fun buildEvent( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + about(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + + fun buildReplaceable( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutReplaceable(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + + fun buildAddress( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutAddressable(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..b64090b6fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation + +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag + +fun TagArrayBuilder.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity)) + +fun TagArrayBuilder.status(status: AttestationStatus) = addUnique(StatusTag.assemble(status)) + +fun TagArrayBuilder.validFrom(timestamp: Long) = addUnique(ValidFromTag.assemble(timestamp)) + +fun TagArrayBuilder.validTo(timestamp: Long) = addUnique(ValidToTag.assemble(timestamp)) + +fun TagArrayBuilder.request(request: EventHintBundle) = addUnique(RequestTag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutAddressable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutReplaceable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.about(request: EventHintBundle) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt new file mode 100644 index 0000000000..2b545818e2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation + +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.validity() = firstNotNullOfOrNull(ValidityTag::parse) + +fun TagArray.status() = firstNotNullOfOrNull(StatusTag::parse) + +fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse) + +fun TagArray.validTo() = firstNotNullOfOrNull(ValidToTag::parse) + +fun TagArray.request() = firstNotNullOfOrNull(RequestTag::parse) + +fun TagArray.requestId() = firstNotNullOfOrNull(RequestTag::parseAddressId) + +fun TagArray.requestAddress() = firstNotNullOfOrNull(RequestTag::parseAddress) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt new file mode 100644 index 0000000000..d22000d12a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class RequestTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + fun toTagArray() = assemble(address, relayHint) + + fun toTagIdOnly() = assemble(address, null) + + companion object { + const val TAG_NAME = "request" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR) + + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + fun isTagged( + tag: Array, + address: ApprovedAddressTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + fun parse(tag: Array): ApprovedAddressTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + + val address = Address.parse(tag[1]) ?: return null + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return ApprovedAddressTag(address, relayHint) + } + + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + return Address.parse(tag[1])?.toValue() + } + + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val address = Address.parse(tag[1]) ?: return null + ensure(address.kind != CommunityDefinitionEvent.KIND) { return null } + return address + } + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt new file mode 100644 index 0000000000..424c698db8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class AttestationStatus( + val code: String, +) { + ACCEPTED("accepted"), + REJECTED("rejected"), + VERIFYING("verifying"), + VERIFIED("verified"), + REVOKED("revoked"), +} + +class StatusTag { + companion object { + const val TAG_NAME = "s" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): AttestationStatus? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + AttestationStatus.ACCEPTED.code -> AttestationStatus.ACCEPTED + AttestationStatus.REJECTED.code -> AttestationStatus.REJECTED + AttestationStatus.VERIFYING.code -> AttestationStatus.VERIFYING + AttestationStatus.VERIFIED.code -> AttestationStatus.VERIFIED + AttestationStatus.REVOKED.code -> AttestationStatus.REVOKED + else -> null + } + } + + fun assemble(status: AttestationStatus) = arrayOf(TAG_NAME, status.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidFromTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidFromTag.kt new file mode 100644 index 0000000000..f33c6d7f83 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidFromTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ValidFromTag { + companion object { + const val TAG_NAME = "valid_from" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(time: Long) = arrayOf(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidToTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidToTag.kt new file mode 100644 index 0000000000..ae7832daf2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidToTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ValidToTag { + companion object { + const val TAG_NAME = "valid_to" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(time: Long) = arrayOf(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt new file mode 100644 index 0000000000..72d7a23c5c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class Validity( + val code: String, +) { + VALID("valid"), + INVALID("invalid"), +} + +class ValidityTag { + companion object { + const val TAG_NAME = "v" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Validity? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + Validity.VALID.code -> Validity.VALID + Validity.INVALID.code -> Validity.INVALID + else -> null + } + } + + fun assemble(validity: Validity) = arrayOf(TAG_NAME, validity.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt new file mode 100644 index 0000000000..49bd5109fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.proficiency + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class AttestorProficiencyEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun kinds() = tags.kinds() + + fun description() = tags.description() + + companion object { + const val KIND = 11871 + const val ALT_DESCRIPTION = "Attestor Proficiency Declaration" + + fun build( + kinds: List, + description: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + kinds(kinds) + description?.let { desc(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..fc617bb515 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.proficiency + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) + +fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt new file mode 100644 index 0000000000..a6dfab4653 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.proficiency + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.kinds() = mapNotNull(KindTag::parse) + +fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt new file mode 100644 index 0000000000..704d856804 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt @@ -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.quartz.experimental.attestations.recommendation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class AttestorRecommendationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun kinds() = tags.kinds() + + fun description() = tags.description() + + companion object { + const val KIND = 31873 + const val ALT_DESCRIPTION = "Attestor Recommendation" + + fun build( + attestorPubKey: HexKey, + kinds: List, + description: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(attestorPubKey) + kinds(kinds) + description?.let { desc(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..23dfbc7e79 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) + +fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt new file mode 100644 index 0000000000..d784f3d9ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.kinds() = mapNotNull(KindTag::parse) + +fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt new file mode 100644 index 0000000000..62b5399206 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "desc" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/KindTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/KindTag.kt new file mode 100644 index 0000000000..aae521c71a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/KindTag.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation.tags + +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class KindTag { + companion object { + const val TAG_NAME = "k" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Kind? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(kind: Kind) = arrayOf(TAG_NAME, kind.toString()) + + fun assemble(kinds: List) = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt new file mode 100644 index 0000000000..f5c646a730 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.request + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.attestations.request.attestorPubKeys +import com.vitorpamplona.quartz.experimental.attestations.request.cashuToken +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.let + +@Immutable +class AttestationRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) + + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun cashuToken() = tags.cashuToken() + + fun assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse) + + fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + + fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) + + companion object { + const val KIND = 31872 + const val ALT_DESCRIPTION = "Attestation Request" + + fun buildEvent( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + about(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + + fun buildReplaceable( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutReplaceable(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + + fun buildAddress( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutAddressable(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..e356deadd2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.request + +import com.vitorpamplona.quartz.experimental.attestations.request.tags.CashuTokenTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag + +fun TagArrayBuilder.attestorPubKeys(pubKeys: List) = addAll(pubKeys.map { PTag.assemble(it, null) }) + +fun TagArrayBuilder.cashuToken(token: String) = addUnique(CashuTokenTag.assemble(token)) + +fun TagArrayBuilder.aboutAddressable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutReplaceable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.about(request: EventHintBundle) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayExt.kt new file mode 100644 index 0000000000..53e3c2ed05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.request + +import com.vitorpamplona.quartz.experimental.attestations.request.tags.CashuTokenTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.cashuToken() = firstNotNullOfOrNull(CashuTokenTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/tags/CashuTokenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/tags/CashuTokenTag.kt new file mode 100644 index 0000000000..583fd23c92 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/tags/CashuTokenTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.request.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CashuTokenTag { + companion object { + const val TAG_NAME = "cashu_token" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(token: String) = arrayOf(TAG_NAME, token) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt index 3b9fbdd86b..bec27039a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt @@ -27,19 +27,19 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.MinimumTag import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -fun TagArrayBuilder.consensusThreshold(percentage: Double) = addUnique(ConsensusThresholdTag.assemble(percentage)) +fun TagArrayBuilder.consensusThreshold(percentage: Double) = addUnique(ConsensusThresholdTag.assemble(percentage)) -fun TagArrayBuilder.minAmount(value: Long) = addUnique(MinimumTag.assemble(value)) +fun TagArrayBuilder.minAmount(value: Long) = addUnique(MinimumTag.assemble(value)) -fun TagArrayBuilder.maxAmount(value: Long) = addUnique(MaximumTag.assemble(value)) +fun TagArrayBuilder.maxAmount(value: Long) = addUnique(MaximumTag.assemble(value)) -fun TagArrayBuilder.closedAt(timestamp: Long) = addUnique(ClosedAtTag.assemble(timestamp)) +fun TagArrayBuilder.closedAt(timestamp: Long) = addUnique(ClosedAtTag.assemble(timestamp)) -fun TagArrayBuilder.pollOption( +fun TagArrayBuilder.pollOption( index: Int, description: String, ) = add(PollOptionTag.assemble(index, description)) -fun TagArrayBuilder.pollOptions(options: Map) = addAll(options.map { PollOptionTag.assemble(it.key, it.value) }) +fun TagArrayBuilder.pollOptions(options: Map) = addAll(options.map { PollOptionTag.assemble(it.key, it.value) }) -fun TagArrayBuilder.pollOptions(options: List) = addAll(options.map { it.toTagArray() }) +fun TagArrayBuilder.pollOptions(options: List) = addAll(options.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt index 19bf092b5e..b22d81aede 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class PollNoteEvent( +class ZapPollEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -131,9 +131,9 @@ class PollNoteEvent( post: String, options: List, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate { - val tags = TagArrayBuilder() + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val tags = TagArrayBuilder() tags.pollOptions(options) tags.alt(ALT_DESCRIPTION) tags.apply(initializer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt index ff099fae41..d5cdbee526 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.Serializable @Immutable +@Serializable(with = EventKSerializer::class) open class Event( val id: HexKey, val pubKey: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt index b801307a09..99fb60435a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt @@ -41,6 +41,8 @@ expect object OptimizedJsonMapper { fun fromJsonToEventTemplate(json: String): EventTemplate + fun fromJsonToEventList(json: String): List + fun fromJsonToRumor(json: String): Rumor fun toJson(tags: Array>): String diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt new file mode 100644 index 0000000000..4d3eba2998 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object CommandKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Command") + + override fun serialize( + encoder: Encoder, + value: Command, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + add(JsonPrimitive(value.label())) + when (value) { + is ReqCmd -> { + add(JsonPrimitive(value.subId)) + for (filter in value.filters) { + add(FilterKSerializer.serializeToElement(filter)) + } + } + + is EventCmd -> { + add(EventKSerializer.serializeToElement(value.event)) + } + + is CloseCmd -> { + add(JsonPrimitive(value.subId)) + } + + is AuthCmd -> { + add(EventKSerializer.serializeToElement(value.event)) + } + + is CountCmd -> { + add(JsonPrimitive(value.queryId)) + for (filter in value.filters) { + add(FilterKSerializer.serializeToElement(filter)) + } + } + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Command { + val jsonDecoder = decoder as JsonDecoder + val array = jsonDecoder.decodeJsonElement().jsonArray + val type = array[0].jsonPrimitive.content + + return when (type) { + ReqCmd.LABEL -> { + val subId = array[1].jsonPrimitive.content + val filters = + (2 until array.size).map { i -> + FilterKSerializer.deserializeFromElement(array[i].jsonObject) + } + ReqCmd(subId, filters) + } + + CountCmd.LABEL -> { + val queryId = array[1].jsonPrimitive.content + val filters = + (2 until array.size).map { i -> + FilterKSerializer.deserializeFromElement(array[i].jsonObject) + } + CountCmd(queryId, filters) + } + + EventCmd.LABEL -> { + EventCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject)) + } + + CloseCmd.LABEL -> { + CloseCmd(array[1].jsonPrimitive.content) + } + + AuthCmd.LABEL -> { + AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) + } + + else -> { + throw IllegalArgumentException("Message $type is not supported") + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt new file mode 100644 index 0000000000..ed0ff95aea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object CountResultKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CountResult") { + element("count") + element("pubkey") + } + + override fun serialize( + encoder: Encoder, + value: CountResult, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(value: CountResult): JsonObject = + buildJsonObject { + put("count", value.count) + // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate + put("pubkey", value.approximate) + } + + override fun deserialize(decoder: Decoder): CountResult { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): CountResult = + CountResult( + count = jsonObject["count"]!!.jsonPrimitive.int, + approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt new file mode 100644 index 0000000000..4eba5b3973 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.EventFactory +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object EventKSerializer : KSerializer { + private val emptyTagArray = emptyArray>() + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Event") { + element("id") + element("pubkey") + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + element("sig") + } + + override fun serialize( + encoder: Encoder, + value: Event, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(event: Event): JsonObject = + buildJsonObject { + put("id", event.id) + put("pubkey", event.pubKey) + put("created_at", event.createdAt) + put("kind", event.kind) + put("tags", TagArrayKSerializer.serializeToElement(event.tags)) + put("content", event.content) + put("sig", event.sig) + } + + override fun deserialize(decoder: Decoder): Event { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): Event { + var id: HexKey = "" + var pubKey: HexKey = "" + var createdAt: Long = 0 + var kind: Kind = 0 + var tags: TagArray = emptyTagArray + var content = "" + var sig: HexKey = "" + + for ((key, value) in jsonObject) { + when (key) { + "id" -> id = value.jsonPrimitive.content + "pubkey" -> pubKey = value.jsonPrimitive.content + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + "sig" -> sig = value.jsonPrimitive.content + } + } + + if (pubKey.isEmpty()) { + throw IllegalArgumentException("Event not found") + } + + return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt new file mode 100644 index 0000000000..58ca13df3a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object EventTemplateKSerializer : KSerializer> { + private val emptyTagArray = emptyArray>() + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("EventTemplate") { + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + } + + override fun serialize( + encoder: Encoder, + value: EventTemplate, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("created_at", value.createdAt) + put("kind", value.kind) + put("tags", TagArrayKSerializer.serializeToElement(value.tags)) + put("content", value.content) + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): EventTemplate { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + var createdAt = 0L + var kind = 0 + var tags: TagArray = emptyTagArray + var content = "" + + for ((key, value) in jsonObject) { + when (key) { + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + } + } + + return EventTemplate(createdAt, kind, tags, content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt new file mode 100644 index 0000000000..0ce754e0f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +object FilterKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Filter") + + override fun serialize( + encoder: Encoder, + value: Filter, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(filter: Filter): JsonObject = + buildJsonObject { + filter.kinds?.let { kinds -> + put( + "kinds", + buildJsonArray { + for (k in kinds) add(JsonPrimitive(k)) + }, + ) + } + filter.ids?.let { ids -> + put( + "ids", + buildJsonArray { + for (id in ids) add(JsonPrimitive(id)) + }, + ) + } + filter.authors?.let { authors -> + put( + "authors", + buildJsonArray { + for (a in authors) add(JsonPrimitive(a)) + }, + ) + } + filter.tags?.let { tags -> + for ((key, values) in tags) { + put( + "#$key", + buildJsonArray { + for (v in values) add(JsonPrimitive(v)) + }, + ) + } + } + filter.tagsAll?.let { tagsAll -> + for ((key, values) in tagsAll) { + put( + "&$key", + buildJsonArray { + for (v in values) add(JsonPrimitive(v)) + }, + ) + } + } + filter.since?.let { put("since", it) } + filter.until?.let { put("until", it) } + filter.limit?.let { put("limit", it) } + filter.search?.let { put("search", it) } + } + + override fun deserialize(decoder: Decoder): Filter { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): Filter { + val tags = mutableMapOf>() + val tagsAll = mutableMapOf>() + + for ((key, value) in jsonObject) { + when { + key.startsWith("#") -> { + tags[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } + } + + key.startsWith("&") -> { + tagsAll[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } + } + } + } + + return Filter( + ids = jsonObject["ids"]?.jsonArray?.mapNotNull { it.jsonPrimitive.content }, + authors = jsonObject["authors"]?.jsonArray?.mapNotNull { it.jsonPrimitive.content }, + kinds = jsonObject["kinds"]?.jsonArray?.mapNotNull { it.jsonPrimitive.intOrNull }, + tags = tags.ifEmpty { null }, + tagsAll = tagsAll.ifEmpty { null }, + since = jsonObject["since"]?.jsonPrimitive?.longOrNull, + until = jsonObject["until"]?.jsonPrimitive?.longOrNull, + limit = jsonObject["limit"]?.jsonPrimitive?.intOrNull, + search = jsonObject["search"]?.jsonPrimitive?.content, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt new file mode 100644 index 0000000000..93caff876e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerMessageKSerializer +import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerRequestKSerializer +import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerResponseKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47NotificationKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47RequestKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47ResponseKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization.RumorKSerializer +import kotlinx.serialization.json.Json + +class KotlinSerializationMapper { + companion object { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + fun fromJson(jsonStr: String): Event = json.decodeFromString(EventKSerializer, jsonStr) + + fun fromJsonToMessage(jsonStr: String): Message = json.decodeFromString(MessageKSerializer, jsonStr) + + fun fromJsonToCommand(jsonStr: String): Command = json.decodeFromString(CommandKSerializer, jsonStr) + + fun fromJsonToTagArray(jsonStr: String): TagArray = json.decodeFromString(TagArrayKSerializer, jsonStr) + + fun fromJsonToRumor(jsonStr: String): Rumor = json.decodeFromString(RumorKSerializer, jsonStr) + + fun fromJsonToEventTemplate(jsonStr: String): EventTemplate = json.decodeFromString(EventTemplateKSerializer, jsonStr) + + fun fromJsonToEventList(jsonStr: String): List = json.decodeFromString(jsonStr) + + fun toJson(event: Event): String = json.encodeToString(EventKSerializer, event) + + fun toJson(tags: TagArray): String = json.encodeToString(TagArrayKSerializer, tags) + + fun toJson(value: OptimizedSerializable): String = + when (value) { + is Event -> { + json.encodeToString(EventKSerializer, value) + } + + is Filter -> { + json.encodeToString(FilterKSerializer, value) + } + + is Rumor -> { + json.encodeToString(RumorKSerializer, value) + } + + is EventTemplate<*> -> { + @Suppress("UNCHECKED_CAST") + json.encodeToString(EventTemplateKSerializer, value as EventTemplate) + } + + is Message -> { + json.encodeToString(MessageKSerializer, value) + } + + is Command -> { + json.encodeToString(CommandKSerializer, value) + } + + is BunkerRequest -> { + json.encodeToString(BunkerRequestKSerializer, value) + } + + is BunkerResponse -> { + json.encodeToString(BunkerResponseKSerializer, value) + } + + is BunkerMessage -> { + json.encodeToString(BunkerMessageKSerializer, value) + } + + is Request -> { + json.encodeToString(Nip47RequestKSerializer, value) + } + + is Response -> { + json.encodeToString(Nip47ResponseKSerializer, value) + } + + is Notification -> { + json.encodeToString(Nip47NotificationKSerializer, value) + } + + else -> { + throw IllegalArgumentException("Unsupported type: ${value::class}") + } + } + + inline fun fromJsonTo(jsonStr: String): T { + val result: Any = + when (T::class) { + Event::class -> fromJson(jsonStr) + Filter::class -> json.decodeFromString(FilterKSerializer, jsonStr) + Rumor::class -> fromJsonToRumor(jsonStr) + EventTemplate::class -> fromJsonToEventTemplate(jsonStr) + Message::class -> fromJsonToMessage(jsonStr) + Command::class -> fromJsonToCommand(jsonStr) + BunkerRequest::class -> json.decodeFromString(BunkerRequestKSerializer, jsonStr) + BunkerResponse::class -> json.decodeFromString(BunkerResponseKSerializer, jsonStr) + BunkerMessage::class -> json.decodeFromString(BunkerMessageKSerializer, jsonStr) + Response::class -> json.decodeFromString(Nip47ResponseKSerializer, jsonStr) + Request::class -> json.decodeFromString(Nip47RequestKSerializer, jsonStr) + Notification::class -> json.decodeFromString(Nip47NotificationKSerializer, jsonStr) + else -> throw IllegalArgumentException("Unsupported type: ${T::class}") + } + @Suppress("UNCHECKED_CAST") + return result as T + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt new file mode 100644 index 0000000000..34ee1b491a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object MessageKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Message") + + override fun serialize( + encoder: Encoder, + value: Message, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + add(JsonPrimitive(value.label())) + when (value) { + is EventMessage -> { + add(JsonPrimitive(value.subId)) + add(EventKSerializer.serializeToElement(value.event)) + } + + is NoticeMessage -> { + add(JsonPrimitive(value.message)) + } + + is OkMessage -> { + add(JsonPrimitive(value.eventId)) + // Jackson writes success as a string, not boolean + add(JsonPrimitive(value.success.toString())) + if (value.message.isNotBlank()) { + add(JsonPrimitive(value.message)) + } + } + + is AuthMessage -> { + add(JsonPrimitive(value.challenge)) + } + + is NotifyMessage -> { + add(JsonPrimitive(value.message)) + } + + is ClosedMessage -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.message)) + } + + is CountMessage -> { + add(CountResultKSerializer.serializeToElement(value.result)) + } + + is EoseMessage -> { + add(JsonPrimitive(value.subId)) + } + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Message { + val jsonDecoder = decoder as JsonDecoder + val array = jsonDecoder.decodeJsonElement().jsonArray + val type = array[0].jsonPrimitive.content + + return when (type) { + EventMessage.LABEL -> { + val subId = array[1].jsonPrimitive.content + val event = EventKSerializer.deserializeFromElement(array[2].jsonObject) + EventMessage(subId, event) + } + + EoseMessage.LABEL -> { + EoseMessage(array[1].jsonPrimitive.content) + } + + NoticeMessage.LABEL -> { + NoticeMessage(array[1].jsonPrimitive.content) + } + + OkMessage.LABEL -> { + OkMessage( + eventId = array[1].jsonPrimitive.content, + success = array[2].jsonPrimitive.boolean, + message = if (array.size > 3) array[3].jsonPrimitive.content else "", + ) + } + + AuthMessage.LABEL -> { + AuthMessage(array[1].jsonPrimitive.content) + } + + NotifyMessage.LABEL -> { + NotifyMessage(array[1].jsonPrimitive.content) + } + + ClosedMessage.LABEL -> { + ClosedMessage( + subId = array[1].jsonPrimitive.content, + message = if (array.size > 2) array[2].jsonPrimitive.content else "", + ) + } + + CountMessage.LABEL -> { + val queryId = array[1].jsonPrimitive.content + val result = CountResultKSerializer.deserializeFromElement(array[2].jsonObject) + CountMessage(queryId, result) + } + + else -> { + throw IllegalArgumentException("Message $type is not supported") + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt new file mode 100644 index 0000000000..c9ebbe5eaf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +object TagArrayKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + ListSerializer(ListSerializer(String.serializer())).descriptor + + override fun serialize( + encoder: Encoder, + value: TagArray, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + for (tag in value) { + add( + buildJsonArray { + for (s in tag) { + add(JsonPrimitive(s)) + } + }, + ) + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): TagArray { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement()) + } + + fun deserializeFromElement(element: JsonElement): TagArray { + val array = element.jsonArray + val outerList = ArrayList>(array.size) + for (inner in array) { + val innerArray = inner.jsonArray + val innerList = ArrayList(innerArray.size.coerceAtLeast(5)) + for (s in innerArray) { + if (s is JsonNull) { + innerList.add("") + } else { + innerList.add(s.jsonPrimitive.content) + } + } + outerList.add(innerList.toTypedArray()) + } + return outerList.toTypedArray() + } + + fun serializeToElement(value: TagArray): JsonArray = + buildJsonArray { + for (tag in value) { + add( + buildJsonArray { + for (s in tag) { + add(JsonPrimitive(s)) + } + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index 668c1ff984..e4d11a98b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -interface INostrClient { +interface INostrClient : AutoCloseable { fun connectedRelaysFlow(): StateFlow> fun availableRelaysFlow(): StateFlow> @@ -136,4 +136,6 @@ object EmptyNostrClient : INostrClient { override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + + override fun close() {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 8d40797e3a..a314a0dd85 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -77,11 +78,15 @@ import kotlinx.coroutines.launch */ class NostrClient( private val websocketBuilder: WebsocketBuilder, - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + private val parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ) : INostrClient, - IRelayClientListener { + IRelayClientListener, + AutoCloseable { private val relayPool: RelayPool = RelayPool(websocketBuilder, this) + /** Scope for all subscriptions. */ + private val scope = CoroutineScope(parentScope.coroutineContext + SupervisorJob()) + private val activeRequests: PoolRequests = PoolRequests() private val activeCounts: PoolCounts = PoolCounts() private val eventOutbox: PoolEventOutbox = PoolEventOutbox() @@ -326,4 +331,13 @@ class NostrClient( override fun connectedRelaysFlow() = relayPool.connectedRelays override fun availableRelaysFlow() = relayPool.availableRelays + + override fun close() { + disconnect() + listeners = emptySet() + activeCounts.destroy() + activeRequests.destroy() + eventOutbox.destroy() + scope.cancel() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt new file mode 100644 index 0000000000..b1ea3d05d7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Sends a NIP-45 COUNT query to a single relay and suspends until + * the result arrives or the timeout expires. + * + * @param relay Target relay to query. + * @param filter The filter to count against. + * @param timeoutMs How long to wait for a response (default 15 s). + * @return The [CountResult], or `null` on timeout. + */ +suspend fun INostrClient.queryCountSuspend( + relay: NormalizedRelayUrl, + filter: Filter, + timeoutMs: Long = 15_000, +): CountResult? { + val subId = newSubId() + val resultChannel = Channel(UNLIMITED) + + val listener = + object : IRelayClientListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage && msg.queryId == subId) { + resultChannel.trySend(msg.result) + } + } + } + + subscribe(listener) + + queryCount(subId = subId, filters = mapOf(relay to listOf(filter))) + + val result = + withTimeoutOrNull(timeoutMs) { + resultChannel.receive() + } + + close(subId) + unsubscribe(listener) + resultChannel.close() + + return result +} + +/** + * Sends NIP-45 COUNT queries to multiple relays in parallel + * (one filter per relay) and suspends until all results arrive + * or the timeout expires. + * + * @param filters Map of relay -> filter to count. + * @param timeoutMs How long to wait for all responses (default 15 s). + * @return Map of relay -> [CountResult] for every relay that responded in time. + */ +suspend fun INostrClient.queryCountSuspend( + filters: Map>, + timeoutMs: Long = 15_000, +): Map { + if (filters.isEmpty()) return emptyMap() + + val subIdToRelay = mutableMapOf() + val resultChannel = Channel>(UNLIMITED) + + val listener = + object : IRelayClientListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val relayUrl = subIdToRelay[msg.queryId] ?: return + resultChannel.trySend(relayUrl to msg.result) + } + } + } + + subscribe(listener) + + filters.forEach { (relay, filterList) -> + val subId = newSubId() + subIdToRelay[subId] = relay + queryCount(subId = subId, filters = mapOf(relay to filterList)) + } + + val results = mutableMapOf() + + withTimeoutOrNull(timeoutMs) { + while (results.size < filters.size) { + val (relay, result) = resultChannel.receive() + results[relay] = result + } + } + + subIdToRelay.keys.forEach { close(it) } + unsubscribe(listener) + resultChannel.close() + + return results +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientReqBypassingRelayLimitsExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientReqBypassingRelayLimitsExt.kt new file mode 100644 index 0000000000..96219ea0ea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientReqBypassingRelayLimitsExt.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient.close +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient.openReqSubscription +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.coroutineContext +import kotlin.math.min + +/** + * Downloads all pages of events matching [filters] from a single [relay] using + * paginated `until` cursors. + * + * After EOSE the oldest [Event.createdAt] seen in that page minus one becomes the + * next `until`, and the query repeats until the relay returns no new events. + * + * Event counting is tracked per filter using [Filter.match]. A filter is considered + * fulfilled when the number of matching events reaches its [Filter.limit]. Pagination + * stops when all filters with limits are fulfilled or when a page returns no events. + * Filters without a limit are considered unbounded and only stop on empty pages. + * + * @param relay The relay to query. + * @param filters Filters to apply on every page (the `until` field is overwritten per page). + * @param timeoutMs Maximum time to wait for a single page's EOSE before giving up. + * @param onEvent Called for every event received (in page order, after each EOSE). + * @return Total number of events received across all pages. + */ +suspend fun INostrClient.reqBypassingRelayLimits( + relay: NormalizedRelayUrl, + filters: List, + timeoutMs: Long = 30_000L, + onNewPage: ((Long) -> Unit)? = null, + onEvent: (Event) -> Unit, +): Int { + var until: Long? = null + var totalEvents = 0 + + // Track how many matching events each filter has received so far. + val matchCountPerFilter = IntArray(filters.size) + + val subId = newSubId() + + while (true) { + coroutineContext.ensureActive() + + val pagedFilters = + if (until == null) { + filters + } else { + onNewPage?.invoke(until) + filters.map { + it.copy(until = until) + } + } + + // Only include filters that still need more events. + val remainingFilters = + pagedFilters.filterIndexed { index, filter -> + val limit = filter.limit + limit == null || matchCountPerFilter[index] < limit + } + + if (remainingFilters.isEmpty()) break + + val doneChannel = Channel(Channel.CONFLATED) + + var pageCount = 0 + var pageMinTs = Long.MAX_VALUE + + try { + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Check if the relay is returning what we asked before moving forward + var atLeastOne = false + for (i in pagedFilters.indices) { + val limit = pagedFilters[i].limit + if ((limit == null || matchCountPerFilter[i] < limit) && pagedFilters[i].match(event)) { + matchCountPerFilter[i]++ + atLeastOne = true + } + } + if (atLeastOne) { + onEvent(event) + pageCount++ + if (event.createdAt < pageMinTs) { + pageMinTs = event.createdAt + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(Unit) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(Unit) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + doneChannel.trySend(Unit) + } + } + + openReqSubscription(subId, mapOf(relay to remainingFilters), listener) + + withTimeoutOrNull(timeoutMs) { + doneChannel.receive() + } + + close(subId) + doneChannel.close() + } finally { + close(subId) + doneChannel.close() + } + + if (pageCount == 0) break + + totalEvents += pageCount + + // Advance cursor: next page starts just before the oldest event seen. + until = min((until ?: Long.MAX_VALUE) - 1, pageMinTs - 1) + } + + return totalEvents +} + +suspend fun INostrClient.reqBypassingRelayLimits( + relay: String, + filters: List, + timeoutMs: Long = 30_000L, + onNewPage: ((Long) -> Unit)? = null, + onEvent: (Event) -> Unit, +): Int = + reqBypassingRelayLimits( + relay = RelayUrlNormalizer.normalize(relay), + filters = filters, + timeoutMs = timeoutMs, + onNewPage = onNewPage, + onEvent = onEvent, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt index 1d894cd206..b797ab647c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt @@ -67,7 +67,7 @@ suspend fun INostrClient.downloadFirstEvent( subscriptionId: String = newSubId(), filters: Map>, ): Event? { - val resultChannel = Channel(UNLIMITED) + val resultChannel = Channel(UNLIMITED) val listener = object : IRequestListener { @@ -79,6 +79,29 @@ suspend fun INostrClient.downloadFirstEvent( ) { resultChannel.trySend(event) } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + resultChannel.trySend(null) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + resultChannel.trySend(null) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + resultChannel.trySend(null) + } } openReqSubscription(subscriptionId, filters, listener) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index eba39649b7..ceb4b36de0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientLis import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -62,6 +63,7 @@ class RelayLogger( is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}") is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}") + is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate}") is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt index c64c0a607f..75b8ff0a49 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt @@ -213,4 +213,10 @@ class PoolCounts { ) { // mark as impossible to get count from this relay } + + fun destroy() { + relayState.clear() + relays.tryEmit(emptySet()) + queries.clear() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index 23ed960272..49b0e2a168 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -28,26 +28,63 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlin.compareTo class PoolEventOutbox { private var eventOutbox = mapOf() val relays = MutableStateFlow(setOf()) - fun updateRelays() { - val myRelays = mutableSetOf() - eventOutbox.values.forEach { - myRelays.addAll(it.relaysLeft()) + fun needsToUpdateRelays(): Boolean { + val currentRelays = relays.value + + var relaysToRemoveCounter = 0 + + currentRelays.forEach { currentRelay -> + if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) { + relaysToRemoveCounter++ + } } - if (relays.value != myRelays) { - relays.tryEmit(myRelays) + var relaysToAddCounter = 0 + eventOutbox.values.forEach { outboxState -> + if (outboxState.relaysRemaining.any { it !in currentRelays }) { + relaysToAddCounter++ + } + } + + return relaysToRemoveCounter > 0 || relaysToAddCounter > 0 + } + + fun updateRelays() { + if (needsToUpdateRelays()) { + relays.update { currentRelays -> + val relaysToRemove = mutableSetOf() + + currentRelays.forEach { currentRelay -> + if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) { + relaysToRemove.add(currentRelay) + } + } + + val relaysToAdd = mutableSetOf() + eventOutbox.values.forEach { outboxState -> + outboxState.relaysRemaining.forEach { relay -> + if (relay !in relaysToAdd && relay !in currentRelays) { + relaysToAdd.add(relay) + } + } + } + + (currentRelays - relaysToRemove) + relaysToAdd + } } } fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set { val myEvents = mutableSetOf() eventOutbox.forEach { (eventId, outboxCache) -> - if (url in outboxCache.relays) { + if (url in outboxCache.relaysRemaining) { myEvents.add(eventId) } } @@ -72,7 +109,12 @@ class PoolEventOutbox { id: HexKey, url: NormalizedRelayUrl, ) { - eventOutbox[id]?.newTry(url) + val waiting = eventOutbox[id] + waiting?.newTry(url) + if (waiting?.isDone() == true) { + eventOutbox = eventOutbox - waiting.event.id + updateRelays() + } } fun newResponse( @@ -84,15 +126,13 @@ class PoolEventOutbox { val waiting = eventOutbox[id] if (waiting != null) { waiting.newResponse(url, success, message) - clear() + if (waiting.isDone()) { + eventOutbox = eventOutbox - waiting.event.id + updateRelays() + } } } - fun clear() { - eventOutbox = eventOutbox.filter { !it.value.isDone() } - updateRelays() - } - // -------------------------- // State management functions // -------------------------- @@ -143,9 +183,14 @@ class PoolEventOutbox { errorMessage: String, ) { eventOutbox.forEach { - if (relay in it.value.relays) { + if (relay in it.value.relaysRemaining) { newResponse(it.key, relay, false, errorMessage) } } } + + fun destroy() { + eventOutbox = emptyMap() + relays.tryEmit(emptySet()) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index bb2407501f..f75f3aa6ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -26,35 +26,37 @@ import com.vitorpamplona.quartz.utils.TimeUtils class PoolEventOutboxState( val event: Event, - var relays: Set, + var relaysRemaining: Set, ) { - private var tries = mapOf() + private var failures = mapOf() fun updateRelays(newRelays: Set) { - relays = newRelays + relaysRemaining = newRelays } - fun isDone(url: NormalizedRelayUrl) = tries[url]?.isDone() ?: false + fun isDone() = relaysRemaining.isEmpty() - fun isDone() = relays.all { isDone(it) } + fun relaysLeft(): Set = relaysRemaining - fun relaysLeft(): Set = relays.filterTo(mutableSetOf()) { !isDone(it) } - - fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url) + fun isSupposedToGo(url: NormalizedRelayUrl) = url in relaysRemaining fun forEachUnsentEvent( url: NormalizedRelayUrl, run: (url: Event) -> Unit, ) = if (isSupposedToGo(url)) run(event) else null - fun remainingRelays() = relays.filterTo(mutableSetOf(), ::isSupposedToGo) + fun remainingRelays() = relaysRemaining fun newTry(url: NormalizedRelayUrl) { - val currentTries = tries[url] + val currentTries = failures[url] if (currentTries != null) { currentTries.addTriedTime(TimeUtils.now()) + if (currentTries.isDone()) { + relaysRemaining = relaysRemaining - url + failures = failures - url + } } else { - tries = tries + (url to Tries(listOf(TimeUtils.now()))) + failures = failures + (url to Tries(listOf(TimeUtils.now()))) } } @@ -63,38 +65,44 @@ class PoolEventOutboxState( success: Boolean, message: String, ) { - val currentTries = tries[url] - if (currentTries != null) { - currentTries.addResponse(Response(success, message)) + val currentTries = failures[url] + if (success || message.shouldDiscard()) { + relaysRemaining = relaysRemaining - url + failures = failures - url } else { - tries = tries + ( - url to - Tries( - listOf(TimeUtils.now() - 1), - listOf(Response(success, message)), - ) - ) + if (currentTries != null) { + currentTries.addResponse(message) + } else { + failures = failures + ( + url to + Tries( + listOf(TimeUtils.now() - 1), + listOf(message), + ) + ) + } } } + fun String.shouldDiscard() = + this.startsWith("replaced:") || + this.startsWith("pow:") || + this.startsWith("deleted:") || + this.startsWith("invalid:") + // Tries 3 times class Tries( var tries: List = listOf(), - var responses: List = listOf(), + var responses: List = listOf(), ) { - fun isDone() = responses.any { it.success } || responses.size > 2 || tries.size > 3 + fun isDone() = responses.size > 2 || tries.size > 3 - fun addResponse(r: Response) { - responses += r + fun addResponse(msg: String) { + responses += msg } fun addTriedTime(tried: Long) { tries += tried } } - - class Response( - val success: Boolean, - val message: String, - ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index c0e41864e0..0ea0c49d76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -265,11 +265,15 @@ class PoolRequests { errorMessage: String, ) { relayState.forEach { subId, state -> - desiredSubListeners.get(subId)?.onCannotConnect( - message = errorMessage, - relay = url, - forFilters = state.lastKnownFilterStates(url), - ) + // These are all my subs.. need to figure out which relays have them + val subs = desiredSubs.get(subId) + if (subs != null && url in subs.keys) { + desiredSubListeners.get(subId)?.onCannotConnect( + relay = url, + message = errorMessage, + forFilters = state.lastKnownFilterStates(url), + ) + } } } @@ -329,4 +333,11 @@ class PoolRequests { // They are the same don't do anything. } } + + fun destroy() { + relayState.clear() + desiredSubs.clear() + desiredSubListeners.clear() + desiredRelays.tryEmit(emptySet()) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index fdd0c60ef7..835499361f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -42,6 +42,8 @@ class RelayStats( override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat() } + fun snapshot(): Map = innerCache.snapshot() + fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen") private val clientListener = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt index 410753b063..49ce72567f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.MessageKSerializer +import kotlinx.serialization.Serializable +@Serializable(with = MessageKSerializer::class) interface Message : OptimizedSerializable { fun label(): String } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt index b7cb4c7a63..0a46a5dda8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.CommandKSerializer +import kotlinx.serialization.Serializable +@Serializable(with = CommandKSerializer::class) interface Command : OptimizedSerializable { fun label(): String diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt new file mode 100644 index 0000000000..9a65df954c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Defines custom behavior for this relay. + */ +interface IRelayPolicy { + fun onConnect(send: (Message) -> Unit) + + /** + * Evaluates whether an incoming EVENT command should be accepted. + * + * @param cmd The event the client wants to publish. + * @return [PolicyResult.Accepted] to store the event, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: EventCmd): PolicyResult + + /** + * Evaluates a REQ command, optionally rewriting the filter list. + * + * @param cmd The filters from the REQ command. + * @return [PolicyResult.Accepted] with an optional replacement filter list, or [PolicyResult.Rejected]. + */ + fun accept(cmd: ReqCmd): PolicyResult + + /** + * Evaluates a COUNT command, optionally rewriting the filter list. + * + * @param cmd The filters from the COUNT command. + * @return [PolicyResult.Accepted] to allow counting, or [PolicyResult.Rejected] with a reason. + */ + fun accept(cmd: CountCmd): PolicyResult + + /** + * Evaluates whether an incoming AUTH command should be accepted. + * + * @param cmd The event the client wants to auth. + * @return [PolicyResult.Accepted] to log in, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: AuthCmd): PolicyResult + + /** + * Filters a live event before it is forwarded to a subscriber. + * + * Called for each event that matches a subscription's filters. Return + * true to deliver the event, false to suppress it for this session. + * + * @param event The event about to be sent. + */ + fun canSendToSession(event: Event): Boolean = true + + operator fun plus(other: IRelayPolicy) = listOf(this, other) +} + +sealed interface PolicyResult { + class Accepted( + val cmd: T, + ) : PolicyResult + + class Rejected( + val reason: String, + ) : PolicyResult +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt new file mode 100644 index 0000000000..4268dcef39 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * A reactive event store that combines historical data retrieval with live event streaming. + * + * This class wraps an [IEventStore] to provide real-time updates. When a [query] is executed, + * it first replays all matching historical events from the underlying store, signals the + * End of Stored Events (EOSE), and then continues to stream matching new events as they + * are inserted. + * + * @property store The underlying persistent storage for events. + */ +class LiveEventStore( + private val store: IEventStore, +) { + private val newEventStream = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, // Optional: adjust for backpressure + onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior + ) + + fun insert(event: Event) { + store.insert(event) + newEventStream.tryEmit(event) + } + + suspend fun query( + filters: List, + onEach: (Event) -> Unit, + onEose: () -> Unit, + ) { + // 1. Replay stored events matching filters. + store.query(filters, onEach) + + // 2. Signal end of stored events. + onEose() + + // 3. Stream live events until cancelled. + newEventStream.collect { newEvent -> + if (filters.any { it.match(newEvent) }) { + onEach(newEvent) + } + } + } + + fun count(filters: List) = store.count(filters) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt new file mode 100644 index 0000000000..78651df5ef --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlin.coroutines.CoroutineContext + +/** + * Represents a Nostr relay server that manages client connections, event storage, and verification. + * + * This class acts as the central coordinator for a relay server, handling the lifecycle of [RelaySession]s + * and providing access to the underlying event store. + * + * @param store The [IEventStore] backing this relay. + * @param policyBuilder Controls requirements for relay commands. + */ +class NostrServer( + private val store: IEventStore, + private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy }, + private val parentContext: CoroutineContext = SupervisorJob(), +) { + private val subStore = LiveEventStore(store) + + /** Scope for all subscriptions. */ + private val scope = CoroutineScope(parentContext + SupervisorJob()) + + /** Active client sessions keyed by an opaque connection id. */ + private val connections = LargeCache() + + /** + * Registers a new client connection. + * + * @param send Callback the server uses to send JSON messages to this client. + * Implementations must be safe to call from any coroutine. + */ + fun connect(send: (String) -> Unit) = + RelaySession( + policy = policyBuilder(), + store = subStore, + scope = scope, + onSend = send, + onClose = { session -> + connections.remove(session.hashCode()) + }, + ).also { session -> + connections.put(session.hashCode(), session) + } + + /** + * Shuts down the server, cancelling all subscriptions and sessions. + */ + fun shutdown() { + connections.forEach { _, session -> session.cancelAllSubscriptions() } + connections.clear() + scope.cancel() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt new file mode 100644 index 0000000000..2a384d8a68 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Represents an active session between a Nostr client and the relay. + * Each one of these is a connection that can hold many subscriptions + */ +class RelaySession( + private val store: LiveEventStore, + val policy: IRelayPolicy, + private val scope: CoroutineScope, + private val onSend: (String) -> Unit, + private val onClose: (RelaySession) -> Unit, +) : AutoCloseable { + private val subscriptions = LargeCache() + + private fun addSubscription( + subId: String, + job: Job, + ) = subscriptions.put(subId, job) + + private fun cancelSubscription(subId: String): Boolean = + subscriptions.remove(subId)?.let { + it.cancel() + true + } ?: false + + fun cancelAllSubscriptions() { + subscriptions.forEach { _, job -> job.cancel() } + subscriptions.clear() + } + + fun send(message: Message) { + try { + onSend(OptimizedJsonMapper.toJson(message)) + } catch (e: Exception) { + Log.w("ClientSession", "Failed to send to ${e.message}") + } + } + + override fun close() { + cancelAllSubscriptions() + onClose(this) + } + + /** + * Processes a raw JSON message from a client. + * + * Parses the message as a NIP-01 command and dispatches it. + */ + suspend fun receive(command: String) { + val cmd = + try { + OptimizedJsonMapper.fromJsonToCommand(command) + } catch (_: Exception) { + send(NoticeMessage("error: could not parse message")) + return + } + + if (!cmd.isValid()) { + send(NoticeMessage("error: invalid command")) + return + } + + when (cmd) { + is AuthCmd -> handleAuth(cmd) + is EventCmd -> handleEvent(cmd) + is ReqCmd -> handleReq(cmd) + is CloseCmd -> handleClose(cmd) + is CountCmd -> handleCount(cmd) + else -> send(NoticeMessage("error: unsupported command ${cmd.label()}")) + } + } + + // -- NIP-42: AUTH --------------------------------------------------------- + private fun handleAuth(cmd: AuthCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + send(OkMessage(cmd.event.id, true, "")) + } + + // -- NIP-01: REQ ---------------------------------------------------------- + private fun handleReq(cmd: ReqCmd) { + // Cancel any existing subscription with the same id (NIP-01 spec). + cancelSubscription(cmd.subId) + + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.subId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val job = + scope.launch { + try { + store.query( + filters = filters, + onEach = { event -> + if (policy.canSendToSession(event)) { + send(EventMessage(cmd.subId, event)) + } + }, + onEose = { send(EoseMessage(cmd.subId)) }, + ) + } catch (_: kotlinx.coroutines.CancellationException) { + // Subscription was closed – this is expected. + } + } + + addSubscription(cmd.subId, job) + } + + // -- NIP-01: CLOSE -------------------------------------------------------- + private fun handleClose(cmd: CloseCmd) { + val cancelled = cancelSubscription(cmd.subId) + if (!cancelled) { + send(ClosedMessage(cmd.subId, "error: no such subscription")) + } + } + + // -- NIP-01: EVENT -------------------------------------------------------- + private fun handleEvent(cmd: EventCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + try { + store.insert(cmd.event) + send(OkMessage(cmd.event.id, true, "")) + } catch (e: Exception) { + send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) + } + } + + // -- NIP-45: COUNT -------------------------------------------------------- + private fun handleCount(cmd: CountCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.queryId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val total = store.count(filters) + + send(CountMessage(cmd.queryId, CountResult(total))) + } + + init { + policy.onConnect(::send) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt new file mode 100644 index 0000000000..fe7429a43e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Allows all commands without authentication. This is the default policy. + */ +object EmptyPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd) + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt new file mode 100644 index 0000000000..525bfa144c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Requires authentication for all EVENT, REQ, and COUNT commands. + * Replicates the previous `requireAuth = true` behavior. + */ +open class FullAuthPolicy( + val relay: NormalizedRelayUrl, +) : IRelayPolicy { + /** The challenge string sent to this client for NIP-42 authentication. */ + val challenge: String = RandomInstance.randomChars(32) + + /** Set of pubkeys that have successfully authenticated on this session. */ + val authenticatedUsers = mutableSetOf() + + /** Returns true if at least one pubkey has authenticated. */ + fun isAuthenticated(): Boolean = authenticatedUsers.isNotEmpty() + + override fun onConnect(send: (Message) -> Unit) { + send(AuthMessage(challenge)) + } + + override fun accept(cmd: AuthCmd): PolicyResult { + val event = cmd.event + + if (event.isExpired()) { + return PolicyResult.Rejected("invalid: auth event expired") + } + + if (!TimeUtils.withinTenMinutes(event.createdAt)) { + return PolicyResult.Rejected("invalid: created_at is too far from the current time") + } + + if (event.challenge() != challenge) { + return PolicyResult.Rejected("invalid: challenge does not match") + } + + if (event.relay() != relay) { + return PolicyResult.Rejected("invalid: relay url does not match") + } + + authenticatedUsers.add(event.pubKey) + + return PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: EventCmd): PolicyResult = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: ReqCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: CountCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt new file mode 100644 index 0000000000..67b3b0f1ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt @@ -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.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +class PolicyStack( + vararg policies: IRelayPolicy, +) : IRelayPolicy { + val policies = policies.toList() + + override fun onConnect(send: (Message) -> Unit) { + policies.forEach { it.onConnect(send) } + } + + override fun accept(cmd: EventCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: ReqCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: CountCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: AuthCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + private inline fun runPolicies( + initialCmd: T, + operation: (IRelayPolicy, T) -> PolicyResult, + ): PolicyResult { + var currentCmd = initialCmd + for (policy in policies) { + val result = operation(policy, currentCmd) + if (result is PolicyResult.Rejected) return result + currentCmd = (result as PolicyResult.Accepted).cmd + } + return PolicyResult.Accepted(currentCmd) + } + + override fun canSendToSession(event: Event): Boolean = policies.all { it.canSendToSession(event) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt new file mode 100644 index 0000000000..283fda6707 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Allows all commands without authentication. This is the default policy. + */ +object VerifyPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt index 1eebac72f1..b387cbd567 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.signers +import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable @@ -27,8 +28,12 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventTemplateKSerializer import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.Serializable +@Immutable +@Serializable(with = EventTemplateKSerializer::class) class EventTemplate( val createdAt: Long, val kind: Int, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt similarity index 95% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 5f38c460f7..1d3d74efdb 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter interface IEventStore { - fun insert(event: Event): Boolean + fun insert(event: Event) interface ITransaction { - fun insert(event: Event): Boolean + fun insert(event: Event) } fun transaction(body: ITransaction.() -> Unit) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt similarity index 92% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt index f0c6e9f71c..4181a34d30 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection class AddressableModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE UNIQUE INDEX addressable_idx @@ -57,7 +57,7 @@ class AddressableModule : IModule { ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt similarity index 94% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index f04844b652..1b50598c1a 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent class DeletionRequestModule( - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { fun rejectDeletedEventsSQLTemplate(): String = @@ -58,7 +58,7 @@ class DeletionRequestModule( * deleted by ID or ATag including GiftWraps that * must be checked against the p-tag (pubkey_owner_hash) */ - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ") db.execSQL( """ @@ -75,13 +75,13 @@ class DeletionRequestModule( ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} fun insert( event: Event, - db: SQLiteDatabase, + db: SQLiteConnection, ) { if (event is DeletionEvent) { val idValues = event.deleteEventIds() @@ -103,7 +103,7 @@ class DeletionRequestModule( pubkey: HexKey, idValues: List, addresses: List
, - hasher: TagNameValueHasher, + hasher: com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher, ): List { val owner = hasher.hash(pubkey) val idParams = idValues.joinToString(",") { "?" } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt similarity index 89% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt index 06e2c59ec7..748975b39d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection class EphemeralModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { // Rejects all ephemeral events. db.execSQL( """ @@ -38,7 +38,7 @@ class EphemeralModule : IModule { ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt similarity index 76% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index c40992ed79..75ef7ce718 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -28,10 +28,10 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent class EventIndexesModule( - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE TABLE event_headers ( @@ -128,7 +128,7 @@ class EventIndexesModule( ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS event_tags") db.execSQL("DROP TABLE IF EXISTS event_headers") } @@ -151,10 +151,9 @@ class EventIndexesModule( fun insert( event: Event, - db: SQLiteDatabase, + db: SQLiteConnection, ): Long { val hasher = hasher(db) - val stmt = db.compileStatement(sqlInsertHeader) val kindLong = event.kind.toLong() val pubkeyHash = hasher.hash(event.pubKey) @@ -168,52 +167,56 @@ class EventIndexesModule( val eTagHash = hasher.hashETag(event.id) - stmt.bindString(1, event.id) - stmt.bindString(2, event.pubKey) - stmt.bindLong(3, event.createdAt) - stmt.bindLong(4, kindLong) - stmt.bindString(5, OptimizedJsonMapper.toJson(event.tags)) - stmt.bindString(6, event.content) - stmt.bindString(7, event.sig) - if (event is AddressableEvent) { - val dTag = event.dTag() - stmt.bindString(8, dTag) - stmt.bindLong(9, eventOwnerHash) - stmt.bindLong(10, eTagHash) - stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) - } else { - stmt.bindNull(8) - stmt.bindLong(9, eventOwnerHash) - stmt.bindLong(10, eTagHash) - stmt.bindNull(11) - } - - val headerId = stmt.executeInsert() - - val stmtTags = db.compileStatement(sqlInsertTags) - - // sorting helps SQLLite by avoiding - // rebalancing the tree every new insert - val indexableTags = ArrayList() - for (idx in event.tags.indices) { - if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) { - indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) + db.prepare(sqlInsertHeader).use { stmt -> + stmt.bindText(1, event.id) + stmt.bindText(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.bindLong(4, kindLong) + stmt.bindText(5, OptimizedJsonMapper.toJson(event.tags)) + stmt.bindText(6, event.content) + stmt.bindText(7, event.sig) + if (event is AddressableEvent) { + val dTag = event.dTag() + stmt.bindText(8, dTag) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) + } else { + stmt.bindNull(8) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindNull(11) } + stmt.step() } - indexableTags.sort() - indexableTags.forEach { - stmtTags.bindLong(1, headerId) - stmtTags.bindLong(2, it) - stmtTags.bindLong(3, event.createdAt) - stmtTags.bindLong(4, kindLong) - stmtTags.bindLong(5, pubkeyHash) - stmtTags.executeInsert() + + val headerId = db.lastInsertRowId() + + db.prepare(sqlInsertTags).use { stmtTags -> + // sorting helps SQLLite by avoiding + // rebalancing the tree every new insert + val indexableTags = ArrayList() + for (idx in event.tags.indices) { + if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) { + indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) + } + } + indexableTags.sort() + indexableTags.forEach { + stmtTags.bindLong(1, headerId) + stmtTags.bindLong(2, it) + stmtTags.bindLong(3, event.createdAt) + stmtTags.bindLong(4, kindLong) + stmtTags.bindLong(5, pubkeyHash) + stmtTags.step() + stmtTags.reset() + } } return headerId } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_tags") db.execSQL("DELETE FROM event_headers") } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt similarity index 90% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 4bff2b9d4e..3f85e755f9 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -20,18 +20,17 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore class EventStore( - context: Context, dbName: String? = "events.db", - val relayUrl: String? = "wss://quartz.local", + relayUrl: String? = "wss://quartz.local", val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IEventStore { - val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy) + val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relayUrl, indexStrategy) override fun insert(event: Event) = store.insertEvent(event) @@ -61,5 +60,5 @@ class EventStore( override fun deleteExpiredEvents() = store.deleteExpiredEvents() - override fun close() = store.close() + override fun close() = store.connection.close() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt similarity index 84% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt index 5359be543f..99baf1cace 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip40Expiration.expiration class ExpirationModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE TABLE event_expirations ( @@ -51,7 +51,7 @@ class ExpirationModule : IModule { ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS event_expirations") } @@ -64,18 +64,19 @@ class ExpirationModule : IModule { fun insert( event: Event, headerId: Long, - db: SQLiteDatabase, + db: SQLiteConnection, ) { val exp = event.expiration() if (exp != null && exp > 0) { - val stmt = db.compileStatement(insertExpiration) - stmt.bindLong(1, headerId) - stmt.bindLong(2, exp) - stmt.executeInsert() + db.prepare(insertExpiration).use { stmt -> + stmt.bindLong(1, headerId) + stmt.bindLong(2, exp) + stmt.step() + } } } - val deleteExpiredEvents = + val deleteExpiredEventsSQL = """ DELETE FROM event_headers WHERE row_id IN ( @@ -84,11 +85,11 @@ class ExpirationModule : IModule { ); """.trimIndent() - fun deleteExpiredEvents(db: SQLiteDatabase) { - db.compileStatement(deleteExpiredEvents).execute() + fun deleteExpiredEvents(db: SQLiteConnection) { + db.prepare(deleteExpiredEventsSQL).use { it.step() } } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_expirations") } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt similarity index 85% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt index a0b32a150b..a7884a224b 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip50Search.SearchableEvent @@ -30,7 +30,7 @@ class FullTextSearchModule : IModule { val eventHeaderRowIdName = "event_header_row_id" val contentName = "content" - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { val ftsVersion = FullTextSearchModule().versionFinder(db) db.execSQL( """ @@ -53,7 +53,7 @@ class FullTextSearchModule : IModule { ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS $tableName") } @@ -66,17 +66,18 @@ class FullTextSearchModule : IModule { fun insert( event: Event, headerId: Long, - db: SQLiteDatabase, + db: SQLiteConnection, ) { if (event is SearchableEvent) { - val stmt = db.compileStatement(insertFTS) - stmt.bindLong(1, headerId) - stmt.bindString(2, event.indexableContent()) - stmt.executeInsert() + db.prepare(insertFTS).use { stmt -> + stmt.bindLong(1, headerId) + stmt.bindText(2, event.indexableContent()) + stmt.step() + } } } - fun versionFinder(db: SQLiteDatabase): Int = + fun versionFinder(db: SQLiteConnection): Int = try { try { db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)") @@ -90,7 +91,7 @@ class FullTextSearchModule : IModule { 3 } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_fts") } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt similarity index 88% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt index 0f239f8538..90a1051d91 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection interface IModule { - fun create(db: SQLiteDatabase) + fun create(db: SQLiteConnection) - fun drop(db: SQLiteDatabase) + fun drop(db: SQLiteConnection) - fun deleteAll(db: SQLiteDatabase) + fun deleteAll(db: SQLiteConnection) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt similarity index 100% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt similarity index 80% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index c876c7b962..86113f6176 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -20,20 +20,21 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.explainQuery import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where import com.vitorpamplona.quartz.utils.EventFactory class QueryBuilder( val fts: FullTextSearchModule, - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, val indexStrategy: IndexingStrategy, ) { // ------------ @@ -41,23 +42,23 @@ class QueryBuilder( // ------------ fun query( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runQuery(toSql(filter, hasher(db))) fun query( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (T) -> Unit, ) = db.runQuery(toSql(filter, hasher(db)), onEach) fun query( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runQuery(toSql(filters, hasher(db))) fun query( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (T) -> Unit, ) = db.runQuery(toSql(filters, hasher(db)), onEach) @@ -66,23 +67,23 @@ class QueryBuilder( // --------------------------- fun rawQuery( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runRawQuery(toSql(filter, hasher(db))) fun rawQuery( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (RawEvent) -> Unit, ) = db.runRawQuery(toSql(filter, hasher(db)), onEach) fun rawQuery( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runRawQuery(toSql(filters, hasher(db))) fun rawQuery( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (RawEvent) -> Unit, ) = db.runRawQuery(toSql(filters, hasher(db)), onEach) @@ -92,7 +93,7 @@ class QueryBuilder( fun planQuery( filter: Filter, hasher: TagNameValueHasher, - db: SQLiteDatabase, + db: SQLiteConnection, ): String { val query = toSql(filter, hasher) return db.explainQuery(query.sql, query.args.toTypedArray()) @@ -101,7 +102,7 @@ class QueryBuilder( fun planQuery( filters: List, hasher: TagNameValueHasher, - db: SQLiteDatabase, + db: SQLiteConnection, ): String { val query = toSql(filters, hasher) return db.explainQuery(query.sql, query.args.toTypedArray()) @@ -184,62 +185,74 @@ class QueryBuilder( ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""} """.trimIndent() - private fun SQLiteDatabase.runQuery(query: QuerySpec): List = - rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - ArrayList(cursor.count).apply { - while (cursor.moveToNext()) { - add(cursor.toEvent()) - } + private fun SQLiteConnection.runQuery(query: QuerySpec): List = + prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) } + val results = ArrayList() + while (stmt.step()) { + results.add(stmt.toEvent()) + } + results } - private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List = - rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - ArrayList(cursor.count).apply { - while (cursor.moveToNext()) { - add(cursor.toRawEvent()) - } + private fun SQLiteConnection.runRawQuery(query: QuerySpec): List = + prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) } + val results = ArrayList() + while (stmt.step()) { + results.add(stmt.toRawEvent()) + } + results } - private inline fun SQLiteDatabase.runQuery( + private inline fun SQLiteConnection.runQuery( query: QuerySpec, onEach: (T) -> Unit, - ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - while (cursor.moveToNext()) { - onEach(cursor.toEvent()) + ) = prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + while (stmt.step()) { + onEach(stmt.toEvent()) } } - private inline fun SQLiteDatabase.runRawQuery( + private inline fun SQLiteConnection.runRawQuery( query: QuerySpec, onEach: (RawEvent) -> Unit, - ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - while (cursor.moveToNext()) { - onEach(cursor.toRawEvent()) + ) = prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + while (stmt.step()) { + onEach(stmt.toRawEvent()) } } - private fun Cursor.toEvent() = + private fun SQLiteStatement.toEvent() = EventFactory.create( - getString(0).intern(), - getString(1).intern(), + getText(0), + getText(1), getLong(2), getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(getString(4)), - getString(5), - getString(6), + OptimizedJsonMapper.fromJsonToTagArray(getText(4)), + getText(5), + getText(6), ) - private fun Cursor.toRawEvent() = + private fun SQLiteStatement.toRawEvent() = RawEvent( - getString(0), - getString(1), + getText(0), + getText(1), getLong(2), getInt(3), - getString(4), - getString(5), - getString(6), + getText(4), + getText(5), + getText(6), ) // -------------- @@ -247,7 +260,7 @@ class QueryBuilder( // ------------- fun count( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val newFilter = filter.toFilterWithDTags() @@ -277,27 +290,30 @@ class QueryBuilder( fun count( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args) } - private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") + private fun SQLiteConnection.countEverything() = runCount("SELECT count(*) as count FROM event_headers") - private fun SQLiteDatabase.countIn( + private fun SQLiteConnection.countIn( rowIdQuery: String, args: List, ) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args) - private fun SQLiteDatabase.runCount( + private fun SQLiteConnection.runCount( sql: String, args: List = emptyList(), ): Int = - rawQuery(sql, args.toTypedArray()).use { cursor -> - cursor.moveToNext() - cursor.getInt(0) + prepare(sql).use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + stmt.step() + stmt.getInt(0) } // -------------- @@ -305,7 +321,7 @@ class QueryBuilder( // ------------- fun delete( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) @@ -318,17 +334,25 @@ class QueryBuilder( fun delete( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) } - private fun SQLiteDatabase.runDelete( + private fun SQLiteConnection.runDelete( sql: String, args: List = emptyList(), - ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + ): Int { + prepare("DELETE FROM event_headers WHERE row_id IN ($sql)").use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + stmt.step() + } + return changes() + } // --------------------------------- // Prepare unions of all the filters @@ -448,7 +472,9 @@ class QueryBuilder( where { // the order should match indexes // ids reduce the filter the most - filter.ids?.let { equalsOrIn("event_headers.id", it) } + filter.ids?.let { + equalsOrIn("event_headers.id", it) + } // it's quite rare to have 2 tags in the filter, but possible nonDTagsIn.keys.forEachIndexed { index, tagName -> @@ -476,18 +502,25 @@ class QueryBuilder( } else { "event_tagsAll${index}_$valueIndex.tag_hash" } - equals(column, hasher.hash(tagName, tagValue)) } } // range search is bad but most of the time these are up the top with few elements. if (reverseLookup) { - filter.kinds?.let { equalsOrIn("event_tags.kind", it) } - filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) } + filter.kinds?.let { + equalsOrIn("event_tags.kind", it) + } + filter.authors?.let { + equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) + } - filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) } - filter.until?.let { lessThanOrEquals("event_tags.created_at", it) } + filter.since?.let { + greaterThanOrEquals("event_tags.created_at", it) + } + filter.until?.let { + lessThanOrEquals("event_tags.created_at", it) + } // there are indexes for these, starting with tags. filter.tags?.forEach { (tagName, tagValues) -> @@ -496,8 +529,12 @@ class QueryBuilder( } } } else { - filter.kinds?.let { equalsOrIn("event_headers.kind", it) } - filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + filter.kinds?.let { + equalsOrIn("event_headers.kind", it) + } + filter.authors?.let { + equalsOrIn("event_headers.pubkey", it) + } // there are indexes for these, starting with tags. filter.tags?.forEach { (tagName, tagValues) -> @@ -506,8 +543,12 @@ class QueryBuilder( } } - filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } - filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + filter.since?.let { + greaterThanOrEquals("event_headers.created_at", it) + } + filter.until?.let { + lessThanOrEquals("event_headers.created_at", it) + } // no need to add the replaceable because query_by_kind_pubkey_created already covers it val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false @@ -561,18 +602,30 @@ class QueryBuilder( where { // the order should match indexes // ids reduce the filter the most - ids?.let { equalsOrIn("event_headers.id", it) } + ids?.let { + equalsOrIn("event_headers.id", it) + } match(fts.tableName, search) - kinds?.let { equalsOrIn("event_headers.kind", it) } - authors?.let { equalsOrIn("event_headers.pubkey", it) } + kinds?.let { + equalsOrIn("event_headers.kind", it) + } + authors?.let { + equalsOrIn("event_headers.pubkey", it) + } // there are indexes for these, starting with tags. - dTags?.let { equalsOrIn("event_headers.d_tag", it) } + dTags?.let { + equalsOrIn("event_headers.d_tag", it) + } - since?.let { greaterThanOrEquals("event_headers.created_at", it) } - until?.let { lessThanOrEquals("event_headers.created_at", it) } + since?.let { + greaterThanOrEquals("event_headers.created_at", it) + } + until?.let { + lessThanOrEquals("event_headers.created_at", it) + } // if this is a dTag filter, it is likely that all kinds are addressables // and so force the use of the addressable index @@ -618,16 +671,28 @@ class QueryBuilder( where { // the order should match indexes // ids reduce the filter the most - ids?.let { equalsOrIn("id", it) } + ids?.let { + equalsOrIn("id", it) + } - kinds?.let { equalsOrIn("kind", it) } - authors?.let { equalsOrIn("pubkey", it) } + kinds?.let { + equalsOrIn("kind", it) + } + authors?.let { + equalsOrIn("pubkey", it) + } // there are indexes for these, starting with tags. - dTags?.let { equalsOrIn("d_tag", it) } + dTags?.let { + equalsOrIn("d_tag", it) + } - since?.let { greaterThanOrEquals("created_at", it) } - until?.let { lessThanOrEquals("created_at", it) } + since?.let { + greaterThanOrEquals("created_at", it) + } + until?.let { + lessThanOrEquals("created_at", it) + } // if this is a dTag filter, it is likely that all kinds are addressables // and so force the use of the addressable index @@ -678,7 +743,8 @@ class QueryBuilder( val search: String? = null, ) { fun isSimpleSearch() = - search != null && search.isNotEmpty() && + search != null && + search.isNotEmpty() && (nonDTagsIn == null || nonDTagsIn.isEmpty()) && (nonDTagsAll == null || nonDTagsAll.isEmpty()) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt similarity index 85% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt index 8d2d307ee9..4b2d6bf278 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt @@ -20,25 +20,29 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection fun SQLiteEventStore.explainQuery( sql: String, args: Array = emptyArray(), -) = readableDatabase.explainQuery(sql, args.map { it.toString() }.toTypedArray()) +) = connection.explainQuery(sql, args.map { it.toString() }.toTypedArray()) -fun SQLiteDatabase.explainQuery( +fun SQLiteConnection.explainQuery( sql: String, args: Array = emptyArray(), ): String = - rawQuery("EXPLAIN QUERY PLAN $sql", args).use { cursor -> + prepare("EXPLAIN QUERY PLAN $sql").use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + val treeIndex = mutableMapOf() val rootNodes = mutableListOf() - while (cursor.moveToNext()) { - val id = cursor.getInt(0) - val parentId = cursor.getInt(1) - val detail = cursor.getString(3) + while (stmt.step()) { + val id = stmt.getInt(0) + val parentId = stmt.getInt(1) + val detail = stmt.getText(3) val line = PlanNode(detail) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md similarity index 98% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/README.md rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md index b82888b111..deee5cee37 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md @@ -87,7 +87,7 @@ It is initialized with a `SQLiteDatabase` instance, and it manages the underlyin To initialize the `EventStore` in your Application class: ```kotlin -val eventStore = EventStore(context, "dbname.db", relayUrlIdentifier) +val eventStore = EventStore("dbname.db", relayUrlIdentifier) ``` ### Querying Events diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt similarity index 92% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt index 558e174c95..4ed253b13e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection class ReplaceableModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE UNIQUE INDEX replaceable_idx @@ -54,7 +54,7 @@ class ReplaceableModule : IModule { ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt similarity index 88% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt index aafb548e7b..c85022bd74 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -20,14 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent class RightToVanishModule( - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, ) : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE TABLE event_vanish ( @@ -88,7 +88,7 @@ class RightToVanishModule( ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS event_vanish") } @@ -102,18 +102,19 @@ class RightToVanishModule( event: Event, relayUrl: String?, headerId: Long, - db: SQLiteDatabase, + db: SQLiteConnection, ) { if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { - val stmt = db.compileStatement(insertRTV) - stmt.bindLong(1, headerId) - stmt.bindLong(2, hasher(db).hash(event.pubKey)) - stmt.bindLong(3, event.createdAt) - stmt.executeInsert() + db.prepare(insertRTV).use { stmt -> + stmt.bindLong(1, headerId) + stmt.bindLong(2, hasher(db).hash(event.pubKey)) + stmt.bindLong(3, event.createdAt) + stmt.step() + } } } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_vanish") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionExt.kt new file mode 100644 index 0000000000..0d5a8464cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionExt.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +inline fun SQLiteStatement.use(block: (SQLiteStatement) -> T): T { + try { + return block(this) + } finally { + close() + } +} + +fun SQLiteConnection.execSQL(sql: String) { + prepare(sql).use { it.step() } +} + +fun SQLiteConnection.execSQL( + sql: String, + args: Array, +) { + prepare(sql).use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindAny(index + 1, arg) + } + stmt.step() + } +} + +fun SQLiteConnection.lastInsertRowId(): Long = + prepare("SELECT last_insert_rowid()").use { stmt -> + stmt.step() + stmt.getLong(0) + } + +fun SQLiteConnection.deleteRows( + table: String, + whereClause: String, + args: Array, +): Int { + execSQL("DELETE FROM $table WHERE $whereClause", args) + return changes() +} + +fun SQLiteConnection.changes(): Int = + prepare("SELECT changes()").use { stmt -> + stmt.step() + stmt.getInt(0) + } + +inline fun SQLiteConnection.transaction(body: SQLiteConnection.() -> T): T { + execSQL("BEGIN IMMEDIATE TRANSACTION") + try { + val result = body() + execSQL("END TRANSACTION") + return result + } catch (e: Throwable) { + execSQL("ROLLBACK TRANSACTION") + throw e + } +} + +fun SQLiteStatement.bindAny( + index: Int, + value: Any, +) { + when (value) { + is String -> bindText(index, value) + is Long -> bindLong(index, value) + is Int -> bindInt(index, value) + is Double -> bindDouble(index, value) + is ByteArray -> bindBlob(index, value) + else -> bindText(index, value.toString()) + } +} + +inline fun SQLiteConnection.rawQuery( + sql: String, + args: List, + block: (SQLiteStatement) -> T, +): T = + prepare(sql).use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + block(stmt) + } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt similarity index 67% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 48c4d52469..604c671c12 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -20,11 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import android.database.sqlite.SQLiteConstraintException -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper -import androidx.core.database.sqlite.transaction +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteException +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind @@ -35,22 +34,31 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.EventFactory import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext class SQLiteEventStore( - val context: Context, + val driver: SQLiteDriver = BundledSQLiteDriver(), val dbName: String? = "events.db", val relayUrl: String? = null, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), -) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { +) { companion object { const val DATABASE_VERSION = 2 } + val connection: SQLiteConnection by lazy { + openAndConfigure() + } + val seedModule = SeedModule() val fullTextSearchModule = FullTextSearchModule() - val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy) + val eventIndexModule = + EventIndexesModule( + seedModule::hasher, + indexStrategy, + ) val replaceableModule = ReplaceableModule() val addressableModule = AddressableModule() @@ -60,7 +68,12 @@ class SQLiteEventStore( val expirationModule = ExpirationModule() val rightToVanishModule = RightToVanishModule(seedModule::hasher) - val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy) + val queryBuilder = + QueryBuilder( + fullTextSearchModule, + seedModule::hasher, + indexStrategy, + ) val modules = listOf( @@ -75,39 +88,56 @@ class SQLiteEventStore( fullTextSearchModule, ) - override fun onConfigure(db: SQLiteDatabase) { - super.onConfigure(db) + private fun openAndConfigure(): SQLiteConnection { + val db = driver.open(dbName ?: ":memory:") // 32MB memory cache db.execSQL("PRAGMA cache_size=-32000;") // makes sure the FKs are sane - db.setForeignKeyConstraintsEnabled(true) + db.execSQL("PRAGMA foreign_keys = ON;") // SQLite implements mutations by appending them to a log, which it occasionally // compacts into the database. This is called Write-Ahead Logging (WAL) - db.enableWriteAheadLogging() + db.execSQL("PRAGMA journal_mode = WAL;") // The DB can be corrupted if the OS is shutdown before sync, which generally // doesn't happen on Android db.execSQL("PRAGMA synchronous = OFF;") + + val currentVersion = getUserVersion(db) + if (currentVersion == 0) { + onCreate(db) + setUserVersion(db, DATABASE_VERSION) + } else if (currentVersion < DATABASE_VERSION) { + onUpgrade(db, currentVersion, DATABASE_VERSION) + setUserVersion(db, DATABASE_VERSION) + } + + return db } - fun dbSizeMB(): Int { - val f1 = context.getDatabasePath(dbName) - val f2 = context.getDatabasePath("$dbName-wal") - val total = f1.length() + f2.length() - return (total / (1024 * 1024)).toInt() + private fun getUserVersion(db: SQLiteConnection): Int = + db.prepare("PRAGMA user_version").use { stmt -> + stmt.step() + stmt.getInt(0) + } + + private fun setUserVersion( + db: SQLiteConnection, + version: Int, + ) { + db.execSQL("PRAGMA user_version = $version") } - override fun onCreate(db: SQLiteDatabase) { + fun onCreate(db: SQLiteConnection) { modules.forEach { it.create(db) } } - override fun onUpgrade( - db: SQLiteDatabase, + fun onUpgrade( + db: SQLiteConnection, oldVersion: Int, newVersion: Int, ) { @@ -125,15 +155,14 @@ class SQLiteEventStore( } fun clearDB() { - val db = writableDatabase - modules.reversed().forEach { it.deleteAll(db) } + modules.reversed().forEach { it.deleteAll(connection) } } suspend fun vacuum() { // 1. ANALYZE: Collects statistics about tables and indices // to help the query planner optimize queries. withContext(Dispatchers.IO) { - writableDatabase.execSQL("VACUUM") + connection.execSQL("VACUUM") } } @@ -141,13 +170,13 @@ class SQLiteEventStore( // 2. VACUUM: Rebuilds the database file, reclaiming unused space // and reducing fragmentation. withContext(Dispatchers.IO) { - writableDatabase.execSQL("ANALYZE") + connection.execSQL("ANALYZE") } } private fun innerInsertEvent( event: Event, - db: SQLiteDatabase, + db: SQLiteConnection, ) { val headerId = eventIndexModule.insert(event, db) deletionModule.insert(event, db) @@ -156,83 +185,84 @@ class SQLiteEventStore( rightToVanishModule.insert(event, relayUrl, headerId, db) } - fun insertEvent(event: Event): Boolean { - if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event") - if (event.kind.isEphemeral()) return false + fun insertEvent(event: Event) { + if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.kind.isEphemeral()) return - writableDatabase.transaction { + connection.transaction { innerInsertEvent(event, this) } - return true } inner class Transaction( - val db: SQLiteDatabase, + val db: SQLiteConnection, ) : IEventStore.ITransaction { - override fun insert(event: Event): Boolean { - if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event") - if (event.kind.isEphemeral()) return false + override fun insert(event: Event) { + if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.kind.isEphemeral()) return innerInsertEvent(event, db) - return true } } fun transaction(body: Transaction.() -> Unit) { - writableDatabase.transaction { + connection.transaction { with(Transaction(this)) { body() } } } - fun query(filter: Filter): List = queryBuilder.query(filter, readableDatabase) + fun query(filter: Filter): List = queryBuilder.query(filter, connection) - fun query(filters: List): List = queryBuilder.query(filters, readableDatabase) + fun query(filters: List): List = queryBuilder.query(filters, connection) fun query( filter: Filter, onEach: (T) -> Unit, - ) = queryBuilder.query(filter, readableDatabase, onEach) + ) = queryBuilder.query(filter, connection, onEach) fun query( filters: List, onEach: (T) -> Unit, - ) = queryBuilder.query(filters, readableDatabase, onEach) + ) = queryBuilder.query(filters, connection, onEach) - fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, readableDatabase) + fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, connection) - fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, readableDatabase) + fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, connection) fun rawQuery( filter: Filter, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filter, readableDatabase, onEach) + ) = queryBuilder.rawQuery(filter, connection, onEach) fun rawQuery( filters: List, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filters, readableDatabase, onEach) + ) = queryBuilder.rawQuery(filters, connection, onEach) - fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase) + fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(connection), connection) - fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase) + fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(connection), connection) - fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase) + fun count(filter: Filter): Int = queryBuilder.count(filter, connection) - fun count(filters: List): Int = queryBuilder.count(filters, readableDatabase) + fun count(filters: List): Int = queryBuilder.count(filters, connection) fun delete(filter: Filter) { - queryBuilder.delete(filter, writableDatabase) + queryBuilder.delete(filter, connection) } fun delete(filters: List) { - queryBuilder.delete(filters, writableDatabase) + queryBuilder.delete(filters, connection) } - fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id)) + fun delete(id: HexKey): Int { + connection.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) + return connection.changes() + } - fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) + fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(connection) } class RawEvent( @@ -246,8 +276,8 @@ class RawEvent( ) { fun toEvent() = EventFactory.create( - id.intern(), - pubKey.intern(), + id, + pubKey, createdAt, kind, OptimizedJsonMapper.fromJsonToTagArray(jsonTags), diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt similarity index 80% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt index 63ef636302..a997497a2c 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt @@ -20,18 +20,19 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.utils.RandomInstance class SeedModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL("CREATE TABLE seeds (seed_value INTEGER)") val insertSeed = "INSERT INTO seeds (seed_value) VALUES (?)" - val stmt = db.compileStatement(insertSeed) - stmt.bindLong(1, RandomInstance.long()) - stmt.executeInsert() + db.prepare(insertSeed).use { stmt -> + stmt.bindLong(1, RandomInstance.long()) + stmt.step() + } // Prevent updates to maintain immutability db.execSQL( @@ -65,19 +66,19 @@ class SeedModule : IModule { ) } - fun getSeed(db: SQLiteDatabase): Long = - db.rawQuery("SELECT seed_value FROM seeds LIMIT 1", null).use { - it.moveToFirst() + fun getSeed(db: SQLiteConnection): Long = + db.prepare("SELECT seed_value FROM seeds LIMIT 1").use { + it.step() it.getLong(0) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS seeds") } - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} private var hasherCache: TagNameValueHasher? = null - fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it } + fun hasher(db: SQLiteConnection): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt similarity index 100% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt similarity index 86% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt index 956d73417d..557c0a6df8 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -20,71 +20,71 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql -sealed class Condition { +sealed interface Condition { data class Raw( val condition: String, - ) : Condition() + ) : Condition data class Equals( val column: String, val value: Any?, - ) : Condition() + ) : Condition data class NotEquals( val column: String, val value: Any?, - ) : Condition() + ) : Condition data class GreaterThan( val column: String, val value: Any, - ) : Condition() + ) : Condition data class GreaterThanOrEquals( val column: String, val value: Any, - ) : Condition() + ) : Condition data class LessThan( val column: String, val value: Any, - ) : Condition() + ) : Condition data class LessThanOrEquals( val column: String, val value: Any, - ) : Condition() + ) : Condition data class Like( val column: String, val value: String, - ) : Condition() + ) : Condition data class Match( val table: String, val value: String, - ) : Condition() + ) : Condition data class IsNull( val column: String, - ) : Condition() + ) : Condition data class IsNotNull( val column: String, - ) : Condition() + ) : Condition data class In( val column: String, val values: List, - ) : Condition() + ) : Condition data class And( val conditions: List, - ) : Condition() + ) : Condition data class Or( val conditions: List, - ) : Condition() + ) : Condition - class Empty : Condition() + class Empty : Condition } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt similarity index 98% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt index 2c8544fe5f..be9bf59c04 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -28,7 +28,10 @@ class SqlSelectionBuilder( fun build(): WhereClause { selectionArgs.clear() // Clear previous args for a fresh build val conditions = buildCondition(condition) - return WhereClause(conditions, selectionArgs) + return WhereClause( + conditions, + selectionArgs, + ) } /** diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt similarity index 72% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt index d4b4db55bb..c3e8f14f8f 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -23,56 +23,83 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql class WhereClauseBuilder { private val conditions = mutableListOf() - fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) } + fun raw(condition: String) = + apply { + conditions.add(Condition.Raw(condition)) + } fun equals( column: String, value: Any?, - ) = apply { conditions.add(Condition.Equals(column, value)) } + ) = apply { + conditions.add(Condition.Equals(column, value)) + } fun notEquals( column: String, value: Any?, - ) = apply { conditions.add(Condition.NotEquals(column, value)) } + ) = apply { + conditions.add(Condition.NotEquals(column, value)) + } fun greaterThan( column: String, value: Any, - ) = apply { conditions.add(Condition.GreaterThan(column, value)) } + ) = apply { + conditions.add(Condition.GreaterThan(column, value)) + } fun greaterThanOrEquals( column: String, value: Any, - ) = apply { conditions.add(Condition.GreaterThanOrEquals(column, value)) } + ) = apply { + conditions.add(Condition.GreaterThanOrEquals(column, value)) + } fun lessThan( column: String, value: Any, - ) = apply { conditions.add(Condition.LessThan(column, value)) } + ) = apply { + conditions.add(Condition.LessThan(column, value)) + } fun lessThanOrEquals( column: String, value: Any, - ) = apply { conditions.add(Condition.LessThanOrEquals(column, value)) } + ) = apply { + conditions.add(Condition.LessThanOrEquals(column, value)) + } fun like( column: String, pattern: String, - ) = apply { conditions.add(Condition.Like(column, pattern)) } + ) = apply { + conditions.add(Condition.Like(column, pattern)) + } fun match( table: String, pattern: String, - ) = apply { conditions.add(Condition.Match(table, pattern)) } + ) = apply { + conditions.add(Condition.Match(table, pattern)) + } - fun isNull(column: String) = apply { conditions.add(Condition.IsNull(column)) } + fun isNull(column: String) = + apply { + conditions.add(Condition.IsNull(column)) + } - fun isNotNull(column: String) = apply { conditions.add(Condition.IsNotNull(column)) } + fun isNotNull(column: String) = + apply { + conditions.add(Condition.IsNotNull(column)) + } fun isIn( column: String, values: List, - ) = apply { conditions.add(Condition.In(column, values)) } + ) = apply { + conditions.add(Condition.In(column, values)) + } fun equalsOrIn( column: String, @@ -119,7 +146,11 @@ class WhereClauseBuilder { } fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { - val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty() + val condition = + WhereClauseBuilder() + .apply(block) + .buildAnd() ?: Condition + .Empty() return SqlSelectionBuilder(condition).build() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt index d79c73717f..f3e9df4722 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt @@ -39,7 +39,7 @@ fun TagArray.isTaggedAddressableKind(kindStr: String) = this.any(ATag::isTaggedW fun TagArray.getTagOfAddressableKind(kind: Int) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kind.toString()) -fun TagArray.getTagOfAddressableKind(kindStr: String) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kindStr.toString()) +fun TagArray.getTagOfAddressableKind(kindStr: String) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kindStr) fun TagArray.taggedATags() = this.mapNotNull(ATag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index 72abf7546a..4d4a44aa82 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -301,7 +301,8 @@ class NamecoinNameResolver( pubkey = rootMatch.content } - firstEntry != null && firstEntry.value is JsonPrimitive && + firstEntry != null && + firstEntry.value is JsonPrimitive && isValidPubkey((firstEntry.value as JsonPrimitive).content) -> { resolvedLocalPart = firstEntry.key pubkey = (firstEntry.value as JsonPrimitive).content diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt new file mode 100644 index 0000000000..025e586cc4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object BunkerMessageKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerMessage") + + override fun serialize( + encoder: Encoder, + value: BunkerMessage, + ) { + val jsonEncoder = encoder as JsonEncoder + when (value) { + is BunkerRequest -> BunkerRequestKSerializer.serialize(jsonEncoder, value) + is BunkerResponse -> BunkerResponseKSerializer.serialize(jsonEncoder, value) + else -> throw IllegalArgumentException("Unknown BunkerMessage type") + } + } + + override fun deserialize(decoder: Decoder): BunkerMessage { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val isRequest = jsonObject.containsKey("method") + + return if (isRequest) { + val id = jsonObject["id"]!!.jsonPrimitive.content + val method = jsonObject["method"]!!.jsonPrimitive.content + val params = + jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() + ?: emptyArray() + dispatchBunkerRequest(id, method, params) + } else { + BunkerResponseKSerializer.deserializeFromElement(jsonObject) + } + } + + private fun dispatchBunkerRequest( + id: String, + method: String, + params: Array, + ): BunkerRequest = + when (method) { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign + .parse(id, params) + } + + else -> { + BunkerRequest(id, method, params) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt new file mode 100644 index 0000000000..7797cf3371 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt @@ -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.quartz.nip46RemoteSigner.kotlinSerialization + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object BunkerRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerRequest") { + element("id") + element("method") + element>("params") + } + + override fun serialize( + encoder: Encoder, + value: BunkerRequest, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("id", value.id) + put("method", value.method) + put( + "params", + buildJsonArray { + for (p in value.params) { + add(JsonPrimitive(p)) + } + }, + ) + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): BunkerRequest { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val id = jsonObject["id"]!!.jsonPrimitive.content + val method = jsonObject["method"]!!.jsonPrimitive.content + val params = + jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() + ?: emptyArray() + + return when (method) { + BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) + BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) + BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) + BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) + BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) + BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) + BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) + BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) + BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) + else -> BunkerRequest(id, method, params) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt new file mode 100644 index 0000000000..9787b10eb3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object BunkerResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerResponse") { + element("id") + element("result") + element("error") + } + + override fun serialize( + encoder: Encoder, + value: BunkerResponse, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("id", value.id) + value.result?.let { put("result", it) } + value.error?.let { put("error", it) } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): BunkerResponse { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): BunkerResponse { + val id = jsonObject["id"]!!.jsonPrimitive.content + val result = jsonObject["result"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val error = jsonObject["error"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + + if (error != null) { + return BunkerResponseError.parse(id, result, error) + } + + if (result != null) { + when (result) { + BunkerResponseAck.RESULT -> { + return BunkerResponseAck.parse(id, result, error) + } + + BunkerResponsePong.RESULT -> { + return BunkerResponsePong.parse(id, result, error) + } + + else -> { + if (result.length == 64 && Hex.isHex(result)) { + return BunkerResponsePublicKey.parse(id, result) + } + + if (result.isNotEmpty() && result[0] == '{') { + try { + return BunkerResponseEvent.parse(id, result) + } catch (_: Exception) { + } + + try { + return BunkerResponseGetRelays.parse(id, result) + } catch (_: Exception) { + } + } + + return BunkerResponse(id, result, error) + } + } + } + + return BunkerResponse(id, result, error) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt new file mode 100644 index 0000000000..4ee11947c6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord + +/** + * High-level NIP-47 Wallet Connect client. + * + * Simplifies the NWC protocol by handling URI parsing, signer creation, + * event building, filter construction, and response decryption. + * + * Usage: + * ```kotlin + * val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + * + * // Build a request event + * val requestEvent = client.payInvoice("lnbc50n1...") + * + * // Send requestEvent to client.relayUrl via your relay connection + * // Subscribe using client.responseFilter(requestEvent.id) for the response + * + * // When response arrives: + * val response = client.parseResponse(responseEvent) + * when (response) { + * is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + * is NwcErrorResponse -> println("Error: ${response.error?.message}") + * } + * ``` + */ +class Nip47Client( + val walletPubKeyHex: HexKey, + val relayUrl: NormalizedRelayUrl, + val signer: NostrSigner, + val useNip44: Boolean = false, +) { + companion object { + /** + * Creates an Nip47Client from a NWC connection URI string. + * + * @param uri NWC URI (e.g., "nostr+walletconnect://pubkey?relay=...&secret=...") + * @throws IllegalArgumentException if the URI is invalid or has no secret + */ + fun fromUri(uri: String): Nip47Client { + val config = Nip47WalletConnect.parse(uri) + return fromNip47URI(config) + } + + /** + * Creates an Nip47Client from parsed NWC connection details. + * + * @param config parsed NWC URI with wallet pubkey, relay, and secret + * @throws IllegalArgumentException if config has no secret + */ + fun fromNip47URI(config: Nip47WalletConnect.Nip47URINorm): Nip47Client { + val secret = config.secret ?: throw IllegalArgumentException("NWC connection requires a secret") + val signer = NostrSignerInternal(KeyPair(secret.hexToByteArray())) + return Nip47Client( + walletPubKeyHex = config.pubKeyHex, + relayUrl = config.relayUri, + signer = signer, + ) + } + } + + // --- Request builders --- + + /** + * Builds a pay_invoice request event. + */ + suspend fun payInvoice( + bolt11: String, + amount: Long? = null, + ): LnZapPaymentRequestEvent = + buildRequest( + if (amount != null) { + PayInvoiceMethod.create(bolt11, amount) + } else { + PayInvoiceMethod.create(bolt11) + }, + ) + + /** + * Builds a pay_keysend request event. + */ + suspend fun payKeysend( + amount: Long, + pubkey: String, + preimage: String? = null, + tlvRecords: List? = null, + ): LnZapPaymentRequestEvent = buildRequest(PayKeysendMethod.create(amount, pubkey, preimage, tlvRecords)) + + /** + * Builds a get_balance request event. + */ + suspend fun getBalance(): LnZapPaymentRequestEvent = buildRequest(GetBalanceMethod.create()) + + /** + * Builds a get_info request event. + */ + suspend fun getInfo(): LnZapPaymentRequestEvent = buildRequest(GetInfoMethod.create()) + + /** + * Builds a make_invoice request event. + */ + suspend fun makeInvoice( + amount: Long, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + ): LnZapPaymentRequestEvent = buildRequest(MakeInvoiceMethod.create(amount, description, descriptionHash, expiry)) + + /** + * Builds a lookup_invoice request event by payment hash. + */ + suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) + + /** + * Builds a lookup_invoice request event by BOLT11 invoice. + */ + suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) + + /** + * Builds a list_transactions request event. + */ + suspend fun listTransactions( + from: Long? = null, + until: Long? = null, + limit: Int? = null, + offset: Int? = null, + unpaid: Boolean? = null, + type: String? = null, + ): LnZapPaymentRequestEvent = buildRequest(ListTransactionsMethod.create(from, until, limit, offset, unpaid, type)) + + /** + * Builds a get_budget request event. + */ + suspend fun getBudget(): LnZapPaymentRequestEvent = buildRequest(GetBudgetMethod.create()) + + /** + * Builds a sign_message request event. + */ + suspend fun signMessage(message: String): LnZapPaymentRequestEvent = buildRequest(SignMessageMethod.create(message)) + + /** + * Builds a make_hold_invoice request event. + */ + suspend fun makeHoldInvoice( + amount: Long, + paymentHash: String, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + minCltvExpiryDelta: Int? = null, + ): LnZapPaymentRequestEvent = buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) + + /** + * Builds a cancel_hold_invoice request event. + */ + suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) + + /** + * Builds a settle_hold_invoice request event. + */ + suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = buildRequest(SettleHoldInvoiceMethod.create(preimage)) + + /** + * Builds a request event from any [Request] object. + * This is the low-level method used by all convenience methods above. + */ + suspend fun buildRequest(request: Request): LnZapPaymentRequestEvent = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletPubKeyHex, + signer = signer, + useNip44 = useNip44, + ) + + // --- Response handling --- + + /** + * Decrypts and parses a response event from the wallet. + */ + suspend fun parseResponse(event: LnZapPaymentResponseEvent): Response = event.decrypt(signer) + + /** + * Decrypts and parses a notification event from the wallet. + */ + suspend fun parseNotification(event: NwcNotificationEvent): Notification = event.decryptNotification(signer) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for responses to a specific request. + * Use this to subscribe on [relayUrl] after sending a request event. + */ + fun responseFilter(requestEventId: HexKey): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("e" to listOf(requestEventId)), + ) + + /** + * Creates a filter to subscribe for all responses from the wallet + * directed to this client. + */ + fun allResponsesFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to subscribe for wallet notifications. + */ + fun notificationsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to fetch the wallet's info event (kind 13194). + */ + fun infoFilter(): Filter = + Filter( + kinds = listOf(NwcInfoEvent.KIND), + authors = listOf(walletPubKeyHex), + limit = 1, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt new file mode 100644 index 0000000000..739351ca16 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse + +/** + * High-level NIP-47 Wallet Connect server (wallet service). + * + * Simplifies building a wallet service that receives NWC requests from clients, + * processes them, and sends back responses and notifications. + * + * Usage: + * ```kotlin + * val server = Nip47Server(walletSigner, supportedMethods, relayUrl) + * + * // Publish capabilities + * val infoEvent = server.buildInfoEvent() + * // Send infoEvent to relay + * + * // Subscribe using server.requestsFilter() on your relay + * + * // When a request arrives: + * val request = server.parseRequest(requestEvent) + * when (request) { + * is GetBalanceMethod -> { + * val response = server.respondGetBalance(requestEvent, balance = 2100000L) + * // Send response to relay + * } + * is PayInvoiceMethod -> { + * // Process payment, then: + * val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + * // Or on error: + * val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + * // Send response to relay + * } + * } + * ``` + */ +class Nip47Server( + val signer: NostrSigner, + val capabilities: List = emptyList(), + val useNip44: Boolean = false, + val encryptionSchemes: List? = null, + val notificationTypes: List? = null, +) { + // --- Info event --- + + /** + * Builds a kind 13194 info event advertising wallet capabilities. + * Sign and publish this event to your relay. + */ + fun buildInfoEvent() = + NwcInfoEvent.build( + capabilities = capabilities, + encryptionSchemes = encryptionSchemes, + notificationTypes = notificationTypes, + ) + + // --- Request parsing --- + + /** + * Decrypts and parses an incoming client request. + */ + suspend fun parseRequest(event: LnZapPaymentRequestEvent): Request = event.decryptRequest(signer) + + // --- Response builders --- + + /** + * Builds a response event from any [Response] object. + */ + suspend fun buildResponse( + response: Response, + requestEvent: LnZapPaymentRequestEvent, + ): LnZapPaymentResponseEvent = + LnZapPaymentResponseEvent.createResponse( + response = response, + requestEvent = requestEvent, + signer = signer, + useNip44 = useNip44, + ) + + /** + * Builds an error response for any method. + */ + suspend fun respondError( + requestEvent: LnZapPaymentRequestEvent, + code: NwcErrorCode, + message: String, + resultType: String? = null, + ): LnZapPaymentResponseEvent { + val method = resultType ?: requestEvent.decryptRequest(signer).method ?: NwcMethod.PAY_INVOICE + return buildResponse(NwcErrorResponse(method, NwcError(code, message)), requestEvent) + } + + /** + * Builds a pay_invoice success response. + */ + suspend fun respondPayInvoice( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayInvoiceSuccessResponse(PayInvoiceSuccessResponse.PayInvoiceResultParams(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a pay_keysend success response. + */ + suspend fun respondPayKeysend( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayKeysendSuccessResponse(PayKeysendSuccessResponse.PayKeysendResult(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a get_balance success response. + */ + suspend fun respondGetBalance( + requestEvent: LnZapPaymentRequestEvent, + balance: Long, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBalanceSuccessResponse(GetBalanceSuccessResponse.GetBalanceResult(balance)), + requestEvent, + ) + + /** + * Builds a get_info success response. + */ + suspend fun respondGetInfo( + requestEvent: LnZapPaymentRequestEvent, + alias: String? = null, + color: String? = null, + pubkey: String? = null, + network: String? = null, + blockHeight: Long? = null, + blockHash: String? = null, + methods: List? = null, + notifications: List? = null, + lud16: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetInfoSuccessResponse( + GetInfoSuccessResponse.GetInfoResult( + alias, + color, + pubkey, + network, + blockHeight, + blockHash, + methods, + notifications, + null, + lud16, + ), + ), + requestEvent, + ) + + /** + * Builds a make_invoice success response. + */ + suspend fun respondMakeInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a lookup_invoice success response. + */ + suspend fun respondLookupInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a list_transactions success response. + */ + suspend fun respondListTransactions( + requestEvent: LnZapPaymentRequestEvent, + transactions: List, + totalCount: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + ListTransactionsSuccessResponse( + ListTransactionsSuccessResponse.ListTransactionsResult(transactions, totalCount), + ), + requestEvent, + ) + + /** + * Builds a get_budget success response. + */ + suspend fun respondGetBudget( + requestEvent: LnZapPaymentRequestEvent, + usedBudget: Long? = null, + totalBudget: Long? = null, + renewsAt: Long? = null, + renewalPeriod: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBudgetSuccessResponse( + GetBudgetSuccessResponse.GetBudgetResult(usedBudget, totalBudget, renewsAt, renewalPeriod), + ), + requestEvent, + ) + + /** + * Builds a sign_message success response. + */ + suspend fun respondSignMessage( + requestEvent: LnZapPaymentRequestEvent, + message: String, + signature: String, + ): LnZapPaymentResponseEvent = + buildResponse( + SignMessageSuccessResponse(SignMessageSuccessResponse.SignMessageResult(message, signature)), + requestEvent, + ) + + // --- Notification builders --- + + /** + * Builds a payment_received notification event. + */ + suspend fun notifyPaymentReceived( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentReceivedNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a payment_sent notification event. + */ + suspend fun notifyPaymentSent( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentSentNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a notification event from any [Notification] object. + */ + suspend fun buildNotification( + notification: Notification, + clientPubkey: HexKey, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = notification, + clientPubkey = clientPubkey, + signer = signer, + ) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for incoming client requests. + * Use this to subscribe on your relay. + */ + fun requestsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentRequestEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt index e3694b02ab..53eb83d7f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -30,11 +30,10 @@ import com.vitorpamplona.quartz.utils.UriParser import kotlinx.coroutines.CancellationException import kotlinx.serialization.Serializable -// Rename to the corect nip number when ready. class Nip47WalletConnect { companion object { fun parse(uri: String): Nip47URINorm { - // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D + // nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=...&lud16=user@example.com val url = UriParser(uri) @@ -55,8 +54,9 @@ class Nip47WalletConnect { val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null") val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url") val secret = url.getQueryParameter("secret") + val lud16 = url.getQueryParameter("lud16") - return Nip47URINorm(pubkeyHex, relayNorm, secret) + return Nip47URINorm(pubkeyHex, relayNorm, secret, lud16) } } @@ -65,6 +65,7 @@ class Nip47WalletConnect { val pubKeyHex: HexKey, val relayUri: String, val secret: HexKey?, + val lud16: String? = null, ) { fun normalize(): Nip47URINorm? = RelayUrlNormalizer.normalizeOrNull(relayUri)?.let { @@ -72,6 +73,7 @@ class Nip47WalletConnect { pubKeyHex, it, secret, + lud16, ) } @@ -86,7 +88,8 @@ class Nip47WalletConnect { val pubKeyHex: HexKey, val relayUri: NormalizedRelayUrl, val secret: HexKey?, + val lud16: String? = null, ) { - fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret) + fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret, lud16) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md new file mode 100644 index 0000000000..0d372e1a49 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -0,0 +1,362 @@ +# NIP-47 Wallet Connect (Quartz) + +Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) — Nostr +Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps** +(like Amethyst) and **wallet service backends** (like Alby Hub). + +## Quick Start — Wallet Client + +Use `Nip47Client` for a high-level API that handles URI parsing, signer creation, +event building, filter construction, and response decryption: + +```kotlin +// 1. Create client from NWC URI +val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + +// 2. Build request events — one method per NWC command +val payEvent = client.payInvoice("lnbc50n1...") +val balanceEvent = client.getBalance() +val infoEvent = client.getInfo() +val invoiceEvent = client.makeInvoice(amount = 50000L, description = "Coffee") +val txEvent = client.listTransactions(limit = 20) + +// 3. Send event to client.relayUrl via your relay connection +// 4. Subscribe using client.responseFilter(payEvent.id) for the response + +// 5. When response arrives, parse it +val response = client.parseResponse(responseEvent) +when (response) { + is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + is GetBalanceSuccessResponse -> println("Balance: ${response.result?.balance} msats") + is NwcErrorResponse -> println("Error: ${response.error?.message}") +} + +// Filter helpers for relay subscriptions +val filter = client.responseFilter(payEvent.id) // Filter for a specific response +val allFilter = client.allResponsesFilter() // Filter for all responses +val notifFilter = client.notificationsFilter() // Filter for notifications +val walletInfo = client.infoFilter() // Filter for wallet info event +``` + +## Quick Start — Wallet Service + +Use `Nip47Server` to build a wallet service that receives requests and sends responses: + +```kotlin +// 1. Create server +val server = Nip47Server( + signer = walletSigner, + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE, NwcMethod.GET_INFO), +) + +// 2. Publish capabilities (kind 13194) +val infoTemplate = server.buildInfoEvent() +// Sign and send: walletSigner.sign(infoTemplate) + +// 3. Subscribe using server.requestsFilter() on your relay + +// 4. When a request arrives, parse and respond +val request = server.parseRequest(requestEvent) +when (request) { + is GetBalanceMethod -> { + val response = server.respondGetBalance(requestEvent, balance = 2100000L) + // Send response to relay + } + is PayInvoiceMethod -> { + // Process payment, then: + val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + // Or on error: + val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + } + is MakeInvoiceMethod -> { + val tx = NwcTransaction(type = NwcTransactionType.INCOMING, invoice = "lnbc...") + val response = server.respondMakeInvoice(requestEvent, tx) + } +} + +// 5. Send notifications +val notifEvent = server.notifyPaymentReceived(clientPubkey, transaction) +``` + +## Architecture + +``` +nip47WalletConnect/ +├── Nip47Client.kt # High-level client API (URI → requests → responses) +├── Nip47Server.kt # High-level server API (requests → responses → notifications) +├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://) +├── Request.kt # All 13 NWC request methods + params +├── Response.kt # All response types (success + error) +├── Notification.kt # Wallet notification types +├── NwcMethod.kt # Method name constants +├── NwcErrorCode.kt # Error codes enum + NwcError +├── NwcTransaction.kt # Transaction, state, budget, TLV models +├── NwcInfoEvent.kt # Kind 13194 — wallet capabilities +├── LnZapPaymentRequestEvent.kt # Kind 23194 — client → wallet request +├── LnZapPaymentResponseEvent.kt # Kind 23195 — wallet → client response +├── NwcNotificationEvent.kt # Kind 23197 — wallet → client notification +├── NostrWalletConnectRequestCache.kt # Request decryption cache +├── NostrWalletConnectResponseCache.kt # Response decryption cache +└── tags/ + ├── EncryptionTag.kt # "encryption" tag parsing + └── NotificationsTag.kt # "notifications" tag parsing +``` + +## Event Kinds + +| Kind | Class | Direction | Purpose | +|-------|------------------------------|-----------------|----------------------| +| 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities | +| 23194 | `LnZapPaymentRequestEvent` | Client → Wallet | NWC request | +| 23195 | `LnZapPaymentResponseEvent` | Wallet → Client | NWC response | +| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) | +| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) | + +## Supported Methods + +| Method | `Nip47Client` method | Request Class | Success Response Class | +|----------------------|-----------------------------|---------------------------|-----------------------------------| +| `pay_invoice` | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `pay_keysend` | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `make_invoice` | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | +| `list_transactions` | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_balance` | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `get_budget` | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | +| `make_hold_invoice` | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| + +Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. + +## Low-Level API + +The high-level `Nip47Client` and `Nip47Server` classes wrap the lower-level event +builders. You can use these directly if you need more control. + +### Wallet Client (Low-Level) + +#### 1. Parse the NWC Connection URI + +```kotlin +val uri = "nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c..." +val nwcConfig = Nip47WalletConnect.parse(uri) +``` + +Supported URI schemes: `nostr+walletconnect://`, `nostrwalletconnect://`, +`amethyst+walletconnect://` + +#### 2. Create the Client Signer + +```kotlin +val clientSigner = NostrSignerInternal( + KeyPair(nwcConfig.secret!!.hexToByteArray()) +) +``` + +#### 3. Build and Send Requests + +```kotlin +val balanceRequest = GetBalanceMethod.create() +val event = LnZapPaymentRequestEvent.createRequest( + request = balanceRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) +// Send `event` to `nwcConfig.relayUri` +``` + +To use NIP-44 encryption instead of NIP-04: + +```kotlin +val event = LnZapPaymentRequestEvent.createRequest( + request = GetInfoMethod.create(), + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, + useNip44 = true, +) +``` + +#### 4. Receive and Parse Responses + +Subscribe to kind `23195` events on the NWC relay, filtered by the wallet +service pubkey and the request event ID: + +```kotlin +val response: Response = responseEvent.decrypt(clientSigner) + +when (response) { + is GetBalanceSuccessResponse -> { + val balanceSats = (response.result?.balance ?: 0L) / 1000L + } + is PayInvoiceSuccessResponse -> { + val preimage = response.result?.preimage + } + is NwcErrorResponse -> { + val errorMessage = response.error?.message + } +} +``` + +#### 5. Listen for Notifications + +```kotlin +val notification: Notification = notificationEvent.decryptNotification(clientSigner) + +when (notification) { + is PaymentReceivedNotification -> { + val tx: NwcTransaction? = notification.notification + } + is PaymentSentNotification -> { + val tx: NwcTransaction? = notification.notification + } +} +``` + +### Wallet Service (Low-Level) + +#### 1. Publish Capabilities + +```kotlin +val infoTemplate = NwcInfoEvent.build( + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE), + encryptionSchemes = listOf("nip04", "nip44_v2"), + notificationTypes = listOf(NwcNotificationType.PAYMENT_RECEIVED), +) +// Sign with wallet signer: walletSigner.sign(infoTemplate) +``` + +#### 2. Parse Requests and Build Responses + +```kotlin +val request: Request = requestEvent.decryptRequest(walletSigner) + +// Build response +val balanceResponse = GetBalanceSuccessResponse( + GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) +) +val responseEvent = LnZapPaymentResponseEvent.createResponse( + response = balanceResponse, + requestEvent = requestEvent, + signer = walletSigner, +) + +// Error response +val errorResponse = NwcErrorResponse( + resultType = NwcMethod.PAY_INVOICE, + error = NwcError(NwcErrorCode.INSUFFICIENT_BALANCE, "Not enough funds"), +) +val errorEvent = LnZapPaymentResponseEvent.createResponse( + response = errorResponse, + requestEvent = requestEvent, + signer = walletSigner, +) +``` + +#### 3. Send Notifications + +```kotlin +val notifEvent = NwcNotificationEvent.createNotification( + notification = PaymentReceivedNotification( + notification = NwcTransaction( + type = NwcTransactionType.INCOMING, + state = NwcTransactionState.SETTLED, + invoice = "lnbc...", + amount = 50000L, + payment_hash = "abc123", + settled_at = TimeUtils.now(), + created_at = TimeUtils.now(), + ), + ), + clientPubkey = clientPubkeyHex, + signer = walletSigner, +) +``` + +## Transaction State Helpers + +Transaction states from different wallet implementations may use different +casing. Use the case-insensitive helpers: + +```kotlin +NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled" +NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending" +NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed" +NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted" +``` + +## URI Persistence + +```kotlin +// Save +val json = Nip47WalletConnect.Nip47URI.serializer(nwcConfig.denormalize()!!) + +// Restore +val restored = Nip47WalletConnect.Nip47URI.parser(json).normalize()!! +``` + +## Error Codes + +| Code | When to Use | +|-------------------------|-------------------------------------------| +| `RATE_LIMITED` | Too many requests | +| `NOT_IMPLEMENTED` | Method not supported by wallet | +| `INSUFFICIENT_BALANCE` | Not enough funds for payment | +| `PAYMENT_FAILED` | Payment could not be completed | +| `QUOTA_EXCEEDED` | Budget/spending limit exceeded | +| `RESTRICTED` | Method not allowed for this connection | +| `UNAUTHORIZED` | Invalid or expired credentials | +| `INTERNAL` | Internal wallet error | +| `UNSUPPORTED_ENCRYPTION`| Requested encryption not supported | +| `BAD_REQUEST` | Malformed request parameters | +| `NOT_FOUND` | Invoice or resource not found | +| `EXPIRED` | Connection or invoice expired | +| `OTHER` | Unspecified error | + +## Encryption + +NWC supports two encryption schemes: + +- **NIP-04** (default): `Nip47Client(useNip44 = false)` or `useNip44 = false` in event builders +- **NIP-44 v2**: `Nip47Client(useNip44 = true)` or `useNip44 = true` in event builders + +Clients can check a wallet's supported encryption via the info event: +```kotlin +val infoEvent: NwcInfoEvent = ... +val schemes: List = infoEvent.encryptionSchemes() +// e.g., ["nip04", "nip44_v2"] +``` + +## Caching + +For apps handling many concurrent NWC events, use the built-in LRU caches: + +```kotlin +val requestCache = NostrWalletConnectRequestCache(signer) +val responseCache = NostrWalletConnectResponseCache(signer) + +val request: Request? = requestCache.decryptRequest(requestEvent) +val response: Response? = responseCache.decryptResponse(responseEvent) +``` + +## Amounts + +All amounts in NWC are in **millisatoshis** (1 sat = 1000 msats). Convert for +display: + +```kotlin +val balanceMsats = response.result?.balance ?: 0L +val balanceSats = balanceMsats / 1000L +``` + +## Interoperability + +This implementation is tested against: +- **Alby Hub** (server) — uppercase transaction states, all error codes +- **Alby JS SDK** (client) — lowercase transaction states, budget renewal + periods, structured metadata + +See `AlbyInteropTest.kt` for real-world test vectors. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt deleted file mode 100644 index cb449136af..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ /dev/null @@ -1,75 +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.quartz.nip47WalletConnect - -import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable - -// RESPONSE OBJECTS -abstract class Response( - val resultType: String, -) : OptimizedSerializable - -// PayInvoice Call - -class PayInvoiceSuccessResponse( - val result: PayInvoiceResultParams? = null, -) : Response("pay_invoice") { - class PayInvoiceResultParams( - val preimage: String? = null, - ) -} - -class PayInvoiceErrorResponse( - val error: PayInvoiceErrorParams? = null, -) : Response("pay_invoice") { - class PayInvoiceErrorParams( - val code: ErrorType? = null, - val message: String? = null, - ) - - enum class ErrorType { - RATE_LIMITED, - - // The client is sending commands too fast. It should retry in a few seconds. - NOT_IMPLEMENTED, - - // The command is not known or is intentionally not implemented. - INSUFFICIENT_BALANCE, - - // The command is not known or is intentionally not implemented. - PAYMENT_FAILED, - - // The wallet does not have enough funds to cover a fee reserve or the payment amount. - QUOTA_EXCEEDED, - - // The wallet has exceeded its spending quota. - RESTRICTED, - - // This public key is not allowed to do this operation. - UNAUTHORIZED, - - // This public key has no wallet connected. - INTERNAL, - - // An internal error. - OTHER, // Other error. - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt index a1befc2da8..59a160871f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.cache import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request class NostrWalletConnectRequestCache( signer: NostrSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt index b883d3a737..c291c2c988 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.cache import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response class NostrWalletConnectResponseCache( signer: NostrSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt similarity index 67% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt index 2a766ef0b5..d98476399c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -52,25 +54,47 @@ class LnZapPaymentRequestEvent( return OptimizedJsonMapper.fromJsonTo(jsonText) } + fun encryptionScheme() = tags.firstOrNull { it.size > 1 && it[0] == "encryption" }?.get(1) + companion object { const val KIND = 23194 - const val ALT = "Zap payment request" + const val ALT = "NWC request" suspend fun create( lnInvoice: String, walletServicePubkey: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LnZapPaymentRequestEvent { - val serializedRequest = OptimizedJsonMapper.toJson(PayInvoiceMethod.create(lnInvoice)) + ): LnZapPaymentRequestEvent = + createRequest( + PayInvoiceMethod.create(lnInvoice), + walletServicePubkey, + signer, + createdAt, + ) - val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT)) + suspend fun createRequest( + request: Request, + walletServicePubkey: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + useNip44: Boolean = false, + ): LnZapPaymentRequestEvent { + val serializedRequest = OptimizedJsonMapper.toJson(request) + + val tags = + if (useNip44) { + arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT), arrayOf("encryption", "nip44_v2")) + } else { + arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT)) + } val encrypted = - signer.nip04Encrypt( - serializedRequest, - walletServicePubkey, - ) + if (useNip44) { + signer.nip44Encrypt(serializedRequest, walletServicePubkey) + } else { + signer.nip04Encrypt(serializedRequest, walletServicePubkey) + } return signer.sign(createdAt, KIND, tags, encrypted) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt similarity index 60% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt index 9a7de947cb..ebabe68a79 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,6 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LnZapPaymentResponseEvent( @@ -55,6 +58,43 @@ class LnZapPaymentResponseEvent( companion object { const val KIND = 23195 - const val ALT = "Zap payment response" + const val ALT = "NWC response" + + /** + * Creates an NWC response event (server-side). + * + * @param response the NWC response object to send + * @param requestEvent the original request event being responded to + * @param signer the wallet service signer + * @param useNip44 whether to use NIP-44 encryption (default: false for NIP-04) + * @param createdAt event timestamp + */ + suspend fun createResponse( + response: Response, + requestEvent: LnZapPaymentRequestEvent, + signer: NostrSigner, + useNip44: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): LnZapPaymentResponseEvent { + val serializedResponse = OptimizedJsonMapper.toJson(response) + + val clientPubkey = requestEvent.pubKey + + val tags = + arrayOf( + arrayOf("p", clientPubkey), + arrayOf("e", requestEvent.id), + AltTag.assemble(ALT), + ) + + val encrypted = + if (useNip44) { + signer.nip44Encrypt(serializedResponse, clientPubkey) + } else { + signer.nip04Encrypt(serializedResponse, clientPubkey) + } + + return signer.sign(createdAt, KIND, tags, encrypted) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt new file mode 100644 index 0000000000..3691d3dc10 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag +import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NwcInfoEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun capabilities(): List = content.split(" ").filter { it.isNotBlank() } + + fun supportsMethod(method: String): Boolean = capabilities().contains(method) + + fun supportsNotifications(): Boolean = capabilities().contains("notifications") + + fun encryptionSchemes() = tags.mapNotNull(EncryptionTag::parse).flatten() + + fun notificationTypes() = tags.mapNotNull(NotificationsTag::parse).flatten() + + companion object { + const val KIND = 13194 + const val ALT_DESCRIPTION = "Wallet service info" + + fun build( + capabilities: List, + encryptionSchemes: List? = null, + notificationTypes: List? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, capabilities.joinToString(" "), createdAt) { + alt(ALT_DESCRIPTION) + encryptionSchemes?.let { addUnique(EncryptionTag.assemble(it)) } + notificationTypes?.let { addUnique(NotificationsTag.assemble(it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcNotificationEvent.kt new file mode 100644 index 0000000000..825b4c8942 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcNotificationEvent.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NwcNotificationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun clientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + + fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) clientPubKey() ?: pubKey else pubKey + + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || clientPubKey() == signer.pubKey + + suspend fun decryptNotification(signer: NostrSigner): Notification { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + val jsonText = signer.decrypt(content, talkingWith(signer.pubKey)) + return OptimizedJsonMapper.fromJsonTo(jsonText) + } + + companion object { + const val KIND = 23197 + const val LEGACY_KIND = 23196 + const val ALT = "Wallet notification" + + /** + * Creates an NWC notification event (server-side). + * Uses NIP-44 encryption (kind 23197). + * + * @param notification the notification to send + * @param clientPubkey the client's public key to encrypt to + * @param signer the wallet service signer + * @param createdAt event timestamp + */ + suspend fun createNotification( + notification: Notification, + clientPubkey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): NwcNotificationEvent { + val serialized = OptimizedJsonMapper.toJson(notification) + + val tags = + arrayOf( + arrayOf("p", clientPubkey), + AltTag.assemble(ALT), + ) + + val encrypted = signer.nip44Encrypt(serialized, clientPubkey) + + return signer.sign(createdAt, KIND, tags, encrypted) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt new file mode 100644 index 0000000000..472972cb6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +// Helper function to convert JsonElement to standard Kotlin types recursively +fun JsonElement.toAnyValue(): Any = + when (this) { + is JsonPrimitive -> { + if (isString) { + content + } else { + content.toBooleanStrictOrNull() ?: content.toDoubleOrNull() ?: content.toLongOrNull() ?: content + } + } + + is JsonObject -> { + toAnyMap() + } + + is JsonArray -> { + map { it.toAnyValue() } + } + } + +fun JsonObject.toAnyMap(): Map = entries.associate { it.key to it.value.toAnyValue() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt new file mode 100644 index 0000000000..e3588f77d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedData +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +object Nip47NotificationKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Notification") + + override fun serialize( + encoder: Encoder, + value: Notification, + ) { + val jsonEncoder = encoder as JsonEncoder + val jsonObject = + buildJsonObject { + put("notification_type", value.notification_type) + when (value) { + is PaymentReceivedNotification -> { + Nip47ResponseKSerializer.serializeTransaction(value.notification)?.let { + put("notification", it) + } + } + + is PaymentSentNotification -> { + Nip47ResponseKSerializer.serializeTransaction(value.notification)?.let { + put("notification", it) + } + } + + is HoldInvoiceAcceptedNotification -> { + value.notification?.let { data -> + put( + "notification", + buildJsonObject { + data.type?.let { put("type", it) } + data.invoice?.let { put("invoice", it) } + data.payment_hash?.let { put("payment_hash", it) } + data.amount?.let { put("amount", it) } + data.created_at?.let { put("created_at", it) } + data.expires_at?.let { put("expires_at", it) } + data.settle_deadline?.let { put("settle_deadline", it) } + }, + ) + } + } + } + } + jsonEncoder.encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): Notification { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val notificationType = + jsonObject["notification_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + + return when (notificationType) { + NwcNotificationType.PAYMENT_RECEIVED -> { + PaymentReceivedNotification( + notification = + Nip47ResponseKSerializer.parseTransaction( + jsonObject["notification"]?.jsonObject, + ), + ) + } + + NwcNotificationType.PAYMENT_SENT -> { + PaymentSentNotification( + notification = + Nip47ResponseKSerializer.parseTransaction( + jsonObject["notification"]?.jsonObject, + ), + ) + } + + NwcNotificationType.HOLD_INVOICE_ACCEPTED -> { + val notifObj = jsonObject["notification"]?.jsonObject + HoldInvoiceAcceptedNotification( + notification = + notifObj?.let { + HoldInvoiceAcceptedData( + type = it["type"]?.jsonPrimitive?.content, + invoice = it["invoice"]?.jsonPrimitive?.content, + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + created_at = it["created_at"]?.jsonPrimitive?.longOrNull, + expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, + settle_deadline = it["settle_deadline"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + else -> { + throw IllegalArgumentException("Unknown notification type: $notificationType") + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt new file mode 100644 index 0000000000..b58dbd182f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonNull.content +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +object Nip47RequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Request") + + override fun serialize( + encoder: Encoder, + value: Request, + ) { + val jsonEncoder = encoder as JsonEncoder + val jsonObject = + buildJsonObject { + put("method", value.method) + when (value) { + is PayInvoiceMethod -> { + value.params?.let { put("params", serializePayInvoiceParams(it)) } + } + + is PayKeysendMethod -> { + value.params?.let { put("params", serializePayKeysendParams(it)) } + } + + is MakeInvoiceMethod -> { + value.params?.let { put("params", serializeMakeInvoiceParams(it)) } + } + + is LookupInvoiceMethod -> { + value.params?.let { put("params", serializeLookupInvoiceParams(it)) } + } + + is ListTransactionsMethod -> { + value.params?.let { put("params", serializeListTransactionsParams(it)) } + } + + is GetBalanceMethod -> {} + + is GetInfoMethod -> {} + + is GetBudgetMethod -> {} + + is SignMessageMethod -> { + value.params?.let { put("params", serializeSignMessageParams(it)) } + } + + is CreateConnectionMethod -> { + value.params?.let { put("params", serializeCreateConnectionParams(it)) } + } + + is MakeHoldInvoiceMethod -> { + value.params?.let { put("params", serializeMakeHoldInvoiceParams(it)) } + } + + is CancelHoldInvoiceMethod -> { + value.params?.let { put("params", serializeCancelHoldInvoiceParams(it)) } + } + + is SettleHoldInvoiceMethod -> { + value.params?.let { put("params", serializeSettleHoldInvoiceParams(it)) } + } + } + } + jsonEncoder.encodeJsonElement(jsonObject) + } + + private fun serializePayInvoiceParams(params: PayInvoiceParams): JsonObject = + buildJsonObject { + params.invoice?.let { put("invoice", it) } + params.amount?.let { put("amount", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.pubkey?.let { put("pubkey", it) } + params.preimage?.let { put("preimage", it) } + params.tlv_records?.let { records -> + put( + "tlv_records", + buildJsonArray { + records.forEach { record -> + add( + buildJsonObject { + record.type?.let { put("type", it) } + record.value?.let { put("value", it) } + }, + ) + } + }, + ) + } + } + + private fun serializeMakeInvoiceParams(params: MakeInvoiceParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.description?.let { put("description", it) } + params.description_hash?.let { put("description_hash", it) } + params.expiry?.let { put("expiry", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializeLookupInvoiceParams(params: LookupInvoiceParams): JsonObject = + buildJsonObject { + params.payment_hash?.let { put("payment_hash", it) } + params.invoice?.let { put("invoice", it) } + } + + private fun serializeListTransactionsParams(params: ListTransactionsParams): JsonObject = + buildJsonObject { + params.from?.let { put("from", it) } + params.until?.let { put("until", it) } + params.limit?.let { put("limit", it) } + params.offset?.let { put("offset", it) } + params.unpaid?.let { put("unpaid", it) } + params.unpaid_outgoing?.let { put("unpaid_outgoing", it) } + params.unpaid_incoming?.let { put("unpaid_incoming", it) } + params.type?.let { put("type", it) } + } + + private fun serializeSignMessageParams(params: SignMessageParams): JsonObject = + buildJsonObject { + params.message?.let { put("message", it) } + } + + private fun serializeCreateConnectionParams(params: CreateConnectionParams): JsonObject = + buildJsonObject { + params.pubkey?.let { put("pubkey", it) } + params.name?.let { put("name", it) } + params.request_methods?.let { methods -> + put("request_methods", buildJsonArray { methods.forEach { add(it) } }) + } + params.notification_types?.let { types -> + put("notification_types", buildJsonArray { types.forEach { add(it) } }) + } + params.max_amount?.let { put("max_amount", it) } + params.budget_renewal?.let { put("budget_renewal", it) } + params.expires_at?.let { put("expires_at", it) } + params.isolated?.let { put("isolated", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializeMakeHoldInvoiceParams(params: MakeHoldInvoiceParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.description?.let { put("description", it) } + params.description_hash?.let { put("description_hash", it) } + params.expiry?.let { put("expiry", it) } + params.payment_hash?.let { put("payment_hash", it) } + params.min_cltv_expiry_delta?.let { put("min_cltv_expiry_delta", it) } + } + + private fun serializeCancelHoldInvoiceParams(params: CancelHoldInvoiceParams): JsonObject = + buildJsonObject { + params.payment_hash?.let { put("payment_hash", it) } + } + + private fun serializeSettleHoldInvoiceParams(params: SettleHoldInvoiceParams): JsonObject = + buildJsonObject { + params.preimage?.let { put("preimage", it) } + } + + override fun deserialize(decoder: Decoder): Request { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val method = jsonObject["method"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + + return when (method) { + NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject) + NwcMethod.PAY_KEYSEND -> parsePayKeysend(jsonObject) + NwcMethod.MAKE_INVOICE -> parseMakeInvoice(jsonObject) + NwcMethod.LOOKUP_INVOICE -> parseLookupInvoice(jsonObject) + NwcMethod.LIST_TRANSACTIONS -> parseListTransactions(jsonObject) + NwcMethod.GET_BALANCE -> GetBalanceMethod() + NwcMethod.GET_INFO -> GetInfoMethod() + NwcMethod.GET_BUDGET -> GetBudgetMethod() + NwcMethod.SIGN_MESSAGE -> parseSignMessage(jsonObject) + NwcMethod.CREATE_CONNECTION -> parseCreateConnection(jsonObject) + NwcMethod.MAKE_HOLD_INVOICE -> parseMakeHoldInvoice(jsonObject) + NwcMethod.CANCEL_HOLD_INVOICE -> parseCancelHoldInvoice(jsonObject) + NwcMethod.SETTLE_HOLD_INVOICE -> parseSettleHoldInvoice(jsonObject) + else -> throw IllegalArgumentException("Unknown NWC method: $method") + } + } + + private fun parsePayInvoice(json: JsonObject): PayInvoiceMethod { + val params = json["params"]?.jsonObject + return PayInvoiceMethod( + params?.let { + PayInvoiceParams( + invoice = it["invoice"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + ) + }, + ) + } + + private fun parsePayKeysend(json: JsonObject): PayKeysendMethod { + val params = json["params"]?.jsonObject + return PayKeysendMethod( + params?.let { + PayKeysendParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + pubkey = it["pubkey"]?.jsonPrimitive?.content, + preimage = it["preimage"]?.jsonPrimitive?.content, + tlv_records = + it["tlv_records"]?.jsonArray?.map { record -> + val obj = record.jsonObject + TlvRecord( + type = obj["type"]?.jsonPrimitive?.longOrNull, + value = obj["value"]?.jsonPrimitive?.content, + ) + }, + ) + }, + ) + } + + private fun parseMakeInvoice(json: JsonObject): MakeInvoiceMethod { + val params = json["params"]?.jsonObject + return MakeInvoiceMethod( + params?.let { + MakeInvoiceParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + description = it["description"]?.jsonPrimitive?.content, + description_hash = it["description_hash"]?.jsonPrimitive?.content, + expiry = it["expiry"]?.jsonPrimitive?.longOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + ) + }, + ) + } + + private fun parseLookupInvoice(json: JsonObject): LookupInvoiceMethod { + val params = json["params"]?.jsonObject + return LookupInvoiceMethod( + params?.let { + LookupInvoiceParams( + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + invoice = it["invoice"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseListTransactions(json: JsonObject): ListTransactionsMethod { + val params = json["params"]?.jsonObject + return ListTransactionsMethod( + params?.let { + ListTransactionsParams( + from = it["from"]?.jsonPrimitive?.longOrNull, + until = it["until"]?.jsonPrimitive?.longOrNull, + limit = it["limit"]?.jsonPrimitive?.intOrNull, + offset = it["offset"]?.jsonPrimitive?.intOrNull, + unpaid = it["unpaid"]?.jsonPrimitive?.booleanOrNull, + unpaid_outgoing = it["unpaid_outgoing"]?.jsonPrimitive?.booleanOrNull, + unpaid_incoming = it["unpaid_incoming"]?.jsonPrimitive?.booleanOrNull, + type = it["type"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseSignMessage(json: JsonObject): SignMessageMethod { + val params = json["params"]?.jsonObject + return SignMessageMethod( + params?.let { + SignMessageParams( + message = it["message"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseCreateConnection(json: JsonObject): CreateConnectionMethod { + val params = json["params"]?.jsonObject + return CreateConnectionMethod( + params?.let { + CreateConnectionParams( + pubkey = it["pubkey"]?.jsonPrimitive?.content, + name = it["name"]?.jsonPrimitive?.content, + request_methods = it["request_methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content }, + notification_types = it["notification_types"]?.jsonArray?.map { n -> n.jsonPrimitive.content }, + max_amount = it["max_amount"]?.jsonPrimitive?.longOrNull, + budget_renewal = it["budget_renewal"]?.jsonPrimitive?.content, + expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, + isolated = it["isolated"]?.jsonPrimitive?.booleanOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + ) + }, + ) + } + + private fun parseMakeHoldInvoice(json: JsonObject): MakeHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return MakeHoldInvoiceMethod( + params?.let { + MakeHoldInvoiceParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + description = it["description"]?.jsonPrimitive?.content, + description_hash = it["description_hash"]?.jsonPrimitive?.content, + expiry = it["expiry"]?.jsonPrimitive?.longOrNull, + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + min_cltv_expiry_delta = it["min_cltv_expiry_delta"]?.jsonPrimitive?.intOrNull, + ) + }, + ) + } + + private fun parseCancelHoldInvoice(json: JsonObject): CancelHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return CancelHoldInvoiceMethod( + params?.let { + CancelHoldInvoiceParams( + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseSettleHoldInvoice(json: JsonObject): SettleHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return SettleHoldInvoiceMethod( + params?.let { + SettleHoldInvoiceParams( + preimage = it["preimage"]?.jsonPrimitive?.content, + ) + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt new file mode 100644 index 0000000000..30922c5439 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -0,0 +1,481 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +object Nip47ResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Response") + + override fun serialize( + encoder: Encoder, + value: Response, + ) { + val jsonEncoder = encoder as JsonEncoder + val jsonObject = + buildJsonObject { + put("result_type", value.resultType) + when (value) { + is NwcErrorResponse -> { + value.error?.let { put("error", serializeNwcError(it)) } + } + + is PayInvoiceSuccessResponse -> { + value.result?.let { put("result", serializePayInvoiceResult(it)) } + } + + is PayInvoiceErrorResponse -> { + value.error?.let { put("error", serializePayInvoiceErrorParams(it)) } + } + + is PayKeysendSuccessResponse -> { + value.result?.let { put("result", serializePayKeysendResult(it)) } + } + + is MakeInvoiceSuccessResponse -> { + serializeTransaction(value.result)?.let { put("result", it) } + } + + is LookupInvoiceSuccessResponse -> { + serializeTransaction(value.result)?.let { put("result", it) } + } + + is ListTransactionsSuccessResponse -> { + value.result?.let { put("result", serializeListTransactionsResult(it)) } + } + + is GetBalanceSuccessResponse -> { + value.result?.let { put("result", serializeGetBalanceResult(it)) } + } + + is GetInfoSuccessResponse -> { + value.result?.let { put("result", serializeGetInfoResult(it)) } + } + + is GetBudgetSuccessResponse -> { + value.result?.let { put("result", serializeGetBudgetResult(it)) } + } + + is SignMessageSuccessResponse -> { + value.result?.let { put("result", serializeSignMessageResult(it)) } + } + + is CreateConnectionSuccessResponse -> { + value.result?.let { put("result", serializeCreateConnectionResult(it)) } + } + + is MakeHoldInvoiceSuccessResponse -> { + serializeTransaction(value.result)?.let { put("result", it) } + } + + is CancelHoldInvoiceSuccessResponse -> { + put("result", buildJsonObject {}) + } + + is SettleHoldInvoiceSuccessResponse -> { + put("result", buildJsonObject {}) + } + } + } + jsonEncoder.encodeJsonElement(jsonObject) + } + + private fun serializeNwcError(error: NwcError): JsonObject = + buildJsonObject { + error.code?.let { put("code", it.name) } + error.message?.let { put("message", it) } + } + + private fun serializePayInvoiceResult(result: PayInvoiceSuccessResponse.PayInvoiceResultParams): JsonObject = + buildJsonObject { + result.preimage?.let { put("preimage", it) } + result.fees_paid?.let { put("fees_paid", it) } + } + + private fun serializePayInvoiceErrorParams(error: PayInvoiceErrorResponse.PayInvoiceErrorParams): JsonObject = + buildJsonObject { + error.code?.let { put("code", it.name) } + error.message?.let { put("message", it) } + } + + private fun serializePayKeysendResult(result: PayKeysendSuccessResponse.PayKeysendResult): JsonObject = + buildJsonObject { + result.preimage?.let { put("preimage", it) } + result.fees_paid?.let { put("fees_paid", it) } + } + + private fun serializeListTransactionsResult(result: ListTransactionsSuccessResponse.ListTransactionsResult): JsonObject = + buildJsonObject { + result.transactions?.let { transactions -> + put( + "transactions", + buildJsonArray { + transactions.forEach { serializeTransaction(it)?.let { t -> add(t) } } + }, + ) + } + result.total_count?.let { put("total_count", it) } + } + + private fun serializeGetBalanceResult(result: GetBalanceSuccessResponse.GetBalanceResult): JsonObject = + buildJsonObject { + result.balance?.let { put("balance", it) } + } + + private fun serializeGetInfoResult(result: GetInfoSuccessResponse.GetInfoResult): JsonObject = + buildJsonObject { + result.alias?.let { put("alias", it) } + result.color?.let { put("color", it) } + result.pubkey?.let { put("pubkey", it) } + result.network?.let { put("network", it) } + result.block_height?.let { put("block_height", it) } + result.block_hash?.let { put("block_hash", it) } + result.methods?.let { methods -> + put("methods", buildJsonArray { methods.forEach { add(it) } }) + } + result.notifications?.let { notifications -> + put("notifications", buildJsonArray { notifications.forEach { add(it) } }) + } + result.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + result.lud16?.let { put("lud16", it) } + } + + private fun serializeGetBudgetResult(result: GetBudgetSuccessResponse.GetBudgetResult): JsonObject = + buildJsonObject { + result.used_budget?.let { put("used_budget", it) } + result.total_budget?.let { put("total_budget", it) } + result.renews_at?.let { put("renews_at", it) } + result.renewal_period?.let { put("renewal_period", it) } + } + + private fun serializeSignMessageResult(result: SignMessageSuccessResponse.SignMessageResult): JsonObject = + buildJsonObject { + result.message?.let { put("message", it) } + result.signature?.let { put("signature", it) } + } + + private fun serializeCreateConnectionResult(result: CreateConnectionSuccessResponse.CreateConnectionResult): JsonObject = + buildJsonObject { + result.wallet_pubkey?.let { put("wallet_pubkey", it) } + } + + override fun deserialize(decoder: Decoder): Response { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val resultType = jsonObject["result_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val hasError = jsonObject["error"]?.let { it !is JsonNull } ?: false + val hasResult = jsonObject["result"]?.let { it !is JsonNull } ?: false + + if (hasError) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> { + parsePayInvoiceError(jsonObject) + } + + else -> { + val error = jsonObject["error"]?.jsonObject?.let { parseNwcError(it) } + NwcErrorResponse(resultType ?: "", error) + } + } + } + + if (hasResult || resultType != null) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> { + parsePayInvoiceSuccess(jsonObject) + } + + NwcMethod.PAY_KEYSEND -> { + parsePayKeysendSuccess(jsonObject) + } + + NwcMethod.MAKE_INVOICE -> { + MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.LOOKUP_INVOICE -> { + LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.LIST_TRANSACTIONS -> { + parseListTransactionsSuccess(jsonObject) + } + + NwcMethod.GET_BALANCE -> { + parseGetBalanceSuccess(jsonObject) + } + + NwcMethod.GET_INFO -> { + parseGetInfoSuccess(jsonObject) + } + + NwcMethod.GET_BUDGET -> { + parseGetBudgetSuccess(jsonObject) + } + + NwcMethod.SIGN_MESSAGE -> { + parseSignMessageSuccess(jsonObject) + } + + NwcMethod.CREATE_CONNECTION -> { + parseCreateConnectionSuccess(jsonObject) + } + + NwcMethod.MAKE_HOLD_INVOICE -> { + MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.CANCEL_HOLD_INVOICE -> { + CancelHoldInvoiceSuccessResponse() + } + + NwcMethod.SETTLE_HOLD_INVOICE -> { + SettleHoldInvoiceSuccessResponse() + } + + else -> { + // backward compatibility: guess by result content + val resultObj = jsonObject["result"]?.jsonObject + if (resultObj?.containsKey("preimage") == true) { + return parsePayInvoiceSuccess(jsonObject) + } + throw IllegalArgumentException("Unknown NWC response type: $resultType") + } + } + } + + throw IllegalArgumentException("NWC response has neither result nor error") + } + + private fun parseNwcError(obj: JsonObject): NwcError { + val code = + obj["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + } + return NwcError(code, obj["message"]?.jsonPrimitive?.content) + } + + fun serializeTransaction(transaction: NwcTransaction?): JsonObject? { + if (transaction == null) return null + return buildJsonObject { + transaction.type?.let { put("type", it) } + transaction.state?.let { put("state", it) } + transaction.invoice?.let { put("invoice", it) } + transaction.description?.let { put("description", it) } + transaction.description_hash?.let { put("description_hash", it) } + transaction.preimage?.let { put("preimage", it) } + transaction.payment_hash?.let { put("payment_hash", it) } + transaction.amount?.let { put("amount", it) } + transaction.fees_paid?.let { put("fees_paid", it) } + transaction.created_at?.let { put("created_at", it) } + transaction.expires_at?.let { put("expires_at", it) } + transaction.settled_at?.let { put("settled_at", it) } + transaction.settle_deadline?.let { put("settle_deadline", it) } + transaction.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + } + + fun parseTransaction(obj: JsonObject?): NwcTransaction? { + if (obj == null) return null + return NwcTransaction( + type = obj["type"]?.jsonPrimitive?.content, + state = obj["state"]?.jsonPrimitive?.content, + invoice = obj["invoice"]?.jsonPrimitive?.content, + description = obj["description"]?.jsonPrimitive?.content, + description_hash = obj["description_hash"]?.jsonPrimitive?.content, + preimage = obj["preimage"]?.jsonPrimitive?.content, + payment_hash = obj["payment_hash"]?.jsonPrimitive?.content, + amount = obj["amount"]?.jsonPrimitive?.longOrNull, + fees_paid = obj["fees_paid"]?.jsonPrimitive?.longOrNull, + created_at = obj["created_at"]?.jsonPrimitive?.longOrNull, + expires_at = obj["expires_at"]?.jsonPrimitive?.longOrNull, + settled_at = obj["settled_at"]?.jsonPrimitive?.longOrNull, + settle_deadline = obj["settle_deadline"]?.jsonPrimitive?.longOrNull, + metadata = obj["metadata"]?.jsonObject?.toAnyMap(), + ) + } + + private fun parsePayInvoiceSuccess(json: JsonObject): PayInvoiceSuccessResponse { + val result = json["result"]?.jsonObject + return PayInvoiceSuccessResponse( + result?.let { + PayInvoiceSuccessResponse.PayInvoiceResultParams( + preimage = it["preimage"]?.jsonPrimitive?.content, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parsePayInvoiceError(json: JsonObject): PayInvoiceErrorResponse { + val error = json["error"]?.jsonObject + return PayInvoiceErrorResponse( + error?.let { + PayInvoiceErrorResponse.PayInvoiceErrorParams( + code = + it["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + }, + message = it["message"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse { + val result = json["result"]?.jsonObject + return PayKeysendSuccessResponse( + result?.let { + PayKeysendSuccessResponse.PayKeysendResult( + preimage = it["preimage"]?.jsonPrimitive?.content, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseListTransactionsSuccess(json: JsonObject): ListTransactionsSuccessResponse { + val result = json["result"]?.jsonObject + return ListTransactionsSuccessResponse( + result?.let { + ListTransactionsSuccessResponse.ListTransactionsResult( + transactions = it["transactions"]?.jsonArray?.mapNotNull { t -> parseTransaction(t.jsonObject) }, + total_count = it["total_count"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseGetBalanceSuccess(json: JsonObject): GetBalanceSuccessResponse { + val result = json["result"]?.jsonObject + return GetBalanceSuccessResponse( + result?.let { + GetBalanceSuccessResponse.GetBalanceResult( + balance = it["balance"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseGetInfoSuccess(json: JsonObject): GetInfoSuccessResponse { + val result = json["result"]?.jsonObject + return GetInfoSuccessResponse( + result?.let { + GetInfoSuccessResponse.GetInfoResult( + alias = it["alias"]?.jsonPrimitive?.content, + color = it["color"]?.jsonPrimitive?.content, + pubkey = it["pubkey"]?.jsonPrimitive?.content, + network = it["network"]?.jsonPrimitive?.content, + block_height = it["block_height"]?.jsonPrimitive?.longOrNull, + block_hash = it["block_hash"]?.jsonPrimitive?.content, + methods = it["methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content }, + notifications = it["notifications"]?.jsonArray?.map { n -> n.jsonPrimitive.content }, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + lud16 = it["lud16"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseGetBudgetSuccess(json: JsonObject): GetBudgetSuccessResponse { + val result = json["result"]?.jsonObject + return GetBudgetSuccessResponse( + result?.let { + GetBudgetSuccessResponse.GetBudgetResult( + used_budget = it["used_budget"]?.jsonPrimitive?.longOrNull, + total_budget = it["total_budget"]?.jsonPrimitive?.longOrNull, + renews_at = it["renews_at"]?.jsonPrimitive?.longOrNull, + renewal_period = it["renewal_period"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseSignMessageSuccess(json: JsonObject): SignMessageSuccessResponse { + val result = json["result"]?.jsonObject + return SignMessageSuccessResponse( + result?.let { + SignMessageSuccessResponse.SignMessageResult( + message = it["message"]?.jsonPrimitive?.content, + signature = it["signature"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseCreateConnectionSuccess(json: JsonObject): CreateConnectionSuccessResponse { + val result = json["result"]?.jsonObject + return CreateConnectionSuccessResponse( + result?.let { + CreateConnectionSuccessResponse.CreateConnectionResult( + wallet_pubkey = it["wallet_pubkey"]?.jsonPrimitive?.content, + ) + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Notification.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Notification.kt new file mode 100644 index 0000000000..6622c219ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Notification.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.rpc + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +object NwcNotificationType { + const val PAYMENT_RECEIVED = "payment_received" + const val PAYMENT_SENT = "payment_sent" + const val HOLD_INVOICE_ACCEPTED = "hold_invoice_accepted" +} + +// NOTIFICATION OBJECTS +abstract class Notification( + val notification_type: String, +) : OptimizedSerializable + +// payment_received notification +class PaymentReceivedNotification( + val notification: NwcTransaction? = null, +) : Notification(NwcNotificationType.PAYMENT_RECEIVED) + +// payment_sent notification +class PaymentSentNotification( + val notification: NwcTransaction? = null, +) : Notification(NwcNotificationType.PAYMENT_SENT) + +// hold_invoice_accepted notification +class HoldInvoiceAcceptedNotification( + val notification: HoldInvoiceAcceptedData? = null, +) : Notification(NwcNotificationType.HOLD_INVOICE_ACCEPTED) + +class HoldInvoiceAcceptedData( + var type: String? = null, + var invoice: String? = null, + var payment_hash: String? = null, + var amount: Long? = null, + var created_at: Long? = null, + var expires_at: Long? = null, + var settle_deadline: Long? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt new file mode 100644 index 0000000000..0a27468342 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.rpc + +enum class NwcErrorCode { + RATE_LIMITED, + NOT_IMPLEMENTED, + INSUFFICIENT_BALANCE, + PAYMENT_FAILED, + QUOTA_EXCEEDED, + RESTRICTED, + UNAUTHORIZED, + INTERNAL, + UNSUPPORTED_ENCRYPTION, + BAD_REQUEST, + NOT_FOUND, + EXPIRED, + OTHER, +} + +class NwcError( + var code: NwcErrorCode? = null, + var message: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt new file mode 100644 index 0000000000..94d3470f14 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.rpc + +object NwcMethod { + const val PAY_INVOICE = "pay_invoice" + const val PAY_KEYSEND = "pay_keysend" + const val MAKE_INVOICE = "make_invoice" + const val LOOKUP_INVOICE = "lookup_invoice" + const val LIST_TRANSACTIONS = "list_transactions" + const val GET_BALANCE = "get_balance" + const val GET_INFO = "get_info" + const val GET_BUDGET = "get_budget" + const val SIGN_MESSAGE = "sign_message" + const val CREATE_CONNECTION = "create_connection" + const val MAKE_HOLD_INVOICE = "make_hold_invoice" + const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" + const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransaction.kt new file mode 100644 index 0000000000..6d3a5888a6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransaction.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.rpc + +object NwcTransactionType { + const val INCOMING = "incoming" + const val OUTGOING = "outgoing" +} + +object NwcTransactionState { + const val PENDING = "PENDING" + const val SETTLED = "SETTLED" + const val FAILED = "FAILED" + const val ACCEPTED = "ACCEPTED" + + fun isSettled(state: String?) = state.equals(SETTLED, ignoreCase = true) + + fun isPending(state: String?) = state.equals(PENDING, ignoreCase = true) + + fun isFailed(state: String?) = state.equals(FAILED, ignoreCase = true) + + fun isAccepted(state: String?) = state.equals(ACCEPTED, ignoreCase = true) +} + +object NwcBudgetRenewal { + const val DAILY = "daily" + const val WEEKLY = "weekly" + const val MONTHLY = "monthly" + const val YEARLY = "yearly" + const val NEVER = "never" +} + +class NwcTransaction( + var type: String? = null, + var state: String? = null, + var invoice: String? = null, + var description: String? = null, + var description_hash: String? = null, + var preimage: String? = null, + var payment_hash: String? = null, + var amount: Long? = null, + var fees_paid: Long? = null, + var created_at: Long? = null, + var expires_at: Long? = null, + var settled_at: Long? = null, + var settle_deadline: Long? = null, + var metadata: Map? = null, +) { + fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata) +} + +class TlvRecord( + var type: Long? = null, + var value: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt new file mode 100644 index 0000000000..88de9de01d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.rpc + +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +class NwcTransactionMetadata( + val comment: String?, + val payerData: PayerData?, + val recipientData: RecipientData?, + val nostr: NostrZapData?, +) { + class PayerData( + val name: String?, + val email: String?, + val pubkey: String?, + ) + + class RecipientData( + val identifier: String?, + ) + + class NostrZapData( + val pubkeyHex: String?, + val recipientPubkeyHex: String?, + ) + + fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) } + + fun senderDisplayName(): String? = payerData?.name ?: payerData?.email + + fun recipientIdentifier(): String? = recipientData?.identifier + + fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex + + companion object { + fun parse(metadata: Any?): NwcTransactionMetadata? { + val map = metadata as? Map<*, *> ?: return null + + val comment = map["comment"] as? String + + val payerData = + (map["payer_data"] as? Map<*, *>)?.let { pd -> + PayerData( + name = pd["name"] as? String, + email = pd["email"] as? String, + pubkey = pd["pubkey"] as? String, + ) + } + + val recipientData = + (map["recipient_data"] as? Map<*, *>)?.let { rd -> + RecipientData( + identifier = rd["identifier"] as? String, + ) + } + + val nostr = + (map["nostr"] as? Map<*, *>)?.let { n -> + val rawPubkey = n["pubkey"] as? String + val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) } + + val tags = n["tags"] as? List<*> + val recipientHex = + tags?.firstNotNullOfOrNull { tag -> + val tagList = tag as? List<*> + if (tagList != null && tagList.size >= 2 && tagList[0] == "p") { + tagList[1] as? String + } else { + null + } + } + + NostrZapData( + pubkeyHex = pubkeyHex, + recipientPubkeyHex = recipientHex, + ) + } + + if (comment == null && payerData == null && recipientData == null && nostr == null) { + return null + } + + return NwcTransactionMetadata( + comment = comment, + payerData = payerData, + recipientData = recipientData, + nostr = nostr, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt new file mode 100644 index 0000000000..a0b3867d69 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.rpc + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +// REQUEST OBJECTS +abstract class Request( + var method: String? = null, +) : OptimizedSerializable + +// pay_invoice +class PayInvoiceParams( + var invoice: String? = null, + var amount: Long? = null, + var metadata: Map? = null, +) + +class PayInvoiceMethod( + var params: PayInvoiceParams? = null, +) : Request(NwcMethod.PAY_INVOICE) { + companion object { + fun create(bolt11: String): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11)) + + fun create( + bolt11: String, + amount: Long, + ): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11, amount)) + } +} + +// pay_keysend +class PayKeysendParams( + var amount: Long? = null, + var pubkey: String? = null, + var preimage: String? = null, + var tlv_records: List? = null, +) + +class PayKeysendMethod( + var params: PayKeysendParams? = null, +) : Request(NwcMethod.PAY_KEYSEND) { + companion object { + fun create( + amount: Long, + pubkey: String, + preimage: String? = null, + tlvRecords: List? = null, + ): PayKeysendMethod = PayKeysendMethod(PayKeysendParams(amount, pubkey, preimage, tlvRecords)) + } +} + +// make_invoice +class MakeInvoiceParams( + var amount: Long? = null, + var description: String? = null, + var description_hash: String? = null, + var expiry: Long? = null, + var metadata: Map? = null, +) + +class MakeInvoiceMethod( + var params: MakeInvoiceParams? = null, +) : Request(NwcMethod.MAKE_INVOICE) { + companion object { + fun create( + amount: Long, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + ): MakeInvoiceMethod = MakeInvoiceMethod(MakeInvoiceParams(amount, description, descriptionHash, expiry)) + } +} + +// lookup_invoice +class LookupInvoiceParams( + var payment_hash: String? = null, + var invoice: String? = null, +) + +class LookupInvoiceMethod( + var params: LookupInvoiceParams? = null, +) : Request(NwcMethod.LOOKUP_INVOICE) { + companion object { + fun createByHash(paymentHash: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(payment_hash = paymentHash)) + + fun createByInvoice(invoice: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(invoice = invoice)) + } +} + +// list_transactions +class ListTransactionsParams( + var from: Long? = null, + var until: Long? = null, + var limit: Int? = null, + var offset: Int? = null, + var unpaid: Boolean? = null, + var unpaid_outgoing: Boolean? = null, + var unpaid_incoming: Boolean? = null, + var type: String? = null, +) + +class ListTransactionsMethod( + var params: ListTransactionsParams? = null, +) : Request(NwcMethod.LIST_TRANSACTIONS) { + companion object { + fun create( + from: Long? = null, + until: Long? = null, + limit: Int? = null, + offset: Int? = null, + unpaid: Boolean? = null, + type: String? = null, + unpaid_outgoing: Boolean? = null, + unpaid_incoming: Boolean? = null, + ): ListTransactionsMethod = + ListTransactionsMethod( + ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type), + ) + } +} + +// get_balance +class GetBalanceMethod : Request(NwcMethod.GET_BALANCE) { + companion object { + fun create(): GetBalanceMethod = GetBalanceMethod() + } +} + +// get_info +class GetInfoMethod : Request(NwcMethod.GET_INFO) { + companion object { + fun create(): GetInfoMethod = GetInfoMethod() + } +} + +// make_hold_invoice +class MakeHoldInvoiceParams( + var amount: Long? = null, + var description: String? = null, + var description_hash: String? = null, + var expiry: Long? = null, + var payment_hash: String? = null, + var min_cltv_expiry_delta: Int? = null, +) + +class MakeHoldInvoiceMethod( + var params: MakeHoldInvoiceParams? = null, +) : Request(NwcMethod.MAKE_HOLD_INVOICE) { + companion object { + fun create( + amount: Long, + paymentHash: String, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + minCltvExpiryDelta: Int? = null, + ): MakeHoldInvoiceMethod = + MakeHoldInvoiceMethod( + MakeHoldInvoiceParams(amount, description, descriptionHash, expiry, paymentHash, minCltvExpiryDelta), + ) + } +} + +// cancel_hold_invoice +class CancelHoldInvoiceParams( + var payment_hash: String? = null, +) + +class CancelHoldInvoiceMethod( + var params: CancelHoldInvoiceParams? = null, +) : Request(NwcMethod.CANCEL_HOLD_INVOICE) { + companion object { + fun create(paymentHash: String): CancelHoldInvoiceMethod = CancelHoldInvoiceMethod(CancelHoldInvoiceParams(paymentHash)) + } +} + +// settle_hold_invoice +class SettleHoldInvoiceParams( + var preimage: String? = null, +) + +class SettleHoldInvoiceMethod( + var params: SettleHoldInvoiceParams? = null, +) : Request(NwcMethod.SETTLE_HOLD_INVOICE) { + companion object { + fun create(preimage: String): SettleHoldInvoiceMethod = SettleHoldInvoiceMethod(SettleHoldInvoiceParams(preimage)) + } +} + +// get_budget +class GetBudgetMethod : Request(NwcMethod.GET_BUDGET) { + companion object { + fun create(): GetBudgetMethod = GetBudgetMethod() + } +} + +// sign_message +class SignMessageParams( + var message: String? = null, +) + +class SignMessageMethod( + var params: SignMessageParams? = null, +) : Request(NwcMethod.SIGN_MESSAGE) { + companion object { + fun create(message: String): SignMessageMethod = SignMessageMethod(SignMessageParams(message)) + } +} + +// create_connection +class CreateConnectionParams( + var pubkey: String? = null, + var name: String? = null, + var request_methods: List? = null, + var notification_types: List? = null, + var max_amount: Long? = null, + var budget_renewal: String? = null, + var expires_at: Long? = null, + var isolated: Boolean? = null, + var metadata: Map? = null, +) + +class CreateConnectionMethod( + var params: CreateConnectionParams? = null, +) : Request(NwcMethod.CREATE_CONNECTION) { + companion object { + fun create( + pubkey: String, + name: String, + requestMethods: List? = null, + notificationTypes: List? = null, + maxAmount: Long? = null, + budgetRenewal: String? = null, + expiresAt: Long? = null, + isolated: Boolean? = null, + metadata: Map? = null, + ): CreateConnectionMethod = + CreateConnectionMethod( + CreateConnectionParams(pubkey, name, requestMethods, notificationTypes, maxAmount, budgetRenewal, expiresAt, isolated, metadata), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt new file mode 100644 index 0000000000..7f8669fda2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.rpc + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +// RESPONSE OBJECTS +abstract class Response( + val resultType: String, +) : OptimizedSerializable + +// Generic error response for any method +class NwcErrorResponse( + resultType: String, + val error: NwcError? = null, +) : Response(resultType) + +// pay_invoice success response +class PayInvoiceSuccessResponse( + val result: PayInvoiceResultParams? = null, +) : Response(NwcMethod.PAY_INVOICE) { + class PayInvoiceResultParams( + val preimage: String? = null, + val fees_paid: Long? = null, + ) +} + +// pay_invoice error response (kept for backward compatibility) +class PayInvoiceErrorResponse( + val error: PayInvoiceErrorParams? = null, +) : Response(NwcMethod.PAY_INVOICE) { + class PayInvoiceErrorParams( + val code: NwcErrorCode? = null, + val message: String? = null, + ) +} + +// pay_keysend success response +class PayKeysendSuccessResponse( + val result: PayKeysendResult? = null, +) : Response(NwcMethod.PAY_KEYSEND) { + class PayKeysendResult( + val preimage: String? = null, + val fees_paid: Long? = null, + ) +} + +// make_invoice success response +class MakeInvoiceSuccessResponse( + val result: NwcTransaction? = null, +) : Response(NwcMethod.MAKE_INVOICE) + +// lookup_invoice success response +class LookupInvoiceSuccessResponse( + val result: NwcTransaction? = null, +) : Response(NwcMethod.LOOKUP_INVOICE) + +// list_transactions success response +class ListTransactionsSuccessResponse( + val result: ListTransactionsResult? = null, +) : Response(NwcMethod.LIST_TRANSACTIONS) { + class ListTransactionsResult( + val transactions: List? = null, + val total_count: Long? = null, + ) +} + +// get_balance success response +class GetBalanceSuccessResponse( + val result: GetBalanceResult? = null, +) : Response(NwcMethod.GET_BALANCE) { + class GetBalanceResult( + val balance: Long? = null, + ) +} + +// get_info success response +class GetInfoSuccessResponse( + val result: GetInfoResult? = null, +) : Response(NwcMethod.GET_INFO) { + class GetInfoResult( + val alias: String? = null, + val color: String? = null, + val pubkey: String? = null, + val network: String? = null, + val block_height: Long? = null, + val block_hash: String? = null, + val methods: List? = null, + val notifications: List? = null, + val metadata: Map? = null, + val lud16: String? = null, + ) +} + +// make_hold_invoice success response +class MakeHoldInvoiceSuccessResponse( + val result: NwcTransaction? = null, +) : Response(NwcMethod.MAKE_HOLD_INVOICE) + +// cancel_hold_invoice success response +class CancelHoldInvoiceSuccessResponse( + val result: Any? = null, +) : Response(NwcMethod.CANCEL_HOLD_INVOICE) + +// settle_hold_invoice success response +class SettleHoldInvoiceSuccessResponse( + val result: Any? = null, +) : Response(NwcMethod.SETTLE_HOLD_INVOICE) + +// get_budget success response +class GetBudgetSuccessResponse( + val result: GetBudgetResult? = null, +) : Response(NwcMethod.GET_BUDGET) { + class GetBudgetResult( + val used_budget: Long? = null, + val total_budget: Long? = null, + val renews_at: Long? = null, + val renewal_period: String? = null, + ) +} + +// sign_message success response +class SignMessageSuccessResponse( + val result: SignMessageResult? = null, +) : Response(NwcMethod.SIGN_MESSAGE) { + class SignMessageResult( + val message: String? = null, + val signature: String? = null, + ) +} + +// create_connection success response +class CreateConnectionSuccessResponse( + val result: CreateConnectionResult? = null, +) : Response(NwcMethod.CREATE_CONNECTION) { + class CreateConnectionResult( + val wallet_pubkey: String? = null, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt new file mode 100644 index 0000000000..3d732528fe --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class EncryptionTag { + companion object { + const val TAG_NAME = "encryption" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag.drop(1) + } + + fun assemble(schemes: List) = arrayOf(TAG_NAME, *schemes.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt new file mode 100644 index 0000000000..cb42cce542 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class NotificationsTag { + companion object { + const val TAG_NAME = "notifications" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag.drop(1) + } + + fun assemble(types: List) = arrayOf(TAG_NAME, *types.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt index a8e28b6f8e..a96ca7fd38 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag @@ -47,7 +48,12 @@ class CalendarRSVPEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun status() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parse) fun statusValue() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parseValue) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt index 5ff212d0c3..c1ca788297 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt @@ -20,14 +20,19 @@ */ package com.vitorpamplona.quartz.nip59Giftwrap.rumors +import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization.RumorKSerializer import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.utils.EventFactory +import kotlinx.serialization.Serializable +@Immutable +@Serializable(with = RumorKSerializer::class) class Rumor( val id: HexKey?, val pubKey: HexKey?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt new file mode 100644 index 0000000000..f6fb5622a7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.TagArrayKSerializer +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object RumorKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Rumor") { + element("id") + element("pubkey") + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + } + + override fun serialize( + encoder: Encoder, + value: Rumor, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + value.id?.let { put("id", it) } + value.pubKey?.let { put("pubkey", it) } + value.createdAt?.let { put("created_at", it) } + value.kind?.let { put("kind", it) } + value.tags?.let { put("tags", TagArrayKSerializer.serializeToElement(it)) } + value.content?.let { put("content", it) } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Rumor { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + var id: HexKey? = null + var pubKey: HexKey? = null + var createdAt: Long? = null + var kind: Int? = null + var tags: TagArray? = null + var content: String? = null + + for ((key, value) in jsonObject) { + when (key) { + "id" -> id = value.jsonPrimitive.content + "pubkey" -> pubKey = value.jsonPrimitive.content + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + } + } + + return Rumor(id, pubKey, createdAt, kind, tags, content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt index 997e9921c7..4dd69fbd45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -217,9 +217,12 @@ object ChessStateReconstructor { return fen1 == fen2 // Fallback to exact match } - return parts1[0] == parts2[0] && // Board position - parts1[1] == parts2[1] && // Active color - parts1[2] == parts2[2] && // Castling rights + return parts1[0] == parts2[0] && + // Board position + parts1[1] == parts2[1] && + // Active color + parts1[2] == parts2[2] && + // Castling rights parts1[3] == parts2[3] // En passant } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index eee5e86891..0239da5a80 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.quartz.utils +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -38,7 +42,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher @@ -76,8 +80,10 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -183,6 +189,10 @@ class EventFactory { AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) AppRecommendationEvent.KIND -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) AppSpecificDataEvent.KIND -> AppSpecificDataEvent(id, pubKey, createdAt, tags, content, sig) + AttestationEvent.KIND -> AttestationEvent(id, pubKey, createdAt, tags, content, sig) + AttestationRequestEvent.KIND -> AttestationRequestEvent(id, pubKey, createdAt, tags, content, sig) + AttestorRecommendationEvent.KIND -> AttestorRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AttestorProficiencyEvent.KIND -> AttestorProficiencyEvent(id, pubKey, createdAt, tags, content, sig) AudioHeaderEvent.KIND -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) @@ -256,6 +266,9 @@ class EventFactory { LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig) + NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) + NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) @@ -277,7 +290,7 @@ class EventFactory { PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) - PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) + ZapPollEvent.KIND -> ZapPollEvent(id, pubKey, createdAt, tags, content, sig) PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig) PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index c8fcbde7db..90ca1272c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -24,6 +24,7 @@ object TimeUtils { const val TEN_SECONDS = 10 const val ONE_MINUTE = 60 const val FIVE_MINUTES = 5 * ONE_MINUTE + const val TEN_MINUTES = 10 * ONE_MINUTE const val FIFTEEN_MINUTES = 15 * ONE_MINUTE const val ONE_HOUR = 60 * ONE_MINUTE const val EIGHT_HOURS = 8 * ONE_HOUR @@ -70,4 +71,9 @@ object TimeUtils { fun ninetyDaysFromNow() = now() + NINETY_DAYS fun oneYearAgo() = now() - ONE_YEAR + + fun withinTenMinutes(time: Long): Boolean { + val now = now() + return time > now - TEN_MINUTES && time < now + TEN_MINUTES + } } diff --git a/quartz/src/commonTest/kotlin/android/util/Log.kt b/quartz/src/commonTest/kotlin/android/util/Log.kt index ba75a2cab1..eb9574b088 100644 --- a/quartz/src/commonTest/kotlin/android/util/Log.kt +++ b/quartz/src/commonTest/kotlin/android/util/Log.kt @@ -24,6 +24,12 @@ import kotlin.jvm.JvmStatic class Log { companion object { + @JvmStatic + fun isLoggable( + tag: String?, + msg: Int?, + ): Boolean = true + @JvmStatic fun d( tag: String?, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt index 633888a45a..6f0c5a11c7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -21,5 +21,7 @@ package com.vitorpamplona.quartz expect class TestResourceLoader() { + fun loadDecompressString(file: String): String + fun loadString(file: String): String } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt new file mode 100644 index 0000000000..bc89df39f4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -0,0 +1,536 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerAuthTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val pubkey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/") + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String = hexId(1), + kind: Int = 1, + createdAt: Long = 1000L, + content: String = "hello", + tags: Array> = emptyArray(), + ) = Event(id, pubkey, createdAt, kind, tags, content, sig) + + /** + * Creates a server using the given dispatcher so coroutines run eagerly + * in tests (UnconfinedTestDispatcher). + */ + private fun createServer( + dispatcher: CoroutineDispatcher, + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { FullAuthPolicy(relayUrl) }, + ): NostrServer = + NostrServer( + store = store, + policyBuilder = policyBuilder, + parentContext = dispatcher, + ) + + private suspend fun RelaySession.insert(event: Event) { + val cmd = EventCmd(event) + this.receive(OptimizedJsonMapper.toJson(cmd)) + } + + /** Collects sent JSON messages for a connection. */ + private class MessageCollector { + val messages = mutableListOf() + + val sendCallback: (String) -> Unit = { messages.add(it) } + + /** + * Parses messages that can be round-tripped (EVENT, EOSE, NOTICE, + * CLOSED). OkMessage and CountMessage serialization uses formats + * incompatible with the client-side deserializer, so check those + * via [rawMessagesContaining]. + */ + fun parsedEventMessages() = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun rawMessagesContaining(label: String) = messages.filter { it.contains("\"$label\"") } + } + + /** + * Builds a kind 22242 auth event for testing. Because verify = { true }, + * the id and signature don't need to be real. + */ + private fun authEvent( + challenge: String, + relay: String = relayUrl.url, + pubKey: String = pubkey, + createdAt: Long = TimeUtils.now(), + ) = RelayAuthEvent( + id = hexId(99), + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("relay", relay), + arrayOf("challenge", challenge), + ), + content = "", + sig = sig, + ) + + private fun authJson(event: RelayAuthEvent): String { + val cmd = AuthCmd(event) + return OptimizedJsonMapper.toJson(cmd) + } + + private fun authJson(event: Event) = """["AUTH",${event.toJson()}]""" + + @Test + fun authChallengeIsSentOnRequest() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + + server.shutdown() + } + + @Test + fun authSucceedsWithValidEvent() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) + assertTrue(session.policy.authenticatedUsers.contains(pubkey)) + + server.shutdown() + } + + @Test + fun authFailsWithWrongChallenge() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = authEvent(challenge = "wrong-challenge") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("challenge")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongRelay() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge, relay = "wss://wrong.relay.com/") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("relay url")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithExpiredTimestamp() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = + authEvent( + challenge = msg.challenge, + createdAt = TimeUtils.now() - 1200L, // 20 minutes ago + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("created_at")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongKind() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Create an event with wrong kind (1 instead of 22242) + val event = + Event( + id = hexId(99), + pubKey = pubkey, + createdAt = TimeUtils.now(), + kind = 1, + tags = + arrayOf( + arrayOf("relay", relayUrl.url), + arrayOf("challenge", msg.challenge), + ), + content = "", + sig = sig, + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("NOTICE") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("error")) + assertTrue(okMessages[0].contains("could not parse message")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun multipleUsersCanAuthenticate() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // First user authenticates + val event1 = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(event1)) + + // Second user authenticates on the same session + val event2 = authEvent(challenge = msg.challenge, pubKey = pubkey2) + session.receive(authJson(event2)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[1].contains("\"true\"")) + + val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers + assertEquals(2, authedPubkeys.size) + assertTrue(authedPubkeys.contains(pubkey)) + assertTrue(authedPubkeys.contains(pubkey2)) + + server.shutdown() + } + + // -- NIP-42: requireAuth --------------------------------------------------- + + @Test + fun requireAuthRejectsEventWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsReqWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsCountWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["COUNT","q1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthAllowsCommandsAfterAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate first + val authEv = authEvent(challenge = msg.challenge) + session.receive(authJson(authEv)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + // Now EVENT should work + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val allOk = collector.rawMessagesContaining("OK") + assertEquals(2, allOk.size) + assertTrue(allOk[1].contains("\"true\"")) + + // REQ should work + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val eoseMessages = collector.rawMessagesContaining("EOSE") + assertTrue(eoseMessages.isNotEmpty()) + + server.shutdown() + } + + @Test + fun noAuthRequiredAllowsCommandsWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { EmptyPolicy }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // EVENT should work without auth when using OpenPolicy + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + server.shutdown() + } + + // -- Custom AuthPolicy tests ----------------------------------------------- + + @Test + fun customPolicyRejectsSpecificEventKinds() = + runTest { + // Policy that blocks kind 4 (DMs) from unauthenticated users. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = + if (cmd.event.kind == 4 && authenticatedUsers.isEmpty()) { + PolicyResult.Rejected("auth-required: kind 4 events require authentication") + } else { + PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { policy }) + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + // Kind 1 should be accepted without auth + val note = testEvent(hexId(1), kind = 1) + session.receive("""["EVENT",${note.toJson()}]""") + assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) + + // Kind 4 should be rejected without auth + val dm = testEvent(hexId(2), kind = 4) + session.receive("""["EVENT",${dm.toJson()}]""") + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[1].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun customPolicyRewritesFilters() = + runTest { + // Policy that restricts kind 4 queries to the authed user's own messages. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult { + val hasDmFilter = cmd.filters.any { it.kinds?.contains(4) == true } + if (!hasDmFilter) return PolicyResult.Accepted(cmd) + if (authenticatedUsers.isEmpty()) { + return PolicyResult.Rejected("auth-required: kind 4 requires auth") + } + // Rewrite: restrict to authed user's pubkey as author + val rewritten = + cmd.filters.map { filter -> + if (filter.kinds?.contains(4) == true) { + filter.copy(authors = authenticatedUsers.toList()) + } else { + filter + } + } + return PolicyResult.Accepted(ReqCmd(cmd.subId, rewritten)) + } + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(store = store, dispatcher = dispatcher, policyBuilder = { policy }) + + // Insert DMs from two different authors + store.insert(testEvent(hexId(1), kind = 4, createdAt = 100L)) // from pubkey + store.insert( + Event(hexId(2), pubkey2, 200L, 4, emptyArray(), "secret", sig), + ) // from pubkey2 + + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate as pubkey + val auth = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(auth)) + + // Query kind 4 — policy should rewrite to only return pubkey's events + session.receive("""["REQ","sub1",{"kinds":[4]}]""") + + val events = collector.parsedEventMessages().filterIsInstance() + assertEquals(1, events.size) + assertEquals(pubkey, events[0].event.pubKey) + + server.shutdown() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt new file mode 100644 index 0000000000..165bfb0e47 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String = hexId(1), + kind: Int = 1, + createdAt: Long = 1000L, + content: String = "hello", + tags: Array> = emptyArray(), + ) = Event(id, pubkey, createdAt, kind, tags, content, sig) + + /** + * Creates a server using the given dispatcher so coroutines run eagerly + * in tests (UnconfinedTestDispatcher). + */ + private fun createServer( + dispatcher: kotlinx.coroutines.CoroutineDispatcher, + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, + ): NostrServer = + NostrServer( + store = store, + policyBuilder = policyBuilder, + parentContext = dispatcher, + ) + + private suspend fun RelaySession.insert(event: Event) { + val cmd = EventCmd(event) + this.receive(OptimizedJsonMapper.toJson(cmd)) + } + + /** Collects sent JSON messages for a connection. */ + private class MessageCollector { + val messages = mutableListOf() + + val sendCallback: (String) -> Unit = { messages.add(it) } + + /** + * Parses messages that can be round-tripped (EVENT, EOSE, NOTICE, + * CLOSED). OkMessage and CountMessage serialization uses formats + * incompatible with the client-side deserializer, so check those + * via [rawMessagesContaining]. + */ + fun parsedEventMessages() = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun rawMessagesContaining(label: String) = messages.filter { it.contains("\"$label\"") } + } + + // -- EVENT command --------------------------------------------------------- + + @Test + fun eventCommandStoresAndRespondsOk() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + + val c1 = server.connect(collector.sendCallback) + + val event = testEvent() + val eventJson = """["EVENT",${event.toJson()}]""" + c1.receive(eventJson) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + // Event should be in store + val stored = store.query(Filter(ids = listOf(event.id))) + assertEquals(1, stored.size) + + server.shutdown() + } + + @Test + fun duplicateEventReturnsOkFalse() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + + val c1 = server.connect(collector.sendCallback) + + val event = testEvent() + val eventJson = """["EVENT",${event.toJson()}]""" + c1.receive(eventJson) + c1.receive(eventJson) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[1].contains("\"false\"")) + + server.shutdown() + } + + // -- REQ command ----------------------------------------------------------- + + @Test + fun reqReturnsStoredEventsAndEose() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + + // Pre-populate store + store.insert(testEvent(hexId(1), kind = 1, createdAt = 100L)) + store.insert(testEvent(hexId(2), kind = 1, createdAt = 200L)) + store.insert(testEvent(hexId(3), kind = 4, createdAt = 300L)) + + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + val parsed = collector.parsedEventMessages() + val events = parsed.filterIsInstance() + val eose = parsed.filterIsInstance() + + assertEquals(2, events.size) + assertEquals(1, eose.size) + assertEquals("sub1", eose[0].subId) + + // Events should be newest first + assertTrue(events[0].event.createdAt >= events[1].event.createdAt) + + server.shutdown() + } + + @Test + fun reqWithLimitRespectsLimit() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + + for (i in 1..10) { + store.insert(testEvent(hexId(i), createdAt = i.toLong())) + } + + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val reqJson = """["REQ","sub1",{"limit":3}]""" + c1.receive(reqJson) + + val events = collector.parsedEventMessages().filterIsInstance() + assertEquals(3, events.size) + + server.shutdown() + } + + // -- Live subscription ----------------------------------------------------- + + @Test + fun liveSubscriptionReceivesNewEvents() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + // Subscribe to kind 1 + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + // After REQ, we should have EOSE + val countAfterEose = collector1.messages.size + + // Now store a new event — should be pushed to subscription + c2.insert(testEvent(hexId(1), kind = 1)) + + val newMessages = collector1.messages.drop(countAfterEose) + assertTrue(newMessages.isNotEmpty()) + assertTrue(newMessages[0].contains("\"EVENT\"")) + + server.shutdown() + } + + @Test + fun liveSubscriptionFiltersNonMatchingEvents() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + val countAfterEose = collector1.messages.size + + // Store a kind 4 event — should NOT match kind 1 subscription + c2.insert(testEvent(hexId(1), kind = 4)) + + assertEquals(countAfterEose, collector1.messages.size) + + server.shutdown() + } + + // -- CLOSE command --------------------------------------------------------- + + @Test + fun closeStopsSubscription() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + // Close the subscription + val closeJson = """["CLOSE","sub1"]""" + c1.receive(closeJson) + + val countAfterClose = collector1.messages.size + + // New events should NOT reach this subscription + c2.insert(testEvent(hexId(1), kind = 1)) + + assertEquals(countAfterClose, collector1.messages.size) + + server.shutdown() + } + + @Test + fun replacingSubscriptionCancelsOld() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + // First subscription for kind 1 + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") + + // Replace with kind 4 + c1.receive("""["REQ","sub1",{"kinds":[4]}]""") + + val countAfterReplace = collector1.messages.size + + // Kind 1 events should not match anymore + c2.insert(testEvent(hexId(1), kind = 1)) + + assertEquals(countAfterReplace, collector1.messages.size) + + // Kind 4 events should match + c2.insert(testEvent(hexId(2), kind = 4)) + + val newMessages = collector1.messages.drop(countAfterReplace) + + assertTrue(newMessages.isNotEmpty()) + assertTrue(newMessages[0].contains("\"EVENT\"")) + assertTrue(newMessages[0].contains(hexId(2))) + + server.shutdown() + } + + // -- COUNT command (NIP-45) ------------------------------------------------ + + @Test + fun countReturnsMatchingEventCount() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + + store.insert(testEvent(hexId(1), kind = 1)) + store.insert(testEvent(hexId(2), kind = 1)) + store.insert(testEvent(hexId(3), kind = 4)) + + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val countJson = """["COUNT","q1",{"kinds":[1]}]""" + c1.receive(countJson) + + val countMessages = collector.rawMessagesContaining("COUNT") + assertEquals(1, countMessages.size) + assertTrue(countMessages[0].contains("\"count\":2")) + + server.shutdown() + } + + // -- Disconnect ------------------------------------------------------------ + + @Test + fun disconnectCancelsAllSubscriptions() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") + c1.receive("""["REQ","sub2",{"kinds":[4]}]""") + + c1.close() + + val countAfterDisconnect = collector1.messages.size + + c2.insert(testEvent(hexId(1), kind = 1)) + c2.insert(testEvent(hexId(2), kind = 4)) + + assertEquals(countAfterDisconnect, collector1.messages.size) + assertEquals(2, collector2.messages.size) + + server.shutdown() + } + + // -- Invalid messages ------------------------------------------------------ + + @Test + fun invalidJsonReturnsNotice() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val c1 = server.connect(collector.sendCallback) + c1.receive("not valid json") + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("NOTICE")) + + server.shutdown() + } +} diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt similarity index 90% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index eb6628aa3e..12c1209b59 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -20,15 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.fail -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class AddressableTest : BaseDBTest() { val signer = NostrSignerSync() @@ -76,18 +75,12 @@ class AddressableTest : BaseDBTest() { db.assertQuery(version3, Filter(ids = listOf(version3.id))) - try { + assertFailsWith { db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } - try { + assertFailsWith { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } db.assertQuery(version3, Filter(ids = listOf(version3.id))) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt similarity index 69% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt index f2fa60ceb6..1475b91e7b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import junit.framework.TestCase +import kotlin.test.assertEquals fun EventStore.assertQuery( expected: T?, @@ -31,12 +31,12 @@ fun EventStore.assertQuery( val queryResult = query(filter) val countResult = count(filter) if (expected == null) { - TestCase.assertEquals(0, queryResult.size) - TestCase.assertEquals(0, countResult) + assertEquals(0, queryResult.size) + assertEquals(0, countResult) } else { - TestCase.assertEquals(1, queryResult.size) - TestCase.assertEquals(1, countResult) - TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + assertEquals(1, queryResult.size) + assertEquals(1, countResult) + assertEquals(expected.toJson(), queryResult.first().toJson()) } } @@ -46,10 +46,10 @@ fun EventStore.assertQuery( ) { val queryResult = query(filter) val countResult = count(filter) - TestCase.assertEquals(expected.size, queryResult.size) - TestCase.assertEquals(expected.size, countResult) + assertEquals(expected.size, queryResult.size) + assertEquals(expected.size, countResult) expected.forEachIndexed { index, event -> - TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + assertEquals(event.toJson(), queryResult[index].toJson()) } } @@ -60,12 +60,12 @@ fun SQLiteEventStore.assertQuery( val queryResult = query(filter) val countResult = count(filter) if (expected == null) { - TestCase.assertEquals(0, queryResult.size) - TestCase.assertEquals(0, countResult) + assertEquals(0, queryResult.size) + assertEquals(0, countResult) } else { - TestCase.assertEquals(1, queryResult.size) - TestCase.assertEquals(1, countResult) - TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + assertEquals(1, queryResult.size) + assertEquals(1, countResult) + assertEquals(expected.toJson(), queryResult.first().toJson()) } } @@ -75,9 +75,9 @@ fun SQLiteEventStore.assertQuery( ) { val queryResult = query(filter) val countResult = count(filter) - TestCase.assertEquals(expected.size, queryResult.size) - TestCase.assertEquals(expected.size, countResult) + assertEquals(expected.size, queryResult.size) + assertEquals(expected.size, countResult) expected.forEachIndexed { index, event -> - TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + assertEquals(event.toJson(), queryResult[index].toJson()) } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt similarity index 91% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt index 2dbc1d860c..8ab0e902c8 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import org.junit.After -import org.junit.Before +import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlin.test.AfterTest +import kotlin.test.BeforeTest open class BaseDBTest { private lateinit var dbs: MutableMap @@ -36,10 +36,8 @@ open class BaseDBTest { useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy """.trimIndent() - @Before + @BeforeTest fun setup() { - val context = ApplicationProvider.getApplicationContext() - val booleans = listOf(true, false) dbs = mutableMapOf() @@ -58,7 +56,6 @@ open class BaseDBTest { ) dbs[indexStrategy.name()] = EventStore( - context = context, dbName = null, indexStrategy = indexStrategy, ) @@ -68,7 +65,7 @@ open class BaseDBTest { } } - @After + @AfterTest fun tearDown() { dbs.forEach { it.value.close() } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt similarity index 98% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index 8dba4ac609..56216d2db5 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -28,9 +28,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class BasicTest : BaseDBTest() { val signer = NostrSignerSync() diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt similarity index 89% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 50af62947f..9ad77c82a3 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -20,19 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher +import com.vitorpamplona.quartz.nip01Core.store.sqlite.explainQuery import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase -import junit.framework.TestCase.fail -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class DeletionTest : BaseDBTest() { val signer = NostrSignerSync() @@ -61,12 +62,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(note2, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -107,12 +104,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(null, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -146,12 +139,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(null, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -192,12 +181,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(wrap1.id))) db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -218,7 +203,7 @@ class DeletionTest : BaseDBTest() { val explainer = db.store.explainQuery(sql) if (db.indexStrategy.indexTagsWithKindAndPubkey) { - TestCase.assertEquals( + assertEquals( """ |$sql |└── SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) @@ -226,7 +211,7 @@ class DeletionTest : BaseDBTest() { explainer, ) } else { - TestCase.assertEquals( + assertEquals( """ |$sql |└── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) @@ -245,10 +230,13 @@ class DeletionTest : BaseDBTest() { pubkey = "key1", idValues = listOf("ca29c211f", "ca29c211d"), addresses = emptyList(), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE @@ -275,10 +263,13 @@ class DeletionTest : BaseDBTest() { listOf( Address(30000, "key1", "a"), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE ( @@ -309,10 +300,13 @@ class DeletionTest : BaseDBTest() { Address(30000, "key1", "c"), Address(30000, "key1", "d"), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE ( @@ -345,10 +339,13 @@ class DeletionTest : BaseDBTest() { Address(30001, "key2", "e"), Address(30001, "key2", "f"), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE ( @@ -387,10 +384,13 @@ class DeletionTest : BaseDBTest() { Address(10001, "key2", ""), Address(10001, "key2", ""), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt similarity index 88% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt index e9c589bfe7..fcd90af04b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase.fail -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertFailsWith class ExpirationTest : BaseDBTest() { val signer = NostrSignerSync() @@ -58,7 +59,9 @@ class ExpirationTest : BaseDBTest() { db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) - Thread.sleep(2000) + runBlocking { + delay(2000) + } db.deleteExpiredEvents() @@ -78,11 +81,8 @@ class ExpirationTest : BaseDBTest() { }, ) - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert expired events") - } catch (e: Exception) { - assertTrue(e is SQLiteConstraintException) } } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt similarity index 99% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt index 196dd769ca..0c5a27283b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import org.junit.Test +import kotlin.test.Test class FilterMatcherTest : BaseDBTest() { val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt similarity index 55% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index 5947c9cdb1..82a982c1d1 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -20,47 +20,38 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import android.database.sqlite.SQLiteException -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.readValue +import androidx.sqlite.SQLiteException +import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log -import org.junit.After -import org.junit.Before -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import java.util.zip.GZIPInputStream -import kotlin.system.measureTimeMillis +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) class LargeDBTests { companion object { - fun getEventDB(): List { - // This file includes duplicates - val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json") - - return JacksonMapper.mapper.readValue>( - GZIPInputStream(fullDBInputStream), + fun getEventDB(): List = + OptimizedJsonMapper.fromJsonToEventList( + TestResourceLoader().loadDecompressString("nostr_vitor_startup_data.json"), ) - } - val events = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + val events by + lazy { + getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + } } private lateinit var db: EventStore - @Before + @BeforeTest fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) + db = EventStore(null) } - @After + @AfterTest fun tearDown() { db.close() } @@ -69,13 +60,7 @@ class LargeDBTests { fun insertHeavyEvent() { events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> try { - val measure = - measureTimeMillis { - db.insert(event) - } - if (measure > 1) { - println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") - } + db.insert(event) } catch (e: SQLiteException) { Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") } @@ -83,17 +68,10 @@ class LargeDBTests { } @Test - @Ignore("Not testing") fun insertDatabase() { events.forEach { event -> try { - val measure = - measureTimeMillis { - db.insert(event) - } - if (measure > 1) { - println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") - } + db.insert(event) } catch (e: SQLiteException) { Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt similarity index 95% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index f9f3090423..aa0dda712b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -23,32 +23,33 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import junit.framework.TestCase -import junit.framework.TestCase.assertEquals -import org.junit.Assert -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals class QueryAssemblerTest : BaseDBTest() { - val hasher = TagNameValueHasher(0) + val hasher = + _root_ide_package_.com.vitorpamplona.quartz.nip01Core.store.sqlite + .TagNameValueHasher(0) val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) + fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.connection) - fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) + fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.connection) @Test fun testEmpty() = forEachDB { db -> val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexEventsByCreatedAtAlone) { - Assert.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -57,7 +58,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(Filter()), ) } else { - Assert.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -81,7 +82,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsWithKindAndPubkey) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -99,7 +100,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -126,7 +127,7 @@ class QueryAssemblerTest : BaseDBTest() { val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexEventsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -136,7 +137,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -160,7 +161,7 @@ class QueryAssemblerTest : BaseDBTest() { "created_at DESC" } if (db.indexStrategy.indexEventsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -187,7 +188,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -234,7 +235,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexEventsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -254,14 +255,15 @@ class QueryAssemblerTest : BaseDBTest() { │ │ └── SCAN (subquery-1) │ ├── UNION USING TEMP B-TREE │ │ ├── CO-ROUTINE (subquery-3) - │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ │ └── USE TEMP B-TREE FOR ORDER BY │ │ └── SCAN (subquery-3) │ └── UNION USING TEMP B-TREE │ ├── CO-ROUTINE (subquery-5) - │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) - │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 + │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ └── USE TEMP B-TREE FOR ORDER BY │ └── SCAN (subquery-5) ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) @@ -270,7 +272,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -291,14 +293,15 @@ class QueryAssemblerTest : BaseDBTest() { │ │ └── SCAN (subquery-1) │ ├── UNION USING TEMP B-TREE │ │ ├── CO-ROUTINE (subquery-3) - │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ │ └── USE TEMP B-TREE FOR ORDER BY │ │ └── SCAN (subquery-3) │ └── UNION USING TEMP B-TREE │ ├── CO-ROUTINE (subquery-5) - │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) - │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 + │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ └── USE TEMP B-TREE FOR ORDER BY │ └── SCAN (subquery-5) ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) @@ -320,7 +323,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE kind = "3" @@ -331,7 +334,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE kind = "3" @@ -360,7 +363,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -400,7 +403,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "3") AND (d_tag = "") @@ -411,7 +414,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "3") AND (d_tag = "") @@ -436,7 +439,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -467,7 +470,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -485,7 +488,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -518,7 +521,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -550,7 +553,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -568,7 +571,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -606,7 +609,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -625,7 +628,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -651,7 +654,7 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(ids = listOf(key1)) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" @@ -661,7 +664,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" @@ -678,7 +681,7 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) @@ -690,7 +693,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) @@ -709,26 +712,26 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(authors = listOf(key1, key2, key3), search = "keywords") if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) ORDER BY event_headers.created_at DESC, event_headers.id ASC - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) ORDER BY event_headers.created_at DESC - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), @@ -742,13 +745,13 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(kinds = listOf(1, 1111, 10000), search = "keywords") val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "event_headers.created_at DESC, event_headers.id ASC" else "event_headers.created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_fts MATCH "keywords") AND (event_headers.kind IN ("1", "1111", "10000")) ORDER BY $orderBy - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), @@ -762,7 +765,7 @@ class QueryAssemblerTest : BaseDBTest() { val filter = Filter(tagsAll = mapOf("p" to listOf(key1, key2))) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -771,8 +774,8 @@ class QueryAssemblerTest : BaseDBTest() { ON event_headers.row_id = filtered.row_id ORDER BY $orderBy ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) - │ ├── SEARCH event_tagsAll0_1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) + │ ├── SEARCH event_tagsAll0_1 USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── SEARCH event_tags USING INDEX fk_event_tags_header_id (event_header_row_id=?) │ └── USE TEMP B-TREE FOR DISTINCT ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) @@ -781,7 +784,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -999,8 +1002,7 @@ class QueryAssemblerTest : BaseDBTest() { SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) ORDER BY created_at DESC, id ASC - ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - └── USE TEMP B-TREE FOR ORDER BY + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(), db.explain(filter), ) @@ -1010,8 +1012,7 @@ class QueryAssemblerTest : BaseDBTest() { SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) ORDER BY created_at DESC - ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - └── USE TEMP B-TREE FOR ORDER BY + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(), db.explain(filter), ) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt similarity index 90% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index ec0626e3f3..5f0ee192db 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.explainQuery import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.fail -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class ReplaceableTest : BaseDBTest() { val signer = NostrSignerSync() @@ -76,18 +76,12 @@ class ReplaceableTest : BaseDBTest() { db.assertQuery(version3, Filter(ids = listOf(version3.id))) - try { + assertFailsWith { db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } - try { + assertFailsWith { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } db.assertQuery(version3, Filter(ids = listOf(version3.id))) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt similarity index 86% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt index 6dc5b0c1ac..36be8e9b59 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -20,16 +20,15 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase.fail -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertFailsWith class RightToVanishTest : BaseDBTest() { val signer = NostrSignerSync() @@ -59,12 +58,8 @@ class RightToVanishTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) @@ -107,11 +102,8 @@ class RightToVanishTest : BaseDBTest() { db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) // trying to insert again should fail. - try { + assertFailsWith { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt similarity index 99% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index ae6a595ea6..786d1a7568 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.Test +import kotlin.test.Test class SearchTest : BaseDBTest() { companion object { diff --git a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Bip32SeedDerivationCommonTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationCommonTest.kt similarity index 93% rename from quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Bip32SeedDerivationCommonTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationCommonTest.kt index 4a6ccb8f98..c9f3040b06 100644 --- a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Bip32SeedDerivationCommonTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationCommonTest.kt @@ -18,12 +18,9 @@ * 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 nip06KeyDerivationCommon +package com.vitorpamplona.quartz.nip06KeyDerivation import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip06KeyDerivation.Bip32SeedDerivation -import com.vitorpamplona.quartz.nip06KeyDerivation.Bip39Mnemonics -import com.vitorpamplona.quartz.nip06KeyDerivation.KeyPath import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Nip06CommonTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Nip06CommonTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt index 5feaa16216..eec79ca775 100644 --- a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Nip06CommonTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt @@ -18,10 +18,9 @@ * 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 nip06KeyDerivationCommon +package com.vitorpamplona.quartz.nip06KeyDerivation import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt index 95432f61fc..946b8fb2da 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -101,6 +101,8 @@ private class TrackingNostrClient : INostrClient { override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + + override fun close() {} } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt new file mode 100644 index 0000000000..bfbbe8ac4e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -0,0 +1,619 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionState +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Interoperability tests using JSON structures matching Alby Hub (server) + * and Alby JS SDK (client) NIP-47 implementations. + * These tests verify that Amethyst can correctly parse responses from Alby wallets. + */ +class AlbyInteropTest { + // --- Alby Hub pay_invoice response format --- + + @Test + fun testAlbyPayInvoiceSuccess() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"6565656565656565656565656565656565656565656565656565656565656565","fees_paid":1}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("6565656565656565656565656565656565656565656565656565656565656565", response.result?.preimage) + assertEquals(1L, response.result?.fees_paid) + } + + @Test + fun testAlbyPayInvoiceInsufficientBalance() { + val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"insufficient funds available to send"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code) + assertEquals("insufficient funds available to send", response.error?.message) + } + + @Test + fun testAlbyPayInvoiceBadRequest() { + val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Failed to decode bolt11 invoice: bolt11 too short"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code) + } + + // --- Alby Hub get_balance response format --- + + @Test + fun testAlbyGetBalanceResponse() { + val json = """{"result_type":"get_balance","result":{"balance":21000000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(21000000L, response.result?.balance) + } + + // --- Alby Hub get_info response format (with extended fields) --- + + @Test + fun testAlbyGetInfoFullResponse() { + val json = + """{"result_type":"get_info","result":{"alias":"AlbyHub","color":"#3399ff","pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","network":"mainnet","block_height":800000,"block_hash":"00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10","methods":["pay_invoice","pay_keysend","get_balance","get_budget","get_info","make_invoice","lookup_invoice","list_transactions","sign_message"],"notifications":["payment_received","payment_sent"],"lud16":"satoshi@getalby.com"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val result = response.result + assertNotNull(result) + assertEquals("AlbyHub", result.alias) + assertEquals("#3399ff", result.color) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", result.pubkey) + assertEquals("mainnet", result.network) + assertEquals(800000L, result.block_height) + assertEquals("00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10", result.block_hash) + assertEquals(9, result.methods?.size) + assertEquals(2, result.notifications?.size) + assertEquals("satoshi@getalby.com", result.lud16) + } + + // --- Alby Hub get_budget response format --- + + @Test + fun testAlbyGetBudgetWithRenewal() { + val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val result = response.result + assertNotNull(result) + assertEquals(50000L, result.used_budget) + assertEquals(100000L, result.total_budget) + assertEquals(1700000000L, result.renews_at) + assertEquals("monthly", result.renewal_period) + } + + @Test + fun testAlbyGetBudgetUnlimited() { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.used_budget) + assertEquals(0L, response.result?.total_budget) + assertNull(response.result?.renews_at) + assertEquals("never", response.result?.renewal_period) + } + + // --- Alby Hub make_invoice response format --- + + @Test + fun testAlbyMakeInvoiceResponse() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc10n1pj3xyz...","description":"Test invoice","payment_hash":"abc123def456","amount":1000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.PENDING, txn.state) + assertEquals("lnbc10n1pj3xyz...", txn.invoice) + assertEquals("Test invoice", txn.description) + assertEquals("abc123def456", txn.payment_hash) + assertEquals(1000L, txn.amount) + assertEquals(0L, txn.fees_paid) + } + + // --- Alby Hub lookup_invoice with settled state --- + + @Test + fun testAlbyLookupInvoiceSettled() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"preimage123","payment_hash":"hash456","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals("preimage123", txn.preimage) + assertEquals(1694876500L, txn.settled_at) + } + + @Test + fun testAlbyLookupInvoiceNotFound() { + val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"transaction not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code) + } + + // --- Alby Hub list_transactions with total_count --- + + @Test + fun testAlbyListTransactionsWithTotalCount() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"SETTLED","invoice":"lnbc1...","payment_hash":"h1","amount":1000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500},{"type":"outgoing","state":"SETTLED","invoice":"lnbc2...","payment_hash":"h2","amount":2000,"fees_paid":10,"created_at":1693876400,"settled_at":1694876400}],"total_count":100}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(2, response.result?.transactions?.size) + assertEquals(100L, response.result?.total_count) + + val first = response.result?.transactions?.get(0) + assertEquals(NwcTransactionType.INCOMING, first?.type) + assertEquals(NwcTransactionState.SETTLED, first?.state) + + val second = response.result?.transactions?.get(1) + assertEquals(NwcTransactionType.OUTGOING, second?.type) + assertEquals(10L, second?.fees_paid) + } + + // --- Alby Hub sign_message response --- + + @Test + fun testAlbySignMessageResponse() { + val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"3045022100..."}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("Hello Nostr", response.result?.message) + assertEquals("3045022100...", response.result?.signature) + } + + // --- Alby Hub create_connection response --- + + @Test + fun testAlbyCreateConnectionResponse() { + val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", response.result?.wallet_pubkey) + } + + // --- Alby Hub notification formats --- + + @Test + fun testAlbyPaymentReceivedNotification() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"pre123","payment_hash":"hash123","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val txn = notification.notification + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals(5000L, txn.amount) + } + + @Test + fun testAlbyPaymentSentNotification() { + val json = + """{"notification_type":"payment_sent","notification":{"type":"outgoing","state":"SETTLED","invoice":"lnbc100n1...","preimage":"pre456","payment_hash":"hash456","amount":10000,"fees_paid":10,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val txn = notification.notification + assertNotNull(txn) + assertEquals(NwcTransactionType.OUTGOING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals(10000L, txn.amount) + assertEquals(10L, txn.fees_paid) + } + + @Test + fun testAlbyHoldInvoiceAcceptedNotification() { + val json = + """{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val data = notification.notification + assertNotNull(data) + assertEquals("incoming", data.type) + assertEquals(20000L, data.amount) + assertEquals(800000L, data.settle_deadline) + } + + // --- Alby Hub error code interop --- + + @Test + fun testAlbyRestricted() { + val json = """{"result_type":"pay_invoice","error":{"code":"RESTRICTED","message":"This app does not have the pay_invoice scope"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.RESTRICTED, response.error?.code) + } + + @Test + fun testAlbyExpiredConnection() { + val json = """{"result_type":"get_balance","error":{"code":"EXPIRED","message":"This app has expired"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.EXPIRED, response.error?.code) + } + + @Test + fun testAlbyQuotaExceeded() { + val json = """{"result_type":"pay_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Exceeded budget limit"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code) + } + + // --- Alby Hub request format interop --- + + @Test + fun testAlbyPayInvoiceRequestFormat() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc10u1pj3xyz..."}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc10u1pj3xyz...", request.params?.invoice) + } + + @Test + fun testAlbyGetBudgetRequestFormat() { + val json = """{"method":"get_budget","params":{}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + @Test + fun testAlbySignMessageRequestFormat() { + val json = """{"method":"sign_message","params":{"message":"Hello from Amethyst"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Hello from Amethyst", request.params?.message) + } + + @Test + fun testAlbyCreateConnectionRequestFormat() { + val json = + """{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Amethyst","request_methods":["pay_invoice","get_balance","get_info","make_invoice","lookup_invoice","list_transactions"],"notification_types":["payment_received","payment_sent"],"max_amount":100000,"budget_renewal":"monthly","isolated":false}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", request.params?.pubkey) + assertEquals("Amethyst", request.params?.name) + assertEquals(6, request.params?.request_methods?.size) + assertEquals(2, request.params?.notification_types?.size) + assertEquals(100000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + assertEquals(false, request.params?.isolated) + } + + // --- Alby Hub real bolt11 test vectors --- + + @Test + fun testAlbyRealBolt11PayInvoiceRequest() { + val json = + """{"method":"pay_invoice","params":{"invoice":"lntbs1230n1pnkqautdqyw3jsnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp5yvnh6hsnlnj4xnuh2trzlnunx732dv8ta2wjr75pdfxf6p2vlyassp5hyeg97a3ft5u769kjwsn7p0e85h79pzz8kladmnqhpcypz2uawjs9qyysgqcqpcxq8zals8sq9yeg2pa9eywkgj50cyzxd5elatujuc0c0wh6j9nat5mn34pgk8u9ufpgs99tw9ldlfk42cqlkr48au3lmuh09269prg4qkggh4a8cyqpfl0y6j","metadata":{"a":123}}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertNotNull(request.params?.invoice) + assertNotNull(request.params?.metadata) + } + + @Test + fun testAlbyPayKeysendWithTlvRecords() { + val json = + """{"method":"pay_keysend","params":{"amount":123000,"pubkey":"123pubkey2","preimage":"018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b","tlv_records":[{"type":5482373484,"value":"fajsn341414fq"}]}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(123000L, request.params?.amount) + assertEquals("123pubkey2", request.params?.pubkey) + assertEquals("018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b", request.params?.preimage) + assertNotNull(request.params?.tlv_records) + assertEquals(1, request.params?.tlv_records?.size) + assertEquals( + 5482373484L, + request.params + ?.tlv_records + ?.first() + ?.type, + ) + assertEquals( + "fajsn341414fq", + request.params + ?.tlv_records + ?.first() + ?.value, + ) + } + + @Test + fun testAlbyMakeInvoiceWithNestedMetadata() { + val json = + """{"method":"make_invoice","params":{"amount":1000,"description":"Hello, world","expiry":3600,"metadata":{"a":1,"b":"2","c":{"d":3,"e":[{"f":"g"},{"h":"i"}]}}}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("Hello, world", request.params?.description) + assertEquals(3600L, request.params?.expiry) + assertNotNull(request.params?.metadata) + } + + @Test + fun testAlbyMakeHoldInvoiceWithPaymentHash() { + val json = + """{"method":"make_hold_invoice","params":{"amount":1000,"description":"Hello, world","payment_hash":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","expiry":3600}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.payment_hash) + assertEquals("Hello, world", request.params?.description) + } + + @Test + fun testAlbySettleHoldInvoiceWithPreimage() { + val json = + """{"method":"settle_hold_invoice","params":{"preimage":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.preimage) + } + + @Test + fun testAlbyListTransactionsWithUnpaidFilters() { + val json = """{"method":"list_transactions","params":{"from":0,"until":0,"limit":10,"offset":0,"unpaid_outgoing":true}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(10, request.params?.limit) + assertEquals(true, request.params?.unpaid_outgoing) + } + + @Test + fun testAlbyCreateConnectionIsolated() { + val json = + """{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Test 123","request_methods":["get_info","pay_invoice"],"notification_types":["payment_received"],"max_amount":100000000,"budget_renewal":"monthly","isolated":true}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Test 123", request.params?.name) + assertEquals(true, request.params?.isolated) + assertEquals(100000000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + assertEquals(listOf("get_info", "pay_invoice"), request.params?.request_methods) + assertEquals(listOf("payment_received"), request.params?.notification_types) + } + + // --- Transaction with settle_deadline from Alby hold invoice --- + + @Test + fun testAlbyMakeHoldInvoiceWithSettleDeadline() { + val json = + """{"result_type":"make_hold_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000,"settle_deadline":144}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(144L, txn.settle_deadline) + assertEquals(NwcTransactionState.PENDING, txn.state) + } + + // =================================================================== + // Alby JS SDK (client) interop tests + // The JS SDK uses lowercase transaction states while Hub uses uppercase. + // Both formats must be handled correctly. + // =================================================================== + + @Test + fun testJsSdkLowercaseSettledState() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("settled", response.result?.state) + assertTrue(NwcTransactionState.isSettled(response.result?.state)) + } + + @Test + fun testJsSdkLowercasePendingState() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","state":"pending","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("pending", response.result?.state) + assertTrue(NwcTransactionState.isPending(response.result?.state)) + } + + @Test + fun testJsSdkLowercaseStatesInListTransactions() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"settled","amount":1000,"created_at":1000},{"type":"outgoing","state":"failed","amount":2000,"created_at":2000},{"type":"incoming","state":"accepted","amount":3000,"created_at":3000}],"total_count":3}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txns = response.result?.transactions + assertNotNull(txns) + assertEquals(3, txns.size) + assertTrue(NwcTransactionState.isSettled(txns[0].state)) + assertTrue(NwcTransactionState.isFailed(txns[1].state)) + assertTrue(NwcTransactionState.isAccepted(txns[2].state)) + } + + @Test + fun testJsSdkLowercaseStatesInNotification() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","state":"settled","invoice":"lnbc...","amount":5000,"created_at":1000,"settled_at":2000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertTrue(NwcTransactionState.isSettled(notification.notification?.state)) + } + + @Test + fun testJsSdkEmptyGetBudgetResponse() { + // JS SDK allows get_budget to return empty object when no budget is set + val json = """{"result_type":"get_budget","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNull(response.result?.used_budget) + assertNull(response.result?.total_budget) + assertNull(response.result?.renews_at) + assertNull(response.result?.renewal_period) + } + + @Test + fun testJsSdkGetBudgetWithAllRenewalPeriods() { + for (period in listOf("daily", "weekly", "monthly", "yearly", "never")) { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":100000,"renewal_period":"$period"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(period, response.result?.renewal_period) + } + } + + @Test + fun testJsSdkTransactionWithMetadata() { + // JS SDK supports structured metadata with comment, payer_data, nostr fields + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Thanks!","payer_data":{"name":"Alice","pubkey":"abc123"},"nostr":{"pubkey":"npub1...","tags":[["p","def456"]]}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.metadata) + } + + @Test + fun testMetadataParserComment() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Great post!"}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("Great post!", parsed.comment) + assertNull(parsed.payerData) + assertNull(parsed.nostr) + } + + @Test + fun testMetadataParserPayerData() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"payer_data":{"name":"Alice","email":"alice@example.com","pubkey":"abc123"}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("Alice", parsed.payerData?.name) + assertEquals("alice@example.com", parsed.payerData?.email) + assertEquals("abc123", parsed.payerData?.pubkey) + assertEquals("Alice", parsed.senderDisplayName()) + } + + @Test + fun testMetadataParserNostrZap() { + val senderHex = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + val recipientHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":21000,"created_at":1000,"settled_at":2000,"metadata":{"nostr":{"pubkey":"$senderHex","tags":[["p","$recipientHex"],["amount","21000"]]}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals(senderHex, parsed.senderPubkeyHex()) + assertEquals(recipientHex, parsed.recipientPubkeyHex()) + } + + @Test + fun testMetadataParserRecipientData() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"outgoing","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"recipient_data":{"identifier":"alice@getalby.com"}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("alice@getalby.com", parsed.recipientIdentifier()) + } + + @Test + fun testMetadataParserNullForSimpleMetadata() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"a":123}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNull(parsed) + } + + @Test + fun testMetadataParserNullMetadata() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNull(parsed) + } + + @Test + fun testJsSdkGetInfoWithAllMethods() { + // JS SDK advertises all 13 single methods + notifications + val json = + """{"result_type":"get_info","result":{"alias":"TestNode","methods":["get_info","get_balance","get_budget","make_invoice","pay_invoice","pay_keysend","lookup_invoice","list_transactions","sign_message","create_connection","make_hold_invoice","settle_hold_invoice","cancel_hold_invoice"],"notifications":["payment_received","payment_sent","hold_invoice_accepted"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(13, response.result?.methods?.size) + assertEquals(3, response.result?.notifications?.size) + assertEquals(response.result?.methods?.contains(NwcMethod.GET_BUDGET), true) + assertEquals(response.result?.methods?.contains(NwcMethod.SIGN_MESSAGE), true) + assertEquals(response.result?.methods?.contains(NwcMethod.CREATE_CONNECTION), true) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt new file mode 100644 index 0000000000..07fa5ac130 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LnZapPaymentRequestEventTest { + @Test + fun testEventKind() { + assertEquals(23194, LnZapPaymentRequestEvent.KIND) + } + + @Test + fun testCreatePayInvoiceRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + ) + + assertEquals(23194, event.kind) + assertEquals(walletServicePubkey, event.walletServicePubKey()) + assertTrue(event.isContentEncoded()) + assertNotNull(event.content) + assertTrue(event.content.isNotEmpty()) + } + + @Test + fun testCreateGenericRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + ) + + assertEquals(23194, event.kind) + assertEquals(walletServicePubkey, event.walletServicePubKey()) + // Should not have encryption tag for NIP-04 + assertNull(event.encryptionScheme()) + } + + @Test + fun testDecryptPayInvoiceRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + // Wallet service should be able to decrypt + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals("lnbc50n1...", decrypted.params?.invoice) + } + + @Test + fun testDecryptGenericRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = MakeInvoiceMethod.create(5000L, "test payment") + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals(5000L, decrypted.params?.amount) + assertEquals("test payment", decrypted.params?.description) + } + + @Test + fun testCanDecrypt() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val otherKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val otherSigner = NostrSignerInternal(otherKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + assertTrue(event.canDecrypt(clientSigner)) + assertTrue(event.canDecrypt(walletSigner)) + assertTrue(!event.canDecrypt(otherSigner)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt new file mode 100644 index 0000000000..825069de6b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class Nip47WalletConnectTest { + @Test + fun testParseWalletConnectUri() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + assertEquals("wss://relay.damus.io/", parsed.relayUri.url) + assertEquals("71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5", parsed.secret) + assertNull(parsed.lud16) + } + + @Test + fun testParseWalletConnectUriWithLud16() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5&lud16=user%40example.com" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + assertNotNull(parsed.lud16) + assertEquals("user@example.com", parsed.lud16) + } + + @Test + fun testParseNostrWalletConnectScheme() { + val uri = + "nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + } + + @Test + fun testParseAmethystWalletConnectScheme() { + val uri = + "amethyst+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + } + + @Test + fun testParseWithoutSecret() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io" + val parsed = Nip47WalletConnect.parse(uri) + + assertNull(parsed.secret) + } + + @Test + fun testParseInvalidSchemeThrows() { + val uri = "https://example.com?relay=wss%3A%2F%2Frelay.damus.io" + assertFailsWith { + Nip47WalletConnect.parse(uri) + } + } + + @Test + fun testParseWithoutRelayThrows() { + val uri = "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4" + assertFailsWith { + Nip47WalletConnect.parse(uri) + } + } + + // --- Alby JS SDK URI test vector --- + + @Test + fun testParseAlbyJsSdkUri() { + // Test vector from @getalby/js-sdk NWCClient.test.ts + val uri = + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss%3A%2F%2Frelay.getalby.com%2Fv1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b&lud16=hello%40getalby.com" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", parsed.pubKeyHex) + assertEquals("e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", parsed.secret) + assertEquals("hello@getalby.com", parsed.lud16) + } + + // --- Nip47URI serialization --- + + @Test + fun testNip47UriSerializationRoundTrip() { + val original = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", + relayUri = "wss://relay.damus.io", + secret = "abc123", + lud16 = "user@example.com", + ) + + val json = Nip47WalletConnect.Nip47URI.serializer(original) + val deserialized = Nip47WalletConnect.Nip47URI.parser(json) + + assertEquals(original.pubKeyHex, deserialized.pubKeyHex) + assertEquals(original.relayUri, deserialized.relayUri) + assertEquals(original.secret, deserialized.secret) + assertEquals(original.lud16, deserialized.lud16) + } + + @Test + fun testNip47UriSerializationWithNullLud16() { + val original = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "abc123", + relayUri = "wss://relay.damus.io", + secret = "secret", + ) + + val json = Nip47WalletConnect.Nip47URI.serializer(original) + val deserialized = Nip47WalletConnect.Nip47URI.parser(json) + + assertEquals(original.pubKeyHex, deserialized.pubKeyHex) + assertNull(deserialized.lud16) + } + + // --- Normalize/Denormalize --- + + @Test + fun testNormalizeDenormalizeRoundTrip() { + val uri = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "abc123", + relayUri = "wss://relay.damus.io", + secret = "secret", + lud16 = "user@example.com", + ) + + val normalized = uri.normalize() + assertNotNull(normalized) + assertEquals("user@example.com", normalized.lud16) + + val denormalized = normalized.denormalize() + assertNotNull(denormalized) + assertEquals("abc123", denormalized.pubKeyHex) + assertEquals("secret", denormalized.secret) + assertEquals("user@example.com", denormalized.lud16) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt new file mode 100644 index 0000000000..3d6760e3b2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class NotificationTest { + @Test + fun testPaymentReceivedDeserialization() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","invoice":"lnbc50n1...","description":"coffee","preimage":"abc","payment_hash":"hash123","amount":5000,"fees_paid":10,"created_at":1693876497,"expires_at":1694876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("incoming", notification.notification.type) + assertEquals("lnbc50n1...", notification.notification.invoice) + assertEquals("coffee", notification.notification.description) + assertEquals("abc", notification.notification.preimage) + assertEquals("hash123", notification.notification.payment_hash) + assertEquals(5000L, notification.notification.amount) + assertEquals(10L, notification.notification.fees_paid) + assertEquals(1693876497L, notification.notification.created_at) + assertEquals(1694876497L, notification.notification.expires_at) + assertEquals(1694876500L, notification.notification.settled_at) + } + + @Test + fun testPaymentSentDeserialization() { + val json = + """{"notification_type":"payment_sent","notification":{"type":"outgoing","invoice":"lnbc100n1...","preimage":"def456","payment_hash":"hash456","amount":10000,"fees_paid":50,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("outgoing", notification.notification.type) + assertEquals("lnbc100n1...", notification.notification.invoice) + assertEquals("def456", notification.notification.preimage) + assertEquals(10000L, notification.notification.amount) + assertEquals(50L, notification.notification.fees_paid) + } + + @Test + fun testHoldInvoiceAcceptedDeserialization() { + val json = + """{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("incoming", notification.notification.type) + assertEquals("lnbc200n1...", notification.notification.invoice) + assertEquals("hash789", notification.notification.payment_hash) + assertEquals(20000L, notification.notification.amount) + assertEquals(800000L, notification.notification.settle_deadline) + assertEquals(1693876497L, notification.notification.created_at) + assertEquals(1694876497L, notification.notification.expires_at) + } + + @Test + fun testPaymentReceivedMinimalFields() { + val json = """{"notification_type":"payment_received","notification":{"type":"incoming","amount":100}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertEquals("incoming", notification.notification?.type) + assertEquals(100L, notification.notification?.amount) + assertNull(notification.notification?.invoice) + assertNull(notification.notification?.preimage) + } + + @Test + @Throws(IllegalArgumentException::class) + fun testUnknownNotificationTypeReturnsNull() { + val json = """{"notification_type":"unknown_type","notification":{}}""" + assertFailsWith { + OptimizedJsonMapper.fromJsonTo(json) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt new file mode 100644 index 0000000000..74a5d023e2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NwcInfoEventTest { + private val signer = DeterministicSigner("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + @Test + fun testBuildInfoEvent() { + val capabilities = listOf("pay_invoice", "get_balance", "make_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertEquals(NwcInfoEvent.KIND, event.kind) + assertEquals("pay_invoice get_balance make_invoice notifications", event.content) + } + + @Test + fun testCapabilities() { + val capabilities = listOf("pay_invoice", "get_balance", "make_invoice") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + val parsed = event.capabilities() + assertEquals(3, parsed.size) + assertTrue(parsed.contains("pay_invoice")) + assertTrue(parsed.contains("get_balance")) + assertTrue(parsed.contains("make_invoice")) + } + + @Test + fun testSupportsMethod() { + val capabilities = listOf("pay_invoice", "get_balance") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.supportsMethod("pay_invoice")) + assertTrue(event.supportsMethod("get_balance")) + assertFalse(event.supportsMethod("make_invoice")) + assertFalse(event.supportsMethod("pay_keysend")) + } + + @Test + fun testSupportsNotifications() { + val capabilities = listOf("pay_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.supportsNotifications()) + } + + @Test + fun testDoesNotSupportNotifications() { + val capabilities = listOf("pay_invoice", "get_balance") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertFalse(event.supportsNotifications()) + } + + @Test + fun testEncryptionSchemes() { + val capabilities = listOf("pay_invoice") + val template = NwcInfoEvent.build(capabilities, encryptionSchemes = listOf("nip44_v2", "nip04")) + val event = signer.sign(template) + + val schemes = event.encryptionSchemes() + assertEquals(2, schemes.size) + assertTrue(schemes.contains("nip44_v2")) + assertTrue(schemes.contains("nip04")) + } + + @Test + fun testNotificationTypes() { + val capabilities = listOf("pay_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities, notificationTypes = listOf("payment_received", "payment_sent")) + val event = signer.sign(template) + + val types = event.notificationTypes() + assertEquals(2, types.size) + assertTrue(types.contains("payment_received")) + assertTrue(types.contains("payment_sent")) + } + + @Test + fun testInfoEventKind() { + assertEquals(13194, NwcInfoEvent.KIND) + } + + @Test + fun testBuildWithNoOptionalTags() { + val capabilities = listOf("pay_invoice") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.encryptionSchemes().isEmpty()) + assertTrue(event.notificationTypes().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt new file mode 100644 index 0000000000..7da485fb8a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcBudgetRenewal +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionState +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NwcMethodTest { + @Test + fun testMethodConstants() { + assertEquals("pay_invoice", NwcMethod.PAY_INVOICE) + assertEquals("pay_keysend", NwcMethod.PAY_KEYSEND) + assertEquals("make_invoice", NwcMethod.MAKE_INVOICE) + assertEquals("lookup_invoice", NwcMethod.LOOKUP_INVOICE) + assertEquals("list_transactions", NwcMethod.LIST_TRANSACTIONS) + assertEquals("get_balance", NwcMethod.GET_BALANCE) + assertEquals("get_info", NwcMethod.GET_INFO) + assertEquals("get_budget", NwcMethod.GET_BUDGET) + assertEquals("sign_message", NwcMethod.SIGN_MESSAGE) + assertEquals("create_connection", NwcMethod.CREATE_CONNECTION) + assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE) + assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE) + assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE) + } + + @Test + fun testNotificationTypeConstants() { + assertEquals("payment_received", NwcNotificationType.PAYMENT_RECEIVED) + assertEquals("payment_sent", NwcNotificationType.PAYMENT_SENT) + assertEquals("hold_invoice_accepted", NwcNotificationType.HOLD_INVOICE_ACCEPTED) + } + + @Test + fun testErrorCodeValues() { + val codes = NwcErrorCode.entries + assertEquals(13, codes.size) + assertEquals(NwcErrorCode.RATE_LIMITED, NwcErrorCode.valueOf("RATE_LIMITED")) + assertEquals(NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.valueOf("NOT_IMPLEMENTED")) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, NwcErrorCode.valueOf("INSUFFICIENT_BALANCE")) + assertEquals(NwcErrorCode.PAYMENT_FAILED, NwcErrorCode.valueOf("PAYMENT_FAILED")) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, NwcErrorCode.valueOf("QUOTA_EXCEEDED")) + assertEquals(NwcErrorCode.RESTRICTED, NwcErrorCode.valueOf("RESTRICTED")) + assertEquals(NwcErrorCode.UNAUTHORIZED, NwcErrorCode.valueOf("UNAUTHORIZED")) + assertEquals(NwcErrorCode.INTERNAL, NwcErrorCode.valueOf("INTERNAL")) + assertEquals(NwcErrorCode.UNSUPPORTED_ENCRYPTION, NwcErrorCode.valueOf("UNSUPPORTED_ENCRYPTION")) + assertEquals(NwcErrorCode.BAD_REQUEST, NwcErrorCode.valueOf("BAD_REQUEST")) + assertEquals(NwcErrorCode.NOT_FOUND, NwcErrorCode.valueOf("NOT_FOUND")) + assertEquals(NwcErrorCode.EXPIRED, NwcErrorCode.valueOf("EXPIRED")) + assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER")) + } + + @Test + fun testTransactionTypeConstants() { + assertEquals("incoming", NwcTransactionType.INCOMING) + assertEquals("outgoing", NwcTransactionType.OUTGOING) + } + + @Test + fun testTransactionStateConstants() { + assertEquals("PENDING", NwcTransactionState.PENDING) + assertEquals("SETTLED", NwcTransactionState.SETTLED) + assertEquals("FAILED", NwcTransactionState.FAILED) + assertEquals("ACCEPTED", NwcTransactionState.ACCEPTED) + } + + @Test + fun testTransactionStateCaseInsensitive() { + // Alby Hub uses uppercase, Alby JS SDK uses lowercase + assertTrue(NwcTransactionState.isSettled("SETTLED")) + assertTrue(NwcTransactionState.isSettled("settled")) + assertTrue(NwcTransactionState.isPending("PENDING")) + assertTrue(NwcTransactionState.isPending("pending")) + assertTrue(NwcTransactionState.isFailed("FAILED")) + assertTrue(NwcTransactionState.isFailed("failed")) + assertTrue(NwcTransactionState.isAccepted("ACCEPTED")) + assertTrue(NwcTransactionState.isAccepted("accepted")) + assertFalse(NwcTransactionState.isSettled("pending")) + assertFalse(NwcTransactionState.isSettled(null)) + } + + @Test + fun testBudgetRenewalConstants() { + assertEquals("daily", NwcBudgetRenewal.DAILY) + assertEquals("weekly", NwcBudgetRenewal.WEEKLY) + assertEquals("monthly", NwcBudgetRenewal.MONTHLY) + assertEquals("yearly", NwcBudgetRenewal.YEARLY) + assertEquals("never", NwcBudgetRenewal.NEVER) + } + + @Test + fun testNwcError() { + val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed") + assertEquals(NwcErrorCode.UNAUTHORIZED, error.code) + assertEquals("not allowed", error.message) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt new file mode 100644 index 0000000000..f8609aa151 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NwcNotificationEventTest { + @Test + fun testKindConstants() { + assertEquals(23197, NwcNotificationEvent.KIND) + assertEquals(23196, NwcNotificationEvent.LEGACY_KIND) + } + + @Test + fun testIsContentEncoded() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = arrayOf(arrayOf("p", "c".repeat(64))), + content = "encrypted_content", + sig = "d".repeat(128), + ) + assertTrue(event.isContentEncoded()) + } + + @Test + fun testClientPubKey() { + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + assertEquals(clientPubKey, event.clientPubKey()) + } + + @Test + fun testClientPubKeyMissing() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = emptyArray(), + content = "encrypted", + sig = "d".repeat(128), + ) + assertNull(event.clientPubKey()) + } + + @Test + fun testTalkingWithAsWalletService() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // Wallet service asking "who am I talking with?" -> client + assertEquals(clientPubKey, event.talkingWith(walletPubKey)) + } + + @Test + fun testTalkingWithAsClient() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // Client asking "who am I talking with?" -> wallet service (pubkey) + assertEquals(walletPubKey, event.talkingWith(clientPubKey)) + } + + @Test + fun testEventKindInFactory() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = emptyArray(), + content = "", + sig = "c".repeat(128), + ) + assertEquals(23197, event.kind) + } + + @Test + fun testCanDecryptReturnsFalseForUnrelatedSigner() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // A signer that is neither the wallet nor the client shouldn't be able to decrypt + assertFalse(event.clientPubKey() == "e".repeat(64)) + assertFalse(event.pubKey == "e".repeat(64)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt new file mode 100644 index 0000000000..8fbaa3a31c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RequestTest { + // --- PayInvoice --- + + @Test + fun testPayInvoiceCreate() { + val request = PayInvoiceMethod.create("lnbc50n1...") + assertEquals(NwcMethod.PAY_INVOICE, request.method) + assertEquals("lnbc50n1...", request.params?.invoice) + assertNull(request.params?.amount) + } + + @Test + fun testPayInvoiceCreateWithAmount() { + val request = PayInvoiceMethod.create("lnbc50n1...", 1000L) + assertEquals(NwcMethod.PAY_INVOICE, request.method) + assertEquals("lnbc50n1...", request.params?.invoice) + assertEquals(1000L, request.params?.amount) + } + + @Test + fun testPayInvoiceSerialization() { + val request = PayInvoiceMethod.create("lnbc50n1...") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay_invoice\"")) + assertTrue(json.contains("\"invoice\":\"lnbc50n1...\"")) + } + + @Test + fun testPayInvoiceDeserialization() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1..."}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc50n1...", request.params?.invoice) + } + + @Test + fun testPayInvoiceWithAmountDeserialization() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1...","amount":1000}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc50n1...", request.params?.invoice) + assertEquals(1000L, request.params?.amount) + } + + // --- PayKeysend --- + + @Test + fun testPayKeysendCreate() { + val request = PayKeysendMethod.create(1000L, "abcdef1234567890") + assertEquals(NwcMethod.PAY_KEYSEND, request.method) + assertEquals(1000L, request.params?.amount) + assertEquals("abcdef1234567890", request.params?.pubkey) + assertNull(request.params?.preimage) + assertNull(request.params?.tlv_records) + } + + @Test + fun testPayKeysendWithTlvRecords() { + val tlvRecords = listOf(TlvRecord(7629169L, "hex_value")) + val request = PayKeysendMethod.create(1000L, "pubkey123", "preimage123", tlvRecords) + assertEquals(1000L, request.params?.amount) + assertEquals("pubkey123", request.params?.pubkey) + assertEquals("preimage123", request.params?.preimage) + assertNotNull(request.params?.tlv_records) + assertEquals(1, request.params?.tlv_records?.size) + assertEquals( + 7629169L, + request.params + ?.tlv_records + ?.first() + ?.type, + ) + assertEquals( + "hex_value", + request.params + ?.tlv_records + ?.first() + ?.value, + ) + } + + @Test + fun testPayKeysendSerialization() { + val request = PayKeysendMethod.create(1000L, "pubkey123") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay_keysend\"")) + assertTrue(json.contains("\"amount\":1000")) + assertTrue(json.contains("\"pubkey\":\"pubkey123\"")) + } + + @Test + fun testPayKeysendDeserialization() { + val json = """{"method":"pay_keysend","params":{"amount":1000,"pubkey":"pubkey123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("pubkey123", request.params?.pubkey) + } + + // --- MakeInvoice --- + + @Test + fun testMakeInvoiceCreate() { + val request = MakeInvoiceMethod.create(5000L, "test payment", null, 3600L) + assertEquals(NwcMethod.MAKE_INVOICE, request.method) + assertEquals(5000L, request.params?.amount) + assertEquals("test payment", request.params?.description) + assertNull(request.params?.description_hash) + assertEquals(3600L, request.params?.expiry) + } + + @Test + fun testMakeInvoiceSerialization() { + val request = MakeInvoiceMethod.create(5000L, "test") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"make_invoice\"")) + assertTrue(json.contains("\"amount\":5000")) + } + + @Test + fun testMakeInvoiceDeserialization() { + val json = """{"method":"make_invoice","params":{"amount":5000,"description":"test","expiry":3600}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(5000L, request.params?.amount) + assertEquals("test", request.params?.description) + assertEquals(3600L, request.params?.expiry) + } + + // --- LookupInvoice --- + + @Test + fun testLookupInvoiceByHash() { + val request = LookupInvoiceMethod.createByHash("abc123") + assertEquals(NwcMethod.LOOKUP_INVOICE, request.method) + assertEquals("abc123", request.params?.payment_hash) + assertNull(request.params?.invoice) + } + + @Test + fun testLookupInvoiceByInvoice() { + val request = LookupInvoiceMethod.createByInvoice("lnbc50n1...") + assertEquals(NwcMethod.LOOKUP_INVOICE, request.method) + assertNull(request.params?.payment_hash) + assertEquals("lnbc50n1...", request.params?.invoice) + } + + @Test + fun testLookupInvoiceDeserialization() { + val json = """{"method":"lookup_invoice","params":{"payment_hash":"abc123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.payment_hash) + } + + // --- ListTransactions --- + + @Test + fun testListTransactionsCreate() { + val request = ListTransactionsMethod.create(from = 1000L, until = 2000L, limit = 10, offset = 0, unpaid = false, type = "incoming") + assertEquals(NwcMethod.LIST_TRANSACTIONS, request.method) + assertEquals(1000L, request.params?.from) + assertEquals(2000L, request.params?.until) + assertEquals(10, request.params?.limit) + assertEquals(0, request.params?.offset) + assertEquals(false, request.params?.unpaid) + assertEquals("incoming", request.params?.type) + } + + @Test + fun testListTransactionsDeserialization() { + val json = """{"method":"list_transactions","params":{"from":1000,"until":2000,"limit":10}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.from) + assertEquals(2000L, request.params?.until) + assertEquals(10, request.params?.limit) + } + + // --- GetBalance --- + + @Test + fun testGetBalanceCreate() { + val request = GetBalanceMethod.create() + assertEquals(NwcMethod.GET_BALANCE, request.method) + } + + @Test + fun testGetBalanceSerialization() { + val request = GetBalanceMethod.create() + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"get_balance\"")) + } + + @Test + fun testGetBalanceDeserialization() { + val json = """{"method":"get_balance"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- GetInfo --- + + @Test + fun testGetInfoCreate() { + val request = GetInfoMethod.create() + assertEquals(NwcMethod.GET_INFO, request.method) + } + + @Test + fun testGetInfoDeserialization() { + val json = """{"method":"get_info"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- MakeHoldInvoice --- + + @Test + fun testMakeHoldInvoiceCreate() { + val request = MakeHoldInvoiceMethod.create(10000L, "payment_hash_abc", "hold invoice", null, 7200L, 144) + assertEquals(NwcMethod.MAKE_HOLD_INVOICE, request.method) + assertEquals(10000L, request.params?.amount) + assertEquals("payment_hash_abc", request.params?.payment_hash) + assertEquals("hold invoice", request.params?.description) + assertEquals(7200L, request.params?.expiry) + assertEquals(144, request.params?.min_cltv_expiry_delta) + } + + @Test + fun testMakeHoldInvoiceDeserialization() { + val json = """{"method":"make_hold_invoice","params":{"amount":10000,"payment_hash":"abc","description":"test"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(10000L, request.params?.amount) + assertEquals("abc", request.params?.payment_hash) + } + + // --- CancelHoldInvoice --- + + @Test + fun testCancelHoldInvoiceCreate() { + val request = CancelHoldInvoiceMethod.create("payment_hash_abc") + assertEquals(NwcMethod.CANCEL_HOLD_INVOICE, request.method) + assertEquals("payment_hash_abc", request.params?.payment_hash) + } + + @Test + fun testCancelHoldInvoiceDeserialization() { + val json = """{"method":"cancel_hold_invoice","params":{"payment_hash":"abc123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.payment_hash) + } + + // --- SettleHoldInvoice --- + + @Test + fun testSettleHoldInvoiceCreate() { + val request = SettleHoldInvoiceMethod.create("preimage_xyz") + assertEquals(NwcMethod.SETTLE_HOLD_INVOICE, request.method) + assertEquals("preimage_xyz", request.params?.preimage) + } + + @Test + fun testSettleHoldInvoiceDeserialization() { + val json = """{"method":"settle_hold_invoice","params":{"preimage":"preimage_xyz"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("preimage_xyz", request.params?.preimage) + } + + // --- GetBudget --- + + @Test + fun testGetBudgetCreate() { + val request = GetBudgetMethod.create() + assertEquals(NwcMethod.GET_BUDGET, request.method) + } + + @Test + fun testGetBudgetSerialization() { + val request = GetBudgetMethod.create() + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"get_budget\"")) + } + + @Test + fun testGetBudgetDeserialization() { + val json = """{"method":"get_budget"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- SignMessage --- + + @Test + fun testSignMessageCreate() { + val request = SignMessageMethod.create("Hello Nostr") + assertEquals(NwcMethod.SIGN_MESSAGE, request.method) + assertEquals("Hello Nostr", request.params?.message) + } + + @Test + fun testSignMessageSerialization() { + val request = SignMessageMethod.create("test message") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"sign_message\"")) + assertTrue(json.contains("\"message\":\"test message\"")) + } + + @Test + fun testSignMessageDeserialization() { + val json = """{"method":"sign_message","params":{"message":"Hello Nostr"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Hello Nostr", request.params?.message) + } + + // --- CreateConnection --- + + @Test + fun testCreateConnectionCreate() { + val request = + CreateConnectionMethod.create( + pubkey = "abc123", + name = "My App", + requestMethods = listOf("pay_invoice", "get_balance"), + notificationTypes = listOf("payment_received"), + maxAmount = 100000L, + budgetRenewal = "monthly", + ) + assertEquals(NwcMethod.CREATE_CONNECTION, request.method) + assertEquals("abc123", request.params?.pubkey) + assertEquals("My App", request.params?.name) + assertEquals(listOf("pay_invoice", "get_balance"), request.params?.request_methods) + assertEquals(listOf("payment_received"), request.params?.notification_types) + assertEquals(100000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + } + + @Test + fun testCreateConnectionSerialization() { + val request = CreateConnectionMethod.create(pubkey = "abc123", name = "My App") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"create_connection\"")) + assertTrue(json.contains("\"pubkey\":\"abc123\"")) + assertTrue(json.contains("\"name\":\"My App\"")) + } + + @Test + fun testCreateConnectionDeserialization() { + val json = + """{"method":"create_connection","params":{"pubkey":"abc123","name":"Test App","request_methods":["pay_invoice"],"max_amount":50000,"budget_renewal":"monthly"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.pubkey) + assertEquals("Test App", request.params?.name) + assertEquals(listOf("pay_invoice"), request.params?.request_methods) + assertEquals(50000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + } + + // --- Unknown method --- + + @Test + fun testUnknownMethodReturnsNull() { + val json = """{"method":"unknown_method","params":{}}""" + assertFailsWith { + OptimizedJsonMapper.fromJsonTo(json) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt new file mode 100644 index 0000000000..21680a2197 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ResponseTest { + // --- PayInvoice Success --- + + @Test + fun testPayInvoiceSuccessDeserialization() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"0123456789abcdef"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("0123456789abcdef", response.result?.preimage) + } + + @Test + fun testPayInvoiceSuccessWithFeesPaid() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":100}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("abc", response.result?.preimage) + assertEquals(100L, response.result?.fees_paid) + } + + @Test + fun testPayInvoiceSuccessGuessWithoutResultType() { + val json = """{"result":{"preimage":"0123456789abcdef"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("0123456789abcdef", response.result?.preimage) + } + + // --- PayInvoice Error --- + + @Test + fun testPayInvoiceErrorDeserialization() { + val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"Not enough funds"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code) + assertEquals("Not enough funds", response.error?.message) + } + + @Test + fun testPayInvoicePaymentFailedError() { + val json = """{"result_type":"pay_invoice","error":{"code":"PAYMENT_FAILED","message":"Route not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.PAYMENT_FAILED, response.error?.code) + } + + // --- PayKeysend Success --- + + @Test + fun testPayKeysendSuccessDeserialization() { + val json = """{"result_type":"pay_keysend","result":{"preimage":"abc123","fees_paid":50}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("abc123", response.result?.preimage) + assertEquals(50L, response.result?.fees_paid) + } + + // --- MakeInvoice Success --- + + @Test + fun testMakeInvoiceSuccessDeserialization() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","invoice":"lnbc50n1...","description":"test","payment_hash":"abc","amount":5000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("incoming", response.result.type) + assertEquals("lnbc50n1...", response.result.invoice) + assertEquals("test", response.result.description) + assertEquals("abc", response.result.payment_hash) + assertEquals(5000L, response.result.amount) + assertEquals(1693876497L, response.result.created_at) + assertEquals(1694876497L, response.result.expires_at) + } + + // --- LookupInvoice Success --- + + @Test + fun testLookupInvoiceSuccessDeserialization() { + val json = """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("incoming", response.result.type) + assertEquals("settled", response.result.state) + assertEquals("hash123", response.result.payment_hash) + assertEquals(1000L, response.result.amount) + assertEquals(1694876497L, response.result.settled_at) + } + + // --- ListTransactions Success --- + + @Test + fun testListTransactionsSuccessDeserialization() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","invoice":"lnbc1...","amount":100,"created_at":1000},{"type":"outgoing","invoice":"lnbc2...","amount":200,"created_at":2000}]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.transactions) + assertEquals(2, response.result.transactions.size) + assertEquals( + "incoming", + response.result.transactions[0].type, + ) + assertEquals( + 100L, + response.result.transactions[0].amount, + ) + assertEquals( + "outgoing", + response.result.transactions[1].type, + ) + assertEquals( + 200L, + response.result.transactions[1].amount, + ) + } + + @Test + fun testListTransactionsEmptyResult() { + val json = """{"result_type":"list_transactions","result":{"transactions":[]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.transactions) + assertEquals(0, response.result.transactions.size) + } + + // --- GetBalance Success --- + + @Test + fun testGetBalanceSuccessDeserialization() { + val json = """{"result_type":"get_balance","result":{"balance":21000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(21000L, response.result?.balance) + } + + @Test + fun testGetBalanceZero() { + val json = """{"result_type":"get_balance","result":{"balance":0}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.balance) + } + + // --- GetInfo Success --- + + @Test + fun testGetInfoSuccessDeserialization() { + val json = + """{"result_type":"get_info","result":{"alias":"MyNode","color":"#ff9900","pubkey":"abc123","network":"mainnet","block_height":800000,"block_hash":"hash","methods":["pay_invoice","get_balance"],"notifications":["payment_received"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("MyNode", response.result.alias) + assertEquals("#ff9900", response.result.color) + assertEquals("abc123", response.result.pubkey) + assertEquals("mainnet", response.result.network) + assertEquals(800000L, response.result.block_height) + assertEquals("hash", response.result.block_hash) + assertEquals(listOf("pay_invoice", "get_balance"), response.result.methods) + assertEquals(listOf("payment_received"), response.result.notifications) + } + + // --- MakeHoldInvoice Success --- + + @Test + fun testMakeHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"make_hold_invoice","result":{"type":"incoming","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("lnbc...", response.result.invoice) + assertEquals("hash", response.result.payment_hash) + } + + // --- CancelHoldInvoice Success --- + + @Test + fun testCancelHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"cancel_hold_invoice","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + } + + // --- SettleHoldInvoice Success --- + + @Test + fun testSettleHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"settle_hold_invoice","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + } + + // --- Generic Error Response --- + + @Test + fun testGenericErrorForGetBalance() { + val json = """{"result_type":"get_balance","error":{"code":"UNAUTHORIZED","message":"No wallet connected"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("get_balance", response.resultType) + assertEquals(NwcErrorCode.UNAUTHORIZED, response.error?.code) + assertEquals("No wallet connected", response.error?.message) + } + + @Test + fun testGenericErrorForGetInfo() { + val json = """{"result_type":"get_info","error":{"code":"NOT_IMPLEMENTED","message":"Not supported"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("get_info", response.resultType) + assertEquals(NwcErrorCode.NOT_IMPLEMENTED, response.error?.code) + } + + @Test + fun testGenericErrorForMakeInvoice() { + val json = """{"result_type":"make_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Spending limit reached"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code) + } + + @Test + fun testGenericErrorRateLimited() { + val json = """{"result_type":"pay_keysend","error":{"code":"RATE_LIMITED","message":"Too many requests"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.RATE_LIMITED, response.error?.code) + } + + @Test + fun testGenericErrorUnsupportedEncryption() { + val json = """{"result_type":"pay_invoice","error":{"code":"UNSUPPORTED_ENCRYPTION","message":"Use nip44"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + // pay_invoice errors go to PayInvoiceErrorResponse for backward compat + assertIs(response) + } + + // --- GetBudget Success --- + + @Test + fun testGetBudgetSuccessDeserialization() { + val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals(50000L, response.result.used_budget) + assertEquals(100000L, response.result.total_budget) + assertEquals(1700000000L, response.result.renews_at) + assertEquals("monthly", response.result.renewal_period) + } + + @Test + fun testGetBudgetNoBudgetLimit() { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.used_budget) + assertEquals(0L, response.result?.total_budget) + assertNull(response.result?.renews_at) + assertEquals("never", response.result?.renewal_period) + } + + // --- SignMessage Success --- + + @Test + fun testSignMessageSuccessDeserialization() { + val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"sig123abc"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("Hello Nostr", response.result.message) + assertEquals("sig123abc", response.result.signature) + } + + // --- CreateConnection Success --- + + @Test + fun testCreateConnectionSuccessDeserialization() { + val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"walletpub123"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("walletpub123", response.result.wallet_pubkey) + } + + // --- GetInfo with extended fields --- + + @Test + fun testGetInfoWithMetadataAndLud16() { + val json = + """{"result_type":"get_info","result":{"alias":"AlbyHub","methods":["pay_invoice","get_balance"],"notifications":["payment_received"],"lud16":"user@getalby.com"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("AlbyHub", response.result?.alias) + assertEquals("user@getalby.com", response.result?.lud16) + assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) + } + + // --- ListTransactions with total_count --- + + @Test + fun testListTransactionsWithTotalCount() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","amount":100,"created_at":1000}],"total_count":42}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(1, response.result?.transactions?.size) + assertEquals(42L, response.result?.total_count) + } + + // --- Transaction with settle_deadline --- + + @Test + fun testTransactionWithSettleDeadline() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"ACCEPTED","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settle_deadline":800000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(800000L, response.result?.settle_deadline) + assertEquals("ACCEPTED", response.result?.state) + } + + // --- Error responses for new error codes --- + + @Test + fun testBadRequestError() { + val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Invalid invoice"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code) + } + + @Test + fun testNotFoundError() { + val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"Invoice not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code) + } + + @Test + fun testExpiredError() { + val json = """{"result_type":"pay_invoice","error":{"code":"EXPIRED","message":"Connection expired"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.EXPIRED, response.error?.code) + } + + // --- Null/missing result --- + + @Test + fun testResponseWithNoResultOrError() { + val json = """{"result_type":"pay_invoice"}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + // Should still deserialize since result_type is present + assertIs(response) + assertNull(response.result) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt new file mode 100644 index 0000000000..8010589356 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag +import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TagsTest { + // --- EncryptionTag --- + + @Test + fun testEncryptionTagParse() { + val tag = arrayOf("encryption", "nip44_v2", "nip04") + val result = EncryptionTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("nip44_v2", "nip04"), result) + } + + @Test + fun testEncryptionTagParseSingleScheme() { + val tag = arrayOf("encryption", "nip44_v2") + val result = EncryptionTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("nip44_v2"), result) + } + + @Test + fun testEncryptionTagParseWrongTagName() { + val tag = arrayOf("other", "nip44_v2") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagParseTooShort() { + val tag = arrayOf("encryption") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagParseEmptyValue() { + val tag = arrayOf("encryption", "") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagAssemble() { + val tag = EncryptionTag.assemble(listOf("nip44_v2", "nip04")) + assertEquals("encryption", tag[0]) + assertEquals("nip44_v2", tag[1]) + assertEquals("nip04", tag[2]) + assertEquals(3, tag.size) + } + + @Test + fun testEncryptionTagIsTag() { + assertTrue(EncryptionTag.isTag(arrayOf("encryption", "nip44_v2"))) + assertFalse(EncryptionTag.isTag(arrayOf("other", "nip44_v2"))) + assertFalse(EncryptionTag.isTag(arrayOf("encryption"))) + assertFalse(EncryptionTag.isTag(arrayOf("encryption", ""))) + } + + // --- NotificationsTag --- + + @Test + fun testNotificationsTagParse() { + val tag = arrayOf("notifications", "payment_received", "payment_sent") + val result = NotificationsTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("payment_received", "payment_sent"), result) + } + + @Test + fun testNotificationsTagParseSingleType() { + val tag = arrayOf("notifications", "payment_received") + val result = NotificationsTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("payment_received"), result) + } + + @Test + fun testNotificationsTagParseWrongTagName() { + val tag = arrayOf("other", "payment_received") + val result = NotificationsTag.parse(tag) + assertNull(result) + } + + @Test + fun testNotificationsTagParseTooShort() { + val tag = arrayOf("notifications") + val result = NotificationsTag.parse(tag) + assertNull(result) + } + + @Test + fun testNotificationsTagAssemble() { + val tag = NotificationsTag.assemble(listOf("payment_received", "payment_sent", "hold_invoice_accepted")) + assertEquals("notifications", tag[0]) + assertEquals("payment_received", tag[1]) + assertEquals("payment_sent", tag[2]) + assertEquals("hold_invoice_accepted", tag[3]) + assertEquals(4, tag.size) + } + + @Test + fun testNotificationsTagIsTag() { + assertTrue(NotificationsTag.isTag(arrayOf("notifications", "payment_received"))) + assertFalse(NotificationsTag.isTag(arrayOf("other", "payment_received"))) + assertFalse(NotificationsTag.isTag(arrayOf("notifications"))) + assertFalse(NotificationsTag.isTag(arrayOf("notifications", ""))) + } +} diff --git a/quartz/src/commonTest/resources/bip39.vectors.json b/quartz/src/commonTest/resources/bip39.vectors.json new file mode 100644 index 0000000000..cf02fbb922 --- /dev/null +++ b/quartz/src/commonTest/resources/bip39.vectors.json @@ -0,0 +1,124 @@ +{ + "english": [ + [ + "00000000000000000000000000000000", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + ], + [ + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + "legal winner thank year wave sausage worth useful legal winner thank yellow", + "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607" + ], + [ + "80808080808080808080808080808080", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8" + ], + [ + "ffffffffffffffffffffffffffffffff", + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069" + ], + [ + "000000000000000000000000000000000000000000000000", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent", + "035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa" + ], + [ + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will", + "f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd" + ], + [ + "808080808080808080808080808080808080808080808080", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always", + "107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65" + ], + [ + "ffffffffffffffffffffffffffffffffffffffffffffffff", + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when", + "0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528" + ], + [ + "0000000000000000000000000000000000000000000000000000000000000000", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8" + ], + [ + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title", + "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87" + ], + [ + "8080808080808080808080808080808080808080808080808080808080808080", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless", + "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f" + ], + [ + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", + "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad" + ], + [ + "77c2b00716cec7213839159e404db50d", + "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge", + "b5b6d0127db1a9d2226af0c3346031d77af31e918dba64287a1b44b8ebf63cdd52676f672a290aae502472cf2d602c051f3e6f18055e84e4c43897fc4e51a6ff" + ], + [ + "b63a9c59a6e641f288ebc103017f1da9f8290b3da6bdef7b", + "renew stay biology evidence goat welcome casual join adapt armor shuffle fault little machine walk stumble urge swap", + "9248d83e06f4cd98debf5b6f010542760df925ce46cf38a1bdb4e4de7d21f5c39366941c69e1bdbf2966e0f6e6dbece898a0e2f0a4c2b3e640953dfe8b7bbdc5" + ], + [ + "3e141609b97933b66a060dcddc71fad1d91677db872031e85f4c015c5e7e8982", + "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic", + "ff7f3184df8696d8bef94b6c03114dbee0ef89ff938712301d27ed8336ca89ef9635da20af07d4175f2bf5f3de130f39c9d9e8dd0472489c19b1a020a940da67" + ], + [ + "0460ef47585604c5660618db2e6a7e7f", + "afford alter spike radar gate glance object seek swamp infant panel yellow", + "65f93a9f36b6c85cbe634ffc1f99f2b82cbb10b31edc7f087b4f6cb9e976e9faf76ff41f8f27c99afdf38f7a303ba1136ee48a4c1e7fcd3dba7aa876113a36e4" + ], + [ + "72f60ebac5dd8add8d2a25a797102c3ce21bc029c200076f", + "indicate race push merry suffer human cruise dwarf pole review arch keep canvas theme poem divorce alter left", + "3bbf9daa0dfad8229786ace5ddb4e00fa98a044ae4c4975ffd5e094dba9e0bb289349dbe2091761f30f382d4e35c4a670ee8ab50758d2c55881be69e327117ba" + ], + [ + "2c85efc7f24ee4573d2b81a6ec66cee209b2dcbd09d8eddc51e0215b0b68e416", + "clutch control vehicle tonight unusual clog visa ice plunge glimpse recipe series open hour vintage deposit universe tip job dress radar refuse motion taste", + "fe908f96f46668b2d5b37d82f558c77ed0d69dd0e7e043a5b0511c48c2f1064694a956f86360c93dd04052a8899497ce9e985ebe0c8c52b955e6ae86d4ff4449" + ], + [ + "eaebabb2383351fd31d703840b32e9e2", + "turtle front uncle idea crush write shrug there lottery flower risk shell", + "bdfb76a0759f301b0b899a1e3985227e53b3f51e67e3f2a65363caedf3e32fde42a66c404f18d7b05818c95ef3ca1e5146646856c461c073169467511680876c" + ], + [ + "7ac45cfe7722ee6c7ba84fbc2d5bd61b45cb2fe5eb65aa78", + "kiss carry display unusual confirm curtain upgrade antique rotate hello void custom frequent obey nut hole price segment", + "ed56ff6c833c07982eb7119a8f48fd363c4a9b1601cd2de736b01045c5eb8ab4f57b079403485d1c4924f0790dc10a971763337cb9f9c62226f64fff26397c79" + ], + [ + "4fa1a8bc3e6d80ee1316050e862c1812031493212b7ec3f3bb1b08f168cabeef", + "exile ask congress lamp submit jacket era scheme attend cousin alcohol catch course end lucky hurt sentence oven short ball bird grab wing top", + "095ee6f817b4c2cb30a5a797360a81a40ab0f9a4e25ecd672a3f58a0b5ba0687c096a6b14d2c0deb3bdefce4f61d01ae07417d502429352e27695163f7447a8c" + ], + [ + "18ab19a9f54a9274f03e5209a2ac8a91", + "board flee heavy tunnel powder denial science ski answer betray cargo cat", + "6eff1bb21562918509c73cb990260db07c0ce34ff0e3cc4a8cb3276129fbcb300bddfe005831350efd633909f476c45c88253276d9fd0df6ef48609e8bb7dca8" + ], + [ + "18a2e1d81b8ecfb2a333adcb0c17a5b9eb76cc5d05db91a4", + "board blade invite damage undo sun mimic interest slam gaze truly inherit resist great inject rocket museum chief", + "f84521c777a13b61564234bf8f8b62b3afce27fc4062b51bb5e62bdfecb23864ee6ecf07c1d5a97c0834307c5c852d8ceb88e7c97923c0a3b496bedd4e5f88a9" + ], + [ + "15da872c95a13dd738fbf50e427583ad61f18fd99f628c417a61cf8343c90419", + "beyond stage sleep clip because twist token leaf atom beauty genius food business side grid unable middle armed observe pair crouch tonight away coconut", + "b15509eaa2d09d3efd3e006ef42151b30367dc6e3aa5e44caba3fe4d3e352e65101fbdb86a96776b91946ff06f8eac594dc6ee1d3e82a42dfe1b40fef6bcc3fd" + ] + ] +} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/github_amethyst.html b/quartz/src/commonTest/resources/github_amethyst.html new file mode 100644 index 0000000000..b57d66d036 --- /dev/null +++ b/quartz/src/commonTest/resources/github_amethyst.html @@ -0,0 +1,7868 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub - vitorpamplona/amethyst: Nostr client for Android + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ Skip + to content + + + + + + + + + + + + +
+
+ + + + + + + +
+ +
+ + +
+ + + +
+ + + + + +
+
+
+ + + + + + +
+ + +

vitorpamplona/amethyst

+
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+
+ + +
+ + +
+
+
+
+ + +
+
+ +
+
+
+
+ +
+ +
+
+
+
+
+ + +

Folders and + files

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Name + +
+ Last commit message +
+
+
+ Last commit date +
+
+
+

+ Latest commit

+
+   +
+
+
+

+ History

4,428 Commits +
+ +
+
+
+
+ +
+

+
+ .github +
+

+
+
+
+
+ +
+

+
+ .github +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ .idea +
+

+
+
+
+
+ +
+

+
+ .idea +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ app +
+

+
+
+
+
+ +
+

+
+ app +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ benchmark +
+

+
+
+
+
+ +
+

+
+ benchmark +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ commons +
+

+
+
+
+
+ +
+

+
+ commons +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ docs +
+

+
+
+
+
+ +
+

+
+ docs +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ + +
+
+
+ + +
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ git-hooks +
+

+
+
+
+
+ +
+

+
+ git-hooks +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ gradle +
+

+
+
+
+
+ +
+

+
+ gradle +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ quartz +
+

+
+
+
+
+ +
+

+
+ quartz +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ spotless +
+

+
+
+
+
+ +
+

+
+ spotless +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ LICENSE +
+

+
+
+
+
+ +
+

+
+ LICENSE +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ README.md +
+

+
+
+
+
+ +
+

+
+ README.md +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+
+ gradlew +
+

+
+
+
+
+ +
+

+
+ gradlew +
+

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+

+ +

+
+
+
+
+ +
+

+ +

+
+
+
+
+   +
+
+
+   +
+
+
+ +
+
+
+
+
+
+

+ Repository files + navigation

+ + +
+
+
+
+ + Amethyst Logo + +

+ Amethyst

+ +
+

+ Nostr + Client for + Android

+ +
+

Join the + social network you + control.

+

GitHub downloads + PlayStore downloads +

+

Last Version + JitPack version + CI + License: Apache-2.0 +

+

+ Download and + Install

+ +
+

Get it on Obtaininum + Get it on GitHub + Get it on F-Droid + Get it on Google Play +

+
+

+ Supported + Features

+ +
+

+

+
    +
  • + + Events / Relay + Subscriptions + (NIP-01) +
  • +
  • + + Follow List (NIP-02) +
  • +
  • + + OpenTimestamps + Attestations + (NIP-03) +
  • +
  • + + Private Messages + (NIP-04) +
  • +
  • + + DNS Address (NIP-05) +
  • +
  • + + Mnemonic seed phrase + (NIP-06) +
  • +
  • + + WebBrowser Signer + (NIP-07, Not + applicable) +
  • +
  • + + Old-style mentions + (NIP-08) +
  • +
  • + + Event Deletion + (NIP-09) +
  • +
  • + + Replies, mentions, + Threads, and + Notifications + (NIP-10) +
  • +
  • + + Relay Information + Document (NIP-11) +
  • +
  • + + Generic Tag Queries + (NIP-12) +
  • +
  • + + Proof of Work + Display (NIP-13) +
  • +
  • + + Proof of Work + Calculations + (NIP-13) +
  • +
  • + + Events with a + Subject (NIP-14) +
  • +
  • + + Marketplace (NIP-15) +
  • +
  • + + Event Treatment + (NIP-16) +
  • +
  • + + Image/Video/Url/LnInvoice + Previews +
  • +
  • + + Reposts, Quotes, + Generic Reposts + (NIP-18) +
  • +
  • + + Bech Encoding + support (NIP-19) +
  • +
  • + + Command Results + (NIP-20) +
  • +
  • + + URI Support (NIP-21) +
  • +
  • + + Long-form Content + (NIP-23) +
  • +
  • + + User Profile Fields + / Relay list + (NIP-17) +
  • +
  • + + Reactions (NIP-25) +
  • +
  • + + Delegated Event + Signing (NIP-26, + Will not implement) +
  • +
  • + + Text Note References + (NIP-27) +
  • +
  • + + Public Chats + (NIP-28) +
  • +
  • + + Custom Emoji + (NIP-30) +
  • +
  • + + Event kind summaries + (NIP-31) +
  • +
  • + + Labeling (NIP-32) +
  • +
  • + + Parameterized + Replaceable Events + (NIP-33) +
  • +
  • + + Git Stuff + (NIP-34/Draft) +
  • +
  • + + Sensitive Content + (NIP-36) +
  • +
  • + + Note Edits + (NIP-37/Draft) +
  • +
  • + + User Status Event + (NIP-38) +
  • +
  • + + External Identities + (NIP-39) +
  • +
  • + + Expiration Support + (NIP-40) +
  • +
  • + + Relay Authentication + (NIP-42) +
  • +
  • + + Event Counts + (NIP-45, Will not + implement) +
  • +
  • + + Nostr Connect + (NIP-46) +
  • +
  • + + Wallet Connect API + (NIP-47) +
  • +
  • + + Proxy Tags (NIP-48, + Not applicable) +
  • +
  • + + Private key + encryption for + import/export + (NIP-49) +
  • +
  • + + Online Relay Search + (NIP-50) +
  • +
  • + + Lists (NIP-51) +
  • +
  • + + Calendar Events + (NIP-52) +
  • +
  • + + Live Activities + & Live Chats + (NIP-53) +
  • +
  • + + Inline Metadata + (NIP-55 - Draft) +
  • +
  • + + Reporting (NIP-56) +
  • +
  • + + Lightning Tips +
  • +
  • + + Zaps (NIP-57) +
  • +
  • + + Private Zaps +
  • +
  • + + Zap Splits (NIP-57) +
  • +
  • + + Gift Wraps & + Seals (NIP-59) +
  • +
  • + + Zapraiser (NIP-TBD) +
  • +
  • + + Badges (NIP-58) +
  • +
  • + + Relay List Metadata + (NIP-65) +
  • +
  • + + Polls (NIP-69) +
  • +
  • + + Moderated + Communities (NIP-72) +
  • +
  • + + Zap Goals (NIP-75) +
  • +
  • + + Arbitrary Custom App + Data (NIP-78) +
  • +
  • + + Highlights (NIP-84) +
  • +
  • + + Recommended + Application Handlers + (NIP-89) +
  • +
  • + + Data Vending Machine + (NIP-90) +
  • +
  • + + Inline Metadata + (NIP-92) +
  • +
  • + + Verifiable file URLs + (NIP-94) +
  • +
  • + + Binary Blobs + (NIP-95) +
  • +
  • + + HTTP File Storage + Integration (NIP-96 + Draft) +
  • +
  • + + HTTP Auth (NIP-98) +
  • +
  • + + Classifieds (NIP-99) +
  • +
  • + + Private Messages and + Small Groups + (NIP-17/Draft) +
  • +
  • + + Versioned Encrypted + Payloads + (NIP-44/Draft) +
  • +
  • + + Audio Tracks + (zapstr.live) + (kind:31337) +
  • +
  • + + Push Notifications + (Google and Unified + Push) +
  • +
  • + + In-Device Automatic + Translations +
  • +
  • + + Hashtag Following + and Custom Hashtags +
  • +
  • + + Login with QR +
  • +
  • + + Bounty support + (nostrbounties.com) +
  • +
  • + + De-googled F-Droid + flavor +
  • +
  • + + Multiple Accounts +
  • +
  • + + Markdown Support +
  • +
  • + + FHIR Payloads + (kind:82) +
  • +
  • + + Decentralized Wiki + (kind:30818) +
  • +
  • + + Embed events +
  • +
  • + + Image/Video Capture + in the app +
  • +
  • + + Local Database +
  • +
  • + + Workspaces +
  • +
  • + + Infinity Scroll +
  • +
+

+ Privacy + and Information + Permanence

+ +
+

Relays know + your IP address, your + name, your location + (guessed from IP), your + pub key, all your + contacts, and other + relays, and can read + every action you do + (post, like, boost, + quote, report, etc) + except for Private Zaps + and Private DMs. While + the content of direct + messages (DMs) is only + visible to you and your + DM counterparty, + everyone can see when + you and your + counterparty DM each + other.

+

If you want to + improve your privacy, + consider utilizing a + service that masks your + IP address (e.g. a VPN + or Tor) from trackers + online.

+

The relay also + learns which public keys + you are requesting, + meaning your public key + will be tied to your IP + address.

+

Information + shared on Nostr can be + re-broadcasted to other + servers and should be + assumed permanent for + privacy purposes. There + is no way to guarantee + the deletion of any + content once posted.

+

+ Development + Overview

+ +
+

This + repository is split + between Amethyst and + Quartz:

+
    +
  • Amethyst is a native + Android app made + with Kotlin and + Jetpack Compose. +
  • +
  • Quartz is our own + Nostr-commons + library to host + classes that are of + interest to other + Nostr Clients. +
  • +
+

The app + architecture consists of + the UI, which uses the + usual + State/ViewModel/Composition, + the service layer that + connects with Nostr + relays, + and the model/repository + layer, which keeps all + Nostr objects in memory, + in a full OO graph.

+

The repository + layer stores Nostr + Events as Notes and + Users separately. Those + classes use LiveData and + Flow objects to + allow the UI and other + parts of the app to + subscribe to each + Note/User and receive + updates when they + happen. + They are also + responsible for updating + viewModels when needed. + As the user scrolls + through Events, the + Datasource classes + are updated to receive + more information about + those particular + Events.

+

Most of the UI + is reactive to changes + in the repository + classes. The service + layer assembles Nostr + filters for each need of + the app, + receives the data from + the Relay, and sends it + to the repository. + Connection with relays + is never closed during + the use of the app. + The UI receives a + notification that + objects have been + updated. Instances of + User and Notes are + mutable directly. + There will never be two + Notes with the same ID + or two User instances + with the same + pubkey.

+

Lastly, the + user's account + information (private + key/pub key) is stored + in the Android KeyStore + for security.

+

+ Setup

+ +
+

Make sure to + have the following + pre-requisites + installed:

+
    +
  1. Java 17+
  2. +
  3. Android Studio
  4. +
  5. Android 8.0+ Phone + or Emulation setup +
  6. +
+

Fork and clone + this repository and + import it into Android + Studio

+
+
git clone https://github.com/vitorpamplona/amethyst.git
+
+

Use an Android + Studio build action to + install and run the app + on your device or a + simulator.

+

+ Building

+ +
+

Build the + app:

+
+
./gradlew assembleDebug
+
+

+ Testing

+ +
+
./gradlew test
+./gradlew connectedAndroidTest
+
+

+ Linting

+ +
+
./gradlew spotlessCheck
+./gradlew spotlessApply
+
+

+ Installing on + device

+ +
+

For the + F-Droid build:

+
+
./gradlew installFdroidDebug
+
+

For the Play + build:

+
+
./gradlew installPlayDebug
+
+

+ Deploying

+ +
+
    +
  1. Generate a new + signing key +
  2. +
+
keytool -genkey -v -keystore <my-release-key.keystore> -alias <alias_name> -keyalg RSA -keysize 2048 -validity 10000
+openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.jks.base64.txt
+
+
+
    +
  1. Create four Secret + Key variables on + your GitHub + repository and fill + in the signing key + information +
      +
    • KEY_ALIAS + <- <alias_name> +
    • +
    • KEY_PASSWORD + <- <your + password> +
    • +
    • KEY_STORE_PASSWORD + <- <your + key + store + password> +
    • +
    • SIGNING_KEY + <- the + data from + <my-release-key.keystore> +
    • +
    +
  2. +
  3. Change the versionCode + and versionName + on app/build.gradle +
  4. +
  5. Commit and push. +
  6. +
  7. Tag the commit with + v{x.x.x} +
  8. +
  9. Let the Create + Release GitHub + Action build a + new aab + file. +
  10. +
  11. Add your CHANGE LOG + to the description + of the new release +
  12. +
  13. Download the aab + file and upload it + to the PlayStore. +
  14. +
+

Using + the + Quartz library

+ +
+

Setup JitPack.io + to your build file

+

Add maven + { url + 'https://jitpack.io' + } to + settings.gradle at the + end of repositories:

+
dependencyResolutionManagement {
+  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+  repositories {
+    mavenCentral()
+    maven { url 'https://jitpack.io' }
+  }
+}
+
+

Add the + dependency

+
+
implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1')
+
+

+ Contributing

+ +
+

Issues can be + logged on: https://gitworkshop.dev/repo/amethyst +

+

GitHub + issues and pull + requests here are + also welcome. + Translations can be + provided via Crowdin +

+

You can also + send patches through + Nostr using GitStr + to + this + nostr address +

+

By + contributing to this + repository, you agree to + license your work under + the MIT license. Any + work contributed where + you are not the original + author must contain its + license header with the + original author(s) and + source.

+

+ Screenshots

+ +
+ + + + + + + + + + + + + + + + + +
FollowFeedsChatsGroupLiveStreamsNotifications +
+ Home Feed + + Messages + + Live Streams + + Notifications +
+

+ Contributors

+ +
+ + + +

MIT + License

+ +
+
Copyright (c) 2023 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.
+
+
+
+
+
+
+
+ +
+
+ + +
+
+ +
+
+
+
+

About

+ +

+ Nostr client for Android +

+ +

Topics

+ + +

Resources

+ + + +

License

+ + + + + + + + + +

Stars

+ + +

Watchers

+ + +

Forks

+ + + +
+ +
+
+ + + + + +
+
+

+ + Packages + +

+ + +
+ No packages published
+
+ + +
+
+ + + + + +
+
+

+ + Contributors + 58 +

+ + + + + + +
+
+ + +
+
+

Languages

+
+ + + +
+ + +
+
+ +
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+

Footer

+ + +
+
+ + + + + © 2024 GitHub, Inc. + +
+ + +
+
+ + + + + + + + + + + + + + + +
+ +
+
+ + diff --git a/quartz/src/iosTest/resources/nip44.vectors.json b/quartz/src/commonTest/resources/nip44.vectors.json similarity index 100% rename from quartz/src/iosTest/resources/nip44.vectors.json rename to quartz/src/commonTest/resources/nip44.vectors.json diff --git a/quartz/src/commonTest/resources/nostr_vitor_short.json b/quartz/src/commonTest/resources/nostr_vitor_short.json new file mode 100644 index 0000000000..ffb46ce7f1 --- /dev/null +++ b/quartz/src/commonTest/resources/nostr_vitor_short.json @@ -0,0 +1,13261 @@ +[ + { + "content": "Do you know which relay has your old info? ", + "created_at": 1690301795, + "id": "fc0e838994bb66a8249aea78e883c6e98f98b93296fb5209e9e9bab54477fe3d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "326a0f267def66d2733ae0d90fcc8cb52de711e5e93561fc90bcd9291f4cad00f135fa65470a52a1dfa3e758595a0057d6c3407f791f052836a263a95fe0f85f", + "tags": [ + [ + "e", + "e414229203fa467f99b4a20f84584e4389b9d22ae7203556d0c0da9012ac321d", + "", + "root" + ], + [ + "e", + "0eb3cb5cc57e25bf08590da5fcd7370b30b74ed2e3fcf4e909d7c03c4d3b7dbc", + "wss://nostr-pub.wellorder.net/", + "reply" + ], + [ + "p", + "eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05" + ], + [ + "p", + "d9dba0e072bdb353dfb0020de159126af47e69e133ea91bbd48e8bede37320e2" + ], + [ + "p", + "eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05" + ] + ] + }, + { + "content": "Some relays still have your old profile.\n\nIf you were using a given relay set in the past, changed your profile there (so all of them got updated) then removed a few relays from the list and updated your profile again, the relays you removed did not receive the update. \n\nSo, if apps are using those relays, they get your old version. ", + "created_at": 1690301420, + "id": "4ef323e0e32b6025b5e7c59e78f4ed0145805fbab9b95247357a10379ede375d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "83caf220a6da34754d147f844f1d56ae61020bb14d2bab38b0498b94037c0ee153121f4e0c99e8be353f096cf72bfcaa6a04b5bacda50df06ffb96faa63b3357", + "tags": [ + [ + "e", + "e414229203fa467f99b4a20f84584e4389b9d22ae7203556d0c0da9012ac321d", + "", + "root" + ], + [ + "e", + "e428765c0b240bb6fc8baa027db1252c0a98ca5bb822bd81f763730374a0be0c", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "d9dba0e072bdb353dfb0020de159126af47e69e133ea91bbd48e8bede37320e2" + ], + [ + "p", + "eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05" + ] + ] + }, + { + "content": "Not that I know of. No bounties as well. \n\nIt doesn't look like people have any interest in this. ", + "created_at": 1690301261, + "id": "d5cce4e3b7a6cf4d2fec27cecb12e8e7f71951fa0fceef56fdf1b834c382843c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a4c39738640bbd6bfd10b949f4f641b964a539977bffe5c4028bb549fba99f32737b2605936979f76745fe3b9db7f1be5b083b0b74b50b8717d3dc5b855ba04c", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "root" + ], + [ + "e", + "e673f98336da0b6884567adcf72dd6b4d86ab16eb036c1c6d6b859110b9233a6", + "wss://nostr.mom/", + "reply" + ], + [ + "p", + "eff0899a8d3e8ed7d7524b86f5a7077c1ec39ee305c191738b29b0bbfa20fe42" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "qZOB2WyD9zuBMmN1tNBhojLDTNJtmsUOIqg2Ld86lyLioL3UDNiTT7jr1D0UdxVuRuGrwaLG4dKDedXWx3s5Bo2CKDBJ0w6fEE43TCqP88yZMjjDwjqLN+a8O1/aKEgP?iv=rwL3fBSM4pvyyMJZpWVGcQ==", + "created_at": 1690298384, + "id": "48a4acebd543263bd867fa41754dcb443c7816a91cd200dce977dc6e6d269060", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9341cdb84ec7338fcf579abfaa484489aeb8858b97c3c8399d0dd4ee26a7932109b4660f9cca5809b8023a714dc38a15c745a2de51792f3773df10382405ec8c", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "AhyW0o7KjGgdrLZcI866xQ==?iv=oVtmzuGFBaBQ616f1JzXBg==", + "created_at": 1690298365, + "id": "c2763bebb1521a0d34a331785bf6d6a4f267f2fd093e4240cf309aad439efcad", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "834c99b2bdaa2a974b44907c2c8457c7ca63c929bf1f19fad960f8abacfb3f1d9b064c918ed4e7f326c88fa80c8b5d7cd85c8e675fac299206a5b9fa75baedec", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "{\"f412192fdc846952c75058e911d37a7392aa7fd2e727330f4344badc92fb8a22\":1690289907,\"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5\":1690289924,\"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb\":1688330562,\"d9185164dbaf1e36a856b8f8759f8f4fb35942da22bac7afc131c910314b1f6c\":1688750583,\"645681b9d067b1a362c4bee8ddff987d2466d49905c26cb8fec5e6fb73af5c84\":1688750587,\"89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c\":1688750589,\"4ff652622cbe22d93e3a0ce2487e86736a9a209724a7328c59bc29b064a42926\":1688750592,\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\":1688750595,\"9989500413fb756d8437912cc32be0730dbe1bfc6b5d2eef759e1456c239f905\":1688750599,\"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322\":1688750600,\"339e87a4ad5b32a7fa88c6a56a65a53bcfbea8595a86b5d87201a39b0408c29c\":1688750603,\"975d0ba9c395954e0d06a801aa27bbd7a631d9a9a8a461df191328cd1505419c\":1688750604}", + "created_at": 1690289924, + "id": "63407a41d95e8e78c33b0d88a329331968a1b6a8f0f3c8c5d1a5b0761d2d5923", + "kind": 30078, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "98e4157674a0fb090a91ffeacc8cecfc5f17df26310252b7bce000cf4e7aade32ba3611ad3ab6a9988fe0c0e951f64a1135d9ce0a07de24187b3a8ab2e5276c3", + "tags": [ + [ + "d", + "read-mark-map" + ] + ] + }, + { + "content": "TL4qxgYec/DKRDTc1GIS0utLHiswcT/c03jZvtSimCECbf1LQBKUlmgRUwQJqgAhmrFDjYD88goSVYyR+OkZ/K2mIC3K8sbYrnvkk93KA6k=?iv=Idl20mo4wuDM+NPMfnvayw==", + "created_at": 1690289826, + "id": "8556d3f5a7bb23f79f5749aefc75b4febd17877fd525ff9b5afab65854e17908", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "49edc54a022ece3f2826f347793042745be713bd4841efa1883dfa4fb35281d429aed42d2e1606077c2cb50dab1e9efd41510e16cabde309bcf57381e7926e5a", + "tags": [ + [ + "p", + "ad5eb2f02a967459a97d041a44cac14cdffe0394ca6fae48b7b354eb1c4fae18" + ], + [ + "lamport", + "6360" + ] + ] + }, + { + "content": "UKV5cwhYK7gCH5bnwoAwkQRrqmCMCfJMBeFzZw9qsco=?iv=aiK4ONfev1EUOMrB7cJ8hg==", + "created_at": 1690289810, + "id": "6fb5ba413998496d6efc3ad69e7673d91320327f6a54b3c049695f995d532194", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "aa039c210a32e919d1ccb7dbb4434265aac723f898a1729222cc7eaeee48e49399c92e67528ffd20c1a863783c64bdb1a60e8de56d4de6429f41c0b03993cae0", + "tags": [ + [ + "p", + "ad5eb2f02a967459a97d041a44cac14cdffe0394ca6fae48b7b354eb1c4fae18" + ], + [ + "lamport", + "6359" + ] + ] + }, + { + "content": "Yep. The amount of memory rotating in the background when you scrool is quite large. We are constantly working to reduce it, but a battle ", + "created_at": 1690289456, + "id": "ff08047596ad0eb7be11cdf66789b4982728d3522838aa7c79b0f173d9196e28", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "38c554b203d4128ab15681d149307cadd250a705f43cfe2c49c4bb454c0a82900ca6371aa214ab1e8e58af5bd47438f6d688b519bcd42cb6b23acf083d6893bf", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "s5pf5nJ1HZSJtnJye6/gZT2H1Ksq0ZOddenz1xOCD76rVJlKpGiXibeIBcGmmJ+M?iv=lgE2cTIHfsdJp2RIFA9kCA==", + "created_at": 1690289261, + "id": "9f48eaddca03a88dd02257a51fffdfc6e58847691e6449b46eda116826d3ee32", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3765866916e6b9ee662b28d81d570265f0e116d5aeab50aad4d2ed503c938a5c4261f6e5712c7dc8e63cc5bfa6f86efad3a0572c285104299fd9945e82555b93", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "uDIFFjFeuxLWlBZ2QBqcxf6GnfFxadrtdwciH7laVOx85iQ9XAOrDbJaOWIllu95KxXuI7SK8bttv66qQjCQ+Q==?iv=47N/FrAOij7INTY45GngLw==", + "created_at": 1690288560, + "id": "29e0784695da2615a4a83a5e55036c9e749cd4cf98324d6b4e49aa20ac3927db", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5ce3335af0da098ae483bfb758ff4c4ac355bf82721cce3587edb9912440044be28651b1a2f72fdcf05561c221c4e1c9f70bce5e4098e6095ca5cffa5a6d0e4f", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "NwfzYhOIbMzbc6dDEbnWWhgPEz89V2smVNkOMdc20k5QKjQWvE83O1UGviFWquSsgGv3GQrqCCi4EW9IQbhXvg==?iv=1xfL0/+De1Tl0PVON5sTMw==", + "created_at": 1690288549, + "id": "294991508f9fe42234c3937d85db77478389a28a2e1c8817c7be7189dd452fdc", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0894f96fb95f158e727189cb3d4e0bdf59b4b4ac065e8972e7b44e94b0816a8330fd8175ee2a52260231fecc81b5429c4a3418f926bc5c548c10e110468d67f2", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Uisi4OKioPOCqI/A0gDNKynLNV+ej0aNCY70P/vjgu21hbMRD1DCw/N7sRO7PeeE?iv=QNIoWu01HPHSdRUdeQYf+w==", + "created_at": 1690288547, + "id": "0600aa6b0e40e5057d6ff85b4d0eefd86bf661a109a0ea5e30a616f8a7509eb9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e1726e0ca65b41dbd13d6c46248d6beb9f7d28eb8a03a50c6f7ff55cee77c85fc7209a2306fda5b849c5f3127420c862d5849b0c5695d48acbb21c5ac651f8ed", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690288395, + "id": "ac2fb0c9b72a6fefe60262fbce6eb8740380b7f964200cb8efdd2e72fcb1ddb0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6df6aeef98c21a0508a788cbe6a2a6825e5cc69e57dd843dc6e6d4ce2c28dd042947723fda3d8b24489305d86b1e81f9f66b390d6f7dabf9742cd3775e17e53f", + "tags": [ + [ + "e", + "2cc5b6e012c5a64fcf580fc1b53bed25ed0d7f785fd896744524b1a114dcc86e" + ], + [ + "p", + "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720" + ] + ] + }, + { + "content": "tRYUKww0df3YH/uVQVDVuZMuxAwVlPxkCInhfjLdaJcaLOaP4nanBVJSiN/Mwd4TqDRIiENmB0VFvfICFVuSC9JWVRA5RXKwhNUvJA8lFhqyYZKJ7hu7vIv/pBsYmoodZkmIOm1k62krq6xcnMab9l5AeqgAGyWmPOM0nZM+B5LFp9s5iqc5uly52wpT4HFrbNEO7Tnp7yg5Zzrrl6wwUcmvgMf9ZdJHUusgy0y449WOglVIZI7CouMgjH4tfxbTr7jpZEb+Bl8PPMBRylEV0qniBEd46TAYTHZW7QOJRnSP18vMfESf+kjM9KbiQJhyOY8FKIMJ2/ofUjCiOlWhlME29JykleKz70KmsrJNA0bQH3MfzQ0M9qBdvSdpD0pPIRc9hFmAmPuSOQpb1k/+lF+lcNQc2SPdj3NOS0L6BialMn8fwWtpuBHFtdRJIj5WrHpgnmXxeZ2ES40KZUwA6izlW+YOYI8RtlkqxsrmOWg=?iv=diRnj/9ih+6dFgT9qGMIdg==", + "created_at": 1690287796, + "id": "856d60b6545084c4d8a450d8f079dddcb3bcff54aed5df0d84eb63aec1f9ce6e", + "kind": 30078, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e7e2a040ec7c4fa8a931005b3fb2880c927107de970165d4869a403a2b3fcadd858ca4ddbfe6305d15b208fbafa15f0341217af5cb6f965aa4194c9b8a2c06a3", + "tags": [ + [ + "d", + "coracle/last_checked/v1" + ], + [ + "client", + "coracle" + ] + ] + }, + { + "content": "I am not raising money because I can't make the numbers work for investors. But we do take donations to build the team. :) ", + "created_at": 1690287547, + "id": "8a359c03413c06340f034cde37528c4a0bf49cc2b42e571ee806c86abe93ff1b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a08449b21dc283a0effc8a0ee4ad3254ed1905e0e5f53963b101a927d05cd8b7b5718d04ec9909e34986d743e5db272b0541b6b86b1749696f22883b2afad7ef", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "root" + ], + [ + "e", + "4705531ded4c3dff81ff3435c28f2112cc789593fc6afe6d9ad2335b2619245b", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "ohhh good catch. ", + "created_at": 1690287166, + "id": "ffd37a3e6504bb5171ba0c201019628dea6d282b12c71e3a7e35e20ae4de538c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8cbf2035954db635d65b039d549c36f2111a6e8c5c831db5e5a3eed0260009462660e8d0d3923b48141f1b35366854890a3fbda91e1360d242175cecf0a96a7c", + "tags": [ + [ + "e", + "56887d01eebd3727ffc37ca54eb67b6e8427db4b03e7c003683931f5cbde82c2", + "", + "root" + ], + [ + "e", + "8154235a2796bdab6c7c0cc0777a8cc6fb6bdcb19607a5d1f901d02494648743", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ], + [ + "p", + "de14fe62f97e09429581f9e8fec3170f3ce5e7936a2134bf70c87c5ff229e53a" + ] + ] + }, + { + "content": "We don't have server. The main issue is that now the app uses up to 1GB to keep all the content the app needs. Most old phones will definitely struggle with that level of memory use. ", + "created_at": 1690286996, + "id": "5b3a569ab61dc0d88da2a93564de9da3e6f42c8479bb93a522370501ecd08140", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "515101902a9168031260d2cc889bef1a1e6b4e499397e7cfba08799b7503b81cbf3e4a9147a0cbd4cc9fcc252a66330c428e9a6ae4dd6751c551cf9dc1cd6c5e", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "reply" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "+", + "created_at": 1690286957, + "id": "0c97361806dd60f9b6f5a0ffaa25da846d5e4d4717287551530b665db9d39302", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3770d5924fb47cbad974ea6be874dab133a90176e3a62502aff7c8a1ad32ed39a29387c88a1270f78a2d25e01cb926f6fde2b0f3a63fc2356b26e5295b03c3ce", + "tags": [ + [ + "e", + "4705531ded4c3dff81ff3435c28f2112cc789593fc6afe6d9ad2335b2619245b" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "I am not sure why you would say so. Any event can be part of a community, including long-form content. ", + "created_at": 1690286892, + "id": "f65330aab9339f7c814b763a571c02a2c00de96e321e803a09e08140ec941138", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5f391228b9a47d83978a304f0505a16b594ffe48fcb872e8bd69489557306b38cf926603f955e0ed742baabf59dcafd2934919cea4c600e285eebf239d42dfc0", + "tags": [ + [ + "e", + "9dd8ff6c397f19373006037c894b02f35da2b69ac12f62ec008889c9571dbd08", + "", + "reply" + ], + [ + "p", + "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720" + ] + ] + }, + { + "content": "Well, Primal and others don't load everything we do. They just offer the main feed. That's the main difference. Amethyst can use up to 1GB of memory in heavy use. Many light compose apps will work just fine. They are definitely heavier than other UI frameworks for 2+year old phones but the issue happens when we try to add the all components we have on the screen. ", + "created_at": 1690286508, + "id": "07bdab60f70082150f6b50c8f9166244f7fefa4087b95409328c595cd17dce53", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "993391d5757ae264346c8db656d4fcfdec396a63c780d012e767a608087a673ceb262b64a9c0bd12ff18140a1d80b79645759e019c13a969e681092e6c0537c2", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "Probably because your are following a hashtag or a community. Those show up in your feed as well. If so, there is an identifier of the community or hashtag in the first row, together with your name ", + "created_at": 1690285153, + "id": "aae6aa51e943bdd8f9f09b2babbbf02397ca2c7e9c99dfa0042876ded4073ef1", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3b79f668ec10f8fd8d2c7ae7a0a075c57e764440a4d0cb92b8a823941418180af07577f6fd46b9b8459c6cb64151176abea129261af861468fd44ecebc566847", + "tags": [ + [ + "e", + "7e374164ec625c01d4efce693a8d7a13225fd87bbf4a439390fd995ba077b038", + "", + "reply" + ], + [ + "p", + "2ebcd815589195d4fe00e1996323d41c3a455641d3065d7b82e68e574f0c0f48" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690285082, + "id": "6b4c1249dcbebb07e1642ff72384a709ddaace107d98b34ee87028cf2ee151bb", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "71409c88d13a42740a17249bf2a5300c875b7a8fad471fc6097b5de1b9234db339c1f160d4156a661abb871a7e9f97015048735ef44b1af3c95517eaa8b143fc", + "tags": [ + [ + "e", + "69ddeaa2cbbf8954e8bfca9c58354512461de79691e0e57870b40cf13139aefa" + ], + [ + "p", + "e6ce6154325b54e69c9478c46cf1284e093f790e658b6a70f9df6eed275e3444" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690283168, + "id": "1edacc7f9f091e63b8cd59498b98cc28c997b4381a343efbe67b5f0010f594a4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0c20f045b2cf70e5479ae1fea72bd4f766eb3cf5bc62a544466d907c14f212fcac3af581d4440f4c2d99dcb98eadad206e05dfcecc984aa940a133226d2401ff", + "tags": [ + [ + "e", + "6c766ae83204902ae112190f418eafd35fc977f8b64367a13d7a526fc86bbd73" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "I am also doing a local database for the app. That will allow us to delete most of the unused local cache. ", + "created_at": 1690283062, + "id": "3a7b49772ed9223c3d5ec1d0aafe05739aca6b4b0a87da760f78d820a678cfef", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bc768ccf0a6525851f30cd453bf78d1d26658422f56d08ea216132d15b11b773ff30f7612a126f14e16cf03b29b8d34b487a6d59f51802a58ff834af27617a4e", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "It could be the live data as well. Live data runs on the main thread.vwe could switch to Flow objects, which are heavier, but don't switch to main and back all the time. ", + "created_at": 1690283001, + "id": "cfb933727089ceb7735f9c7794217338f354cdd67421ee4582bbd108b8b875a2", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9c5e74d3ddcdab7244866c27a71c54d78f82a95ddb74764a42a08a18c88299d9eae6c02627a68e8bd66ea867c3425aa4b6d2e272eef28e41883aa6b805b8372f", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "", + "created_at": 1690252421, + "id": "e17e96546af73ea49281aec14d8833cdc9a6c8758eb499eca64890d5e16dff1f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "01e3edbdabf4d7612977af8de5afe3f8ee53c0fd63bc211f2b7cae0c9e04af11ef21449a260bb3945e16f6999c1975d87b6f8cd52e74284083a90716ee3814fd", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_902a3245d00d506efc193b25f25f32ee994c365bbd73a515.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c8d8226166e4c54ed87aa1151229301230a0b9936ada748ce2b02a3a2fc28cbc" + ], + [ + "size", + "844802" + ] + ] + }, + { + "content": "Are you using the latest (10.7)? It's about memory. The app is using up to 1Gb of ram these days. ", + "created_at": 1690251933, + "id": "85094e157439a0ede8ba0354b9be60aabb4d952bc3baa5838c1049f1e1f62b7d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8c43251aeb9eba5bc6a265a8060eb24ac4e699661eec156e33c1cd48678c13442d15ed3f29fdf6d3395317892d11a854853e9529dc0eb4113fd1a23723dbde88", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "jKgFvim4lNx7zC2l/93q5rHvdracu8Xk75I0FQYdEba+7pX0RonHyDQajS3aXk4iksLl0NYyf0Ky68+K6MlTRerRcFnW2bKuZIOcHqlw5sG79MOy+m3yCawj4jgPQy04OkPv8zK6H0wvQncApLFZlqPy5mzP59tW3LNkgoAoR7xG8Zbup70CGHs5LXuCD6Usxm0drk1Os5x3WtTXfssP7pRIZfTc5SSCDyVz7NHA/euXDNGcv+1Ru0CdnVQAv0Ke/WSuMGvW8ALb2hMkh2QRwZeRwQXLQnCZKkA/aK91l1TKjLR/P/cubbGJwdpQs00bdppd5OaImjGmCJnZfPBcVfi98om+9Z135G3mjSl1AiuQgVk1RpEPvQYE3yFrkWjDOq3vQSohGyoPDPzyQYW5vweFqmbp+ON21F4TQJBZ7pEwKXmF279hMi6vqttmsAuzLzKmmLXnUxgzI4jDHShJrAdjt/FQNsVb2JFzqOAWYVQnvxSTYhGS2wLc1/vTNkHcVWsIBoAGtWrPA5VBI6zQmI/CzmczPAOiTK+l/k+LyluKeKK7hR2GFL6dUmopswOTiSQHAHuJ7CtmwT0GnKFKvBzjWgeQ5u7H7W/jVfC1zB+GxZfa3auo2kCSIg8YfwE3mkKEZ9tns6cQ3Tgoag5csfGD8U3dJ3Cpa8VmTUXtn7italkQFOp5UUeqtBVKo0mYSMJg5uxf+ZC6P1oRsAl9g7La6Rc3t9DpkVB8mTS6d+0mwaKEUnn3NJbbzd4pecO4LOF1zid8At4gQQSUrUug79Vc0VmwYfltd1njirgMrDWZuM4G4RcgYYQ3i7ZfsP+fo5PZ8kVKBO+S8bNpIf2GWolaGi7IncYh9/PJkz1dn+cmpBwlBbAmKTUjxwaJErlBi8YvnYeSEL0o35d7YYEwW6rkgz8oJfsqSguBMWgJHnMLkzSWbwpZuB202dp9olsYOVjPyQKwSvgbukqtQxDqRDHg0gr48tS7WSZJXhI1gETLKg2QTgE6hgXGQhNhPAE22AT0AhT7YsWks/6+wuc6AFEnH+/PnjuR3N89G1hTjV6W/8B4wQdYBeHxCwWlNnCfXUIZnaKN7PXGfXJdWUl3RLXO9+Sr3CqVHjW7GrOEmEjGu1/iUehcxnk5Mfz1vNbnPVSe0n9dyBEoZYFi6+Tkk9MWBzUenniPIJkoFMR5vn5z98GlhxYCafG6SdXkZDUkHjnp2kDBP6+h8LSA+qwa2BxPyGhSleN9NQWC82PRYVtVtI1w3l3gCqnXLyQnidHY5fkkL8gmqUOgyT+FgAT/O6+a7mhyvOvYZLZh2Mt0iM5p+/15d90VTq+mC9eltDdBsa4esj/bxIe0rhGOlvcB4IMhYRKTJ3pDvTizdH7Ox2jmXl/kLIbAEsAzC45CnJJo6TP5KjzeJa0kJu6MGvuE34VjsGWiOb7Q9CTlsvBlptA7n5WhXEjA42/yd2E0Qdbp/vTQZ+lHnOLn3QTCG771btfc5hXep2Oenmw85Quqzin8lgxuEU9ysWLD7Kwk1U1sI3p70dL5m2EU8xWm/dx74pAU3RjjwCBWZcwGwoEPde0FAl7NSTaudZh8BJ6p6BW4GP/zlHAVS+6RwOhDdeRFBOGhuVSUc6ehTYCXiRdYOS1famN/bGK3NlzP/1m2qJ3tfiZ31wXJoBWoy4O+DxnPkjNUhk8sr2uHDU+ILu3Ut5D4fLXepKYZDI/2r1qxxkf1ZuoDYO+mz+FBGG6Onf3CE184Vmgp7ajjCJ+wa1FP14utnfj0wQA2Cn7G/Fy+pBAbrJXiUDAOPXKt9iXwni46yY7Akfov5vVPnL059YEGVByfwDBDccTIkcJL1uc5qnLxdSZiQyjKjhH5v0V8x2z+TxhR02luWJnSVHLo7ltRpkSLehNAuTC0Pou5HUeVZ/LmVZWc72NINUlGDvJ/hYJf3I2ozMrffebSHXX9B34fTX16MCS/KhbAqVwk/ucV59fkmKOuic2sSAdJAV8f2r22jy3lGR4Z1L0SJj8k0PZUq3HgGw0m9lWaJ4xmS4BjAzuzjpJ/UoWXe4k8zJRKWb0p736BBAYIGk5aTUBKC1AYtk0fl+avgYNstvpRpDp9LH3Cri9OD+4pxruNRf+vT5gfOo3AoDmUr186Ny261zK0WdvO1UMfELDYDsBXyWNOq4ZuNGjwKTMQbMK9TlZ+fu+lPrzNIhoW5Lo9QqyHdKjnjaK6D44CLJQELb7/x5bEe0LFAlX3K2jby6/8DHJy/WjQtlixBba7G6wNeAtI5FZ3m4uYvCFK7hA51c2oxDbYu2QOmsWfqdQmUP5Cn9gWXIEGWkXnu4W7bxBQk5H6fB3QxE5YNgnC3UGkxk8vWHnkYGnVhzfXI/LPcQLi6/8lEdLiOcjQLbdoQckkvgXjOucoL3Kcy5nYrAz1Z266ksLrSvd01fw6uswVRrB9ltWxk06J4PSeoXUm/FOhaTZB0mia91geu8VBDXl+nHhINbbQ6FAuGLB7dDJx8KDW9PbTVnXBQJ2M1WvR2oNSxVsMmhiL6ojYJGLQG2sB8fCQPwyOZE93K+cCeQN8ensuxYvJliMaGlxzb/9wrGd2BsgIK2lRrYCZLv7P8a02m/BD78ekYGVT1At81u/d34OYS5vtOV6dkCfDRaCM0t+aT/JxusTF/jCHpjV/Mt7lAGS17I45GwkXIudaJNwUEDmaAO0TUkGtMBYphdbp9mhu6Iw6dFTzS2VeYbpiDkKmhhwI5/F+9dzlo7ffIQDJAl8ih7NrW/SJzs4cpSGupTQ3975bw2aYleforZMgg50kcjC8FJxDaygwZtxOZ7Ux3f6kceIbftpAx3u2KCZg/xa+sM45rokkceUoBp+8gNerJjAhY0pTfaCYFoNAGiYaxH+qi6Y7GWCKJLt2aVPQEjmvzbExPGohGieJsPRblfL2cCUj1/JNRdC+EOGZtSOyfWDCCS+tIU/3eWiHYXVtQjbyoBKiR08kLBzFnyYRtncplnWnVc8WbbXs4WABQbHR3ITwLuOx7qLZIvUCG8F8ZI3tLgdWEfALBFnLkhioJsDMPSER/YT00zQZb4U7lrjyqD8bCz4WtWi3NXYJ9cKydoc7LD63h4YIeoWHKXo4BXEzyeN4V4DrPaZ9EjbwkKqfzZke2KsOV9bo1TN+X7DmMxStEW5kT7D4NUlpJRH0LD4zMuZpOUr88eX3rsUCSA8Pbj9qU1R513LKmE4gvzpBwgDcINqNQGGT5YBfYpThiE+osR3dsR7xoN0aSmmHg3jG3vDS9VapaRp0v0ZsMvXVlKwvbWKSkrBb141gzewjoIs4OuKyszi/vXq3ZegbA6vLxqiP+NNHKze8wLlZAeFTB/4LZ/RAfk7WJVTfwRdxq/5khhM8FnEEtrG380KvAWwZ1iyFJoxQlZRB3tQDHf56YxI3ge10Mh0smCs1PR9LIraJDtNpp2C0L4lP7plp604rotOjdC9DkawLippygcz4vSyI92yxYm+9ve6iZcCdow0/oMY+xjDqxOJgrbKoTWKPxKYKJZky50t5tmdPYl8pFgIUASOlqiFZnve55Jb2cm+JBDlr0NnkxV1+NzOcRZxCKCW1uJcIxwje1M8VyDdPmUMqjZ7hMojWqkrYUFygpPagIAIFDBfWfDqTIMp5e0sapGEkpIewr3XQzAlJ/GYxMnwR4RQmKcxk5EPZzA+VujPWIxQ1leQc9fdykFkDjjH0rkJnUmuNGIHOjd9PLcnj9eAZe8pE+SdBVG4SH4LGoz2EGGBubOHKD+JSPwe1ePupLGLY6Peh+v67gXGFpoykP9mS6t5JZj3NCssq2F0pB4lO7nPt3eTO5eyJ6XwTq8L6y8C9ZbjqpyZq4Z/cAn7JJ1pm1vLaaqeMTXGPoS5ubDqHR86+MiJ78vWLhyImNIiOSDpBnt7afhFeiSttOvhdodRdivB3e+aOdMW/tuy/pw2WLdSYMvN4cDC0Uizv2dxcmYaXiZhNaTQuGIHl8xTj4nOnK53vDXaTLOwTFURD3IEop4KtA0xlufI/fSpuiioJLnDizClkkc8Tg/XL1juIIArTMaGkgTh5gedD7l4heEZWhuY2A97TGpPazjytXOVAI5LHI6Ey1Vvqxu+ha8gLD+fVVRbTfYFGhjTMt6RNVG0LLKndwhpJXIWrn9mG9x1KX956kHXfmqoC68ex4jDkBEPySjATpiJLgiusAzwO6EMzHsNZw3h/HKaKooF+b+teNFMzoR1lHiy6dB6dXUIkGdzttTvBn27t7DpiAwMwxMQWEqWnqVfMbiVyf6ohaeFX3P2vmXKs6Dgn0ChTa+ud1o/RmDd+mT7/ybaNdhwOEgRjipzrHY5aJnGyauU9oPd0q3RCAWp7k9UlUk+c/WTHBPGnJ+X2WG/ovx8re+j0+7VeeRlpKIy4l+koywFhWlJD30fBMhKC0k30m+dwPwEsD5MNMqGuAkjUoKKMUAizNA8EGs+fMu2+DAVD+40l80JV//EN3PEx7zB/RvRNXmNlLKijCRbM6qyPmvRPOB3ShoEpt21buskMgu75vGSt+jMpnU5AALvXDwbRKcMMrz3KXiptjU/cumda3JBsSRtxhxrDLlJHj76L6qVWOnok+zrRu2rXeKO0+1KBfRGxSPGmPLVY1e6qlvCnH8boUHmUVz+I9BzwlgTlvuMtglmav2II3hkrskp2YspPnRFenHTyum9EfL3mcjO/bMfCR3eAZ0rK+GMZprKiFxXwFvMi6nIQCKWWI3jBsC08DNgXkVUdHVSLDN0TefKxlp0iomxShQ9J8LbDI7aK/TUsORjM4pA3fBnHK/2u6bI+4QXPJggAYWm7852sF3kwdvYwoo2ag7JGJ3S/9/cqvapMfohEdiCH0aJfquysoEVEZP2TsalEAAWxi0ZmwoyMmFvblZp4Fjfc1WGGdnsG0yssOOJBxXjldGl8bCEsF5donFY/O3OyEFSMUMcIdK7uC0+acuhVeHGn2D3eGax66HAclvrOwCyzCzKUuy1iXTYbUfNohw7LKPC7mxJwDHDr5P5rS+fQGWizEUMNLlOYkRinPaCvUPOEpVkQdmMkrTohAiZKVl2EsayIrdMyPn1yhMvzHsNI9jKZnFtnZg+UqQxwyTyn0wW4PAATbvJIG2FJL//eoV4LSMlUJLFiEvzZ/V5ZyFm1rmlq6g7zJ5DbiBiN+2vroTM5+fVa6IK6BcNqVLEay0KNV6y+5PR0irNX5atZ/nPeZe7/bojOmwihr7Gf/ZTDM1wvkld3LaVhyPqfBG/qm7VBV45PSX2NYVehdS6cIvnsJY8+KDq2l1PC7xfC2yfGGupizb63tPXQ+qDBYpxkletE/Nw5topEzuf0rzB59zR4tg1inRseUM2829Ncw4qOaVuN+ngVGBNsqHw4HB6UBEYWfwONJ/vnaAqJoXP2uaeBHUTXxt/yVK/62bHQ+utTpSS6+slHDXYlnyeTKrNlss8hHNGYlym2i5vA1lyA5/wgCWN/+sD+xWHKAvFzW2X5HYXDmCWFAwXDVFGZp9It4djOxgPGVNdVDFYlkfRo1aW/xAwx8o+OmmrX0SiOx9F0ygYudCuQegzZHQ4EQtDyhOzU9nerDOUzOguSzEwdlyZ4GyeyMNMhVX9Qc/s8OCDIFIV4wWOo9R8KWbK2l7/EXkpWjggfrZoAswVpwKwrMrA74svNLKSuVly/UVfTNBf3D1aLg5IXvvr/5Y203698EkvB+OmL4Dd4X1SQD3xW6UEUrTHOmWSroJ/lxihx5SswDWj9LAerClMAulIOBwhuiv68Q00G9d/idg8qo/W+UBwtXB4ou2VS/ImqKByv4JWZaA+oUiCknmxU8afNe4iTYb0vomJE/DuZcIpumX2m/622sM9OTs0kNPmqrWw8nj7nn1b5UkCGc3Jbc9dkVv+aD4+8T97sck5l09tJY7IVcgnFUQzvPOrWOc8V825DYEbqR59/t3lNNkrxU98UCCE6888fE9l8kemB9xvXgcYmcBwEWLUjSARZy7W9HfecH2j3VCe/MLoOeia8ZAvKHHoY1yF9V2w0BjxRTH6tZyFlIEAMC0+pbs1KV9usbgRnV3hFjphwNx8gO0CKOuloXsarbEscbhl0UKVNlac8EHweccsW0SvXLAQo1isAaXStmGpTrV8ZZBGACOQyiOugBY3N0E7PmPmgMHaRx5F6AjEb+YFB7/H5rP+cc9/RShmngnkeqi69lEVuWd0Dl/rjUwLMMULeV9eW5Vc1NXsfS8ILp1OGBTuHEnHJwsMeP11lYOpx8LSeMnhNj7s+VafleReAdUW2ZfcF3JF2maU8pSUeC99Hdns6AzGs/d0hHTzhjz5frQFPmLLtMPRbARnCmVM+Hfmf5/nRRZYraT9h5Zld36AMxZV9UEbE31EqM7vd9aPb6CzVIMGv+XyzFW56hrLehg0KR1R852rBuy0dsnuwz4Gby0UlPoWILxhsRN9o910XQxpIDXx+aZ16LUpBr5nN7X24/AkNilCbrpBbxO2iDX/Ylnx2KwKnIKdQ1ounLBoNsMr6PLrk7s7RScVGDpwYuOqKbxRpHtCA5qTQ8OVNBQ50nRHftDTjjw+8svNIL8eXuqKCsbNf0JEW9ATW6zK/tpzJcnJ70Je9f2enkHoJzW1TYK07dzPf7Znb51g6ramAlkpYsmhL3uV7n2TXBEGrAi2/ajDu0ItidXAOPyEoWM40b6T640qWU9LwtDQ/AjrqxSr4DaTOV+4eS+pqZKQLZN5tqQnUpK7VRbHZcM0ej1zL9hZqjXF8Pr6ySNeeJTzJshlnv+rGlO6NC+JI8PIuwp6jQicV/O4CqpdwLpmlis5nsmP6tfsb1xHhqd7Vdg1VDBkqqAYIpOS/4w2zgXCGb/T4AEEQXWrVMOwxsFz2mQd9gWK5H6vD0iq8ZL5XCK3ZHrsc8VWqu+X9mUvp2TAecOEUVvK49E15E1YwUww3gzgwU1H2cLsSY3rorMCMoKm6+uhK9opuys8O/A1bePTNXH316EOCU5Q8zBB4QN2PU2CDovOrIYLmiFuZwSyed58nJP0Cy88OmE25+zY7Ou/8mkhOItS5/lAK92LElgv1lR26C1YUCOulpQhc3qZYj0iOlaoP6aBhmT6O8Npf85GLzgDKwN6/1D+22O6yiH1VTCjvE6CVIbsCsaUTs5sY+3p3NWwe2jAnuamHUX39kAYfJIjhOBv61IloBWPtw++ygfuu2uUeS5MTWEEhnT48n1Z6zJMExpUTqSvU6mxYVDrUNlnPsIHUxlR0+/GPP1msweejeJnfoVV5dG7D/jIxJ0NRuYOLttlNwBFEPC/mOAiyc6Q1MdSx+P9SVH6nQOudB+IrHPDtToFKq4z6PE9gbckfOvttlODQDHQHp92AtK8JvQoxD9aA+e27UALwOHjXzUIpy5UcZGbH8rs0WSRE1wWIjP5rBMCilOhAB5tLi3b42sZ3N2qQqIU6b1kbcMiLw7VWXxbLiCpWUHXVHO0CE6ETzrs70vR45PPsMh/Yw/bn2xNuGBwwSk523cPmtsC9pnLGit2elA5FkXAzxXj5bCIhSMFC5C2whYi/VepK8RRcZDrohCNLbSKtIUTkV+42nWv8q+2HHb15QzY8lIxqrMC6zXfxj8iVZSfudu5EyvMP7cP8VdLD6GAT1Rxav+C3JwOA+iE+4gOe8mqQqDBjuwuvcC5g4mczSxL6r56tLVKSioNd0WreWQNu5JkmjHQqUtYIQRrLVtUN9u2HTpPY29C/dORTtd66HIPdBzFTVSkcTYlnZ626g/vzFChpq471aTZutyTTwpSUqovogIw3gkDrwqB0cXRKJORdQKguCjkw0dl3LYA07PrL31/hFZGVuJYNBmRTpN8lj78M/IFwMv1WMMNjo9e+P55/ruw3837HWg7icnCuuPtNh8xV6V50tabrsHXIYSjmpQS/6IH1p2/WtMkfEUrKF/c4tcZb42PB3c21d3DCnIMF3fHohX8XtTd+QG1ZWqdAM9OlLi685MRUY1wpMb+Woq0gp4YzP0gqFsQK04chxH6nfVCEgj6Q5N/QD4Ypy1hBAbzgqbWwKQ2E1iFb2nL6hCWaGEWxiglD1KGkgiP5ha37mMWGMPyPJq4mG+OsMZVI3y4+p+vFS2RXmc1pKMCIrtzqqpf/YDxHNIqKbGpj0TykSHPJCq4uReQMeaRk1tNFzv2vafgct65omdog7crddG8tzuRPOBTEQ2oYcSDU+11t0i3ild1hAyKtWNHxCSXsQvakHimWQGqneABJmAnuxCI6954J6c7C7+TacBdHJXtwssf+5mlqMPMpmvOQF4GTCEPhAJlNoXgWpdvyc+ZkD91qhTugDu5RBSdLXGmqsG9+sPEe/DNc0wlmSPzOOS4lGOjAuHMWZPyDiUD0FCef9qKTzJRcsMebFWud8V9sAzviPv5Q2KOtOUHEW2bfy5GrEgRhqRU/c4BTmgMGTrc8fG0OuAR803WLuXhpPQ0Zkhr9h2S9hOzKPUhzUahdWK25PURZfqP9TfCA+vHMd7v4inSTHvFYTqM/b/EQ0d2KPNv5TcQpUzgvVJXCZOyQ/3YRbVyVyRprKGUNPfFGp4KEc8kVtNjr02EvQoD+ICYGx+LU/UPxkNOlgBcqVoK8pbvwkCwIvI1WD85uLI3Yhvk0KHOOhk9VoeCPFkQvRjquFXA9RBjoRT5Yno/sWUJEUlun7No+xiQ72y0kNghzjTxXpwLYqK5r+8uTNqQEhCEyvT9kI8tdtdbEeFwf/ESf7QfB7xXGnrmuYhNJSnmx51ivXNzVmAzqR3vmZNrvSBUGlmv188s/gDG2uqczKaBo68jqmPUQNQcgGN7H4UJ6IN7jHU8l1ZHKUgKui53QT95a4wVoPPysTZQrXD2CRyH/Yu8DAfAX63Tgtxl+qKQ58cbWwvf0UB9wBG5x5l5MdJuOuMFuy4zSiut7fYWBQnXOi8S4I+at6xIC3AMmSx2vR54za/CtTG/b/tkD4b7mD+ED/TxJfxmCPcZohW6+MCLQmNtqVXh1ACHaRzf9C5J85uM1IV+qcmR6tzki0gI5WcYZlYEur+PdlcculzYyFHA8/mLrFdrVjaoKewNJvG6VKgeZ3GhagNXfuc6J18PAknNMLGWjhXOnpOUV0l3j6XNs6tiFL6Ye1UwZ3DpajV2kA2gipBfQFkWS9d/fJrqO2rdVR0gcTntyxPPrSCcAqFjbymUD0VctaJc0ZvuVNt5r90v/viq0l3SO7WuNFu+cWYmKckVhA2vON7ZDAe8/lIOQavGwMmQefYwjKT5AZwlZULJ3IWh9OcOAp0pq3Jmo5sq57vLgfxNLUVUXF+UZ8Cu4JwB0WFQZD95zK4aG5XKk/vEmv6YXYFR4IBPppTuQj5O3mS2JVYZ6WrYui6PzOFlqbdWMsiJ23XF84L02x3Qvp+BnSHlruIP+qXyH8n0SST+oOJGa201LAdvOZVpuThygd5UtJxF3xyaVzfJlQuEQqt4FYBYJgLwe3XcEL7UrLT4CMEnJciNjAQ7Ud4Lo+CMqot+4rJKF7YwJEiKSPClUO0G2Iz+FcO8U3HpG32h43YQyDpcD1dx/cxEcqvbzg4tGQssO6IBP21vHGkMxPR3EVTyIaZG+3Xudnl0kuRIeaHfVM9Jn4BYi1/lNpsxlOgyenveF3Q8qMawiwfM/vJ202Y3sMXrweCmli9++uMILZfh5L/jNlbJ67ofKQIWTr6cd2k9EbhFVtXJmeyDy9gIzUguEW7leTUEQf/4mY1UOhKDv+2sOEbnACFu2PsuGf7EFEe+lIznutu0zdThw6IpJsnm9VKjVsIQ7VxzsVDcOV0UwwaGlzGOn51drz5euVdy+EcBusUru+pETFP0YBdMOfOI5phZITdNAvhmOJHT3dR+YGwj9jBSEjByX8FXMKAtBB8z3R1YRYcS4p1l5AKdXM/AJNwDmDyI0hiqpbbZsdCJwg/KfenP7GvKhFy/wOSjkJnQpS1OhS5EAUorzq23dB3gJOA+Bk3yxTnwkiHZ30R0pAyl8+G6ArupfsrpaciyEqefXgxEntIc2eerpPqVEKJ25JkYDK4qxZEJZuY7H1vW7nzrqss4VxIaY+Y9/hly9uY45ugHU54y85jAStCmEbPeRZq+Tq6BPglib7eNFnUO2yyzVh1csaZ3wItksOhpcV/k+4KygnY/Atg6/KQvH0kH3DF+YmOuIEIhCBuAV+3RsSuSGgTc7GCLSvZIejccQehXXrk4pk3AzvgxITGNzRVEN9ASslb+ITYLZRo8lnWIhYvic1tUVWe8eeXbB+i9GtdLWxgkHHTDDV2Y80S0XBI2qyIJf5cMlCdgoCG/RXR+m4r/mBgNm/TxUgG9VJqWmPJwUJydPL2OmhKHCuyVhIiVpFGUm4jz8+8PW3pY0CBrFi3rbhol6yE6+TwGlTdZGhqR+Z/NetN5fxT68d2X7xEnEi1p+UyseSCvZhy8lMBYGvOhdqDAhe3zJbgvq44WMlj+N80ayr6AuZE3YWS5unb1mbcEX1iLaW0szVwsQMVhO2BjHFLQfQPMAgZHGmGNy0GOipyaFI0zvrrirb6JzlOxULf6I0Bi1/mPEOB3jTxaTeAgbal/8R1bRJy/tQ8cZWAHFG04RSz177DTYLeR1J1aYYOcJAQyhGLK6X3ReXB/gjTcqcllLAVIvTiKhkHytL5X1LOxkSJRdXN2PPFhDaULgDy9AscoJkRQiixfxn/zQeDqy/FZw8asb7U3lVRYvJiu6LfkNDsSMFhjNsiZpzGMuLOoFxvPIv6kh+m4vJwbuWOc+7shLOfw6k1Tc0iwSZ4ReKNNdDxf1Ir8mWFI+FkdezdXZX/1EnBeROT90DZdH0QliPVi6Y+sR1t2Jyhg0V27adwQ9+vkZqHFjkVcyYzZ9CO5hy4LUggdfs6JBXIFqHfJ6A6gK0QtE3pSqBAbxxBOuS4DyKQtNzixcFJDPztEdggO0jfUxQp3ZFOJMFw3QZG83g0dwGjt7Wi4aVjX53mUt0SHLEeM5bedSp4UDKQpXuCDtXrRv6bBjNoqIloJik1HHqswRneAAcIXPIahnQRT1jDb8cIJ+W8hGLCkvqv2tvvmnkcBKb4r+NXcUezsnIb7Uk1ovHULXIPgyE6ODCg9VNPqxdg84YPGsOBoKacXFJKgC7Zm1PCOItsksRJshAGXf/puuIaRNVTSK03Ulj59F0+/wfPbivCTasvt/FQQhY1sdN50n5opNd/RHV+bnfKO9cs2WP/3UdiuWBvnnTf5ylYP4Z4F4WDll3w5EJzRwSKcZlGdVwKFlL/fA45Xcobi84ooWMYE/E9Wro9+5Db0omdNznIKRM63zWXAU92cJZm8IBvjeK2oPMM/xGMmTb57X/n5qrjcrOZu5tM28mNnB5IGns8ZuXIt4U68/I8DKihhbK/TGfPKk4qQk1Z1gN+6ncV1SmleVAnIbrB1sD8tadTK7QP1beEE7bugLAbtzikW+FqcwiMx1sm7Bm9/Bli01ejjohzR+Sm+OxtHA06snUeTl4P/A89psD50A6a7kjvl/HDb4diYCU9IIdrFJ8gPalm2q64xTH3MxQNpR+8qpe8GxxwKRvvN2xoR/hCN++sg3P8f6urm2Ujf1Bva0QfAQUmISpWbfgbBnXTDWh/+qfyOTN3PLTacv/O/tL7EMtRDeZh6+hVruCARFegQYZhRNlWFGMwrzwq6X9y3rM3ayOW19gqKQ+j/4w8cxEld05ZbNgqFnfeCvevGNoNNuhjHKfHww2YY/PuYYTpEqa9EcbEafj6gfhEcmb5fPcZU1nQ29KAerc3CHcPeqS7MJvzW7F2bIe6buHgKfAtky2fd4/7UiUc/gTrfTfx9Bxng2COgJ4lI8jgy9txzey+Jbs5//sI4b6MLVuAddbbOkluZlVaw5lY14WA1xDAdPExfCj+PzhHA3LOIYCHYL9iFfy16z798Cwyd4fcn2EG0wyCURvigBHEur1M8sEWkWACfYJAaTZX7QrU80h6yew9FtN+peJSKRNawNivSaSW1rIawZERAyvuBkKY5JvATIz5MuwDMSqJylgY8etKi3ULgth3Gwrbi7YiXSlVXfut/oXDs99qcFIsdMpk7cNgIYYLGp1PyhX6q1BnFh0q/2F3kbhMmG3r/9jVNekXs+UfhmcovdEi6eZ4ZpfHC+2xGZ5UXS2LaFb07kdFrrA6k67XnPGMDIUnD2hW7OAytXpUrt7LiOptBojo1aFfv2Il1ECzXNrxtrNCol2XGtUbT3QY5fwkGxgEtK06fDWNytRSAZeme/SnQMvLU9MrGsJBmWmdypZMpybN2z2W9sWf/+YZj+1DY2w9G37cH7AYesGgf7dxtf/Irg1pZIbAFcECSty4dtHspzYS+ctA2RS+k92xt3l0PJlfjfMPfpQsHcxgOV/69hdoeLr41fdpraGPqQie7P6qwQ/UpRyiLev+EkW8+nwp5eDOdmKntk7W2/5KBsf6f/iZlsWMpROAj8AcWBE0hJ7aWXHhhNAQXAPoqsdC7XMfoAmFV5U1vHd310nWqB2BKCRz641P2yLZErJgjgPyvPbhA1QlQlc/1IHw8GspQFCLB8boXI0eZ/HV/BIJ3/uDupiy3snQqJZFlOy/o5V0g9GyR5Ta8vFDelC2Ae8CJhSdlJ1qDpe7ZShmdL/x6O9hJTpaj9ecwmrBEGL6KbfhiklDPJeChLb2Iz3V902UL9h3vnmicVYhJCCK57PBBbzF2nqfeX+YoK+gUieEfyDBqwh5iSJQKRLYObOpng7rdkNJUQb68gGNw1jAUCMezOUPATPKpOXjt7RgR0kvmfe7roJgxEwsMriL326CL7uMK0XDMJbjDqu3CHFkvRCx6Qxl9kpU8QAHl0eCwN5tcFFDx6Vwm6mpfGcPmLguY2JwzdNytNh/+befNNJbOguzlJGSYiw2F/FiaThx2iXPzPeqmNjVohoNSDyGsBUhpAeSzSUSPgLsZPOt+6U0ofUW9PKWUtJWlzzYjG5Hln2ZILc1rkzKQwHCS21cl8PiielYec4hgNoEboGBT9eo+Zk51hVgg/p2G8VuisZhRIJ64pL8Nq3Q/e+C1qKL2Dj//YW4hh7T/ErZ8xTO3bsTtthfDpNyD9hhcyOCeQdStGkO+eupO1QPzMWj7nJNQbu5F4Ld+Tf/K4KeuUJVmkTTDE9NiEr+Z+ptjKAym+x5XHexhawvI+w48KC7F2Ps0eDwqgvLIi03yooHjTRdafukIu5h4xAStrwROB7ivcpjQTKDl1C3V+UN6EX5ErKRhutQ3xzonOmoZyfv4Qw1E+7pYFTkq62T2NKE2psN3nf1RuSbQT/7rlp8DgnMe3bfU9NTXUKt+Lp6zu9vhNkQCiQBWQVy9IMtkbnXMzlrAi9/Jz05IbRzZFk+uy3LeVWzADXPIiMiTcRbIP4C+EG/g9e9GDG2h3txtbWXDUN7Uoa4njIQywccQ92tVIwtpJKvzkkh3eUPWYx0rwm0kYvZq+RBZib7evfdPQgzKdZvMzxwuuDEuVdvpD0qqgwGPMDA3rFRNuVKTeuO6zQslOqF6k87eLKZgavA1TS9BDIdbDPuhfiUx3Akc4CJfXVOkskaqxbt+o7nfWUQMO/ebeG9Bfp+CFBRoJ0FGWnwFkJiehVB7uJzH4Xa7uELfwIpPU8Y+DVBMtKgZCn+F2RoNJGeu7ZnpH3XiK1rwioerOVU+lpVu6+lx1lnvW9qW94A5CKOVNMEPk7LsnnJqmbxrLBL0YFWTx30dgtmElqZSGbOPC5wppl0A3VbZm8MStlfLVKW8lVw7UcnxphUhMMYZzQbbrrFsvRDpL87L05Oc1GLhkbMKoFpTq5mL2n5bNp3BwzkL6L2BnsOhGG7Oo0bIk+qEXme5KDGIPekOwWZlYe3nM7YYE75wez089OsT/FBT754FJbX0jdHDpQmUBzxslSQE+2k2xhT3CKVF9GVsnxUrht7ouIZeyT7SCW8lj4x3S8u1wUghupZvaURr4WBok0yho4oXbcaSvZYj8pB+0qE9EEQzEjTMDYFmawTT+l1XUFWGfh1I1aps+s2nD582xE+eBlsrVFczdRLqlB9i6nTtGXFj3LAOe44S9ZLFsaKj9qpsyc3aQd8I2Ygz0iuoYa3PhG4tBOVTCw/+ASvD4LH8ligvhXF1nBipvmi16XdmW6dkMRqaMzS1OXTUHMOygrVMv3ozHJsRkR41bj0TzGkbFgWeVOUZx/lO21n4HR9yXfFKM0ty2MdQq2unRwxXp7xkSJkciznmoOF9OA7p9WLmyyRJwuyXrZg5odais+4Ls7NhUWGmJONviMP4Xc4n9zFtXJMHg3HanoeiTdHJCzDBgI5ZdkaYdNNkVJ8flbISdvcUieOPNrAYaflJTFwVtUIETYGWJqMOpuxw9GrCZfu5HDu5hmva/9yoiLvEmyeEj7KD9u+eIxLJI9Q6uOIoD1F08eL0Ffhg9E1AkMykHd4K8H4OmznN/tdxycsCt4k7fp1UREVOljMCspAp194ys7xh0IyejbL4PrUCYV36/H9EPlbFd9NwPtxwPdirWn4ZTsgOJK0YsTkqdU5DUzDMzjKAm4k8zSjlJ1wjnY1u3ndX8fqEZScGlw0W+wm1FW7KvDSI+G1AslzzG6N6oyRwEBGFts+bISpFtDY45UeZgAjNpoH+bUPoHuS3dBZAOAzD0fKvsTm8NHmYVC1aXkaZdEDeS1Z2/Z8lQ6k5L/xWqIQW+gc3sorwmkO/VF/Qlt1D+edyLAtaLtk2JHPVZmkjXWhhngqZAFl5a10Nara8/nOXfJULsR5FHvbptHcX7zfe8KgqLFJYtN7FrwMCVaDwLeFgwCYkv+XpgjO3Stfqey1XmuT+oNMrXUhxG8IdH2/SugDTDWdRtk9CvOJYIre8w+dJbmSRFek+zca51wmz9YRzlEjR83m9PrE+7G1GbYA9GSEuCh6ffFMDs4Ef3X9F6DVFZXOQCnnZlbqEYUys+lOx149XbiEedZWICSCOBxI1Pl2pvWJUdXtts6Hl6K48r3W0Ea7RVN/QfTmp6pmff9ts3uTXfVhBpRdJxf3DBPzOAsJhe9JcpoS2cmRv8ttBR4H1zOXN8em4U6wLDmSYD7M3vKc5Y7cZUPNPDPhRP0bE9+W8c4GqOmiWRIS7x+UcnAGG9gVVXjDOT7SWjPIrVg1sMhvQ6WcZCRWdyZEkRsavT2Rm2f4Uzc2ghMpy0KWsOzQsbUlUrGceb1nBvF0v66Gwu1CD1Bs79Txuxv8ssdI1DVWb/mBJZJY0vF8enLgVUqdqrVEBGZ3oQ12IrDt8vimlQ3VtCebADFDQ7WiI0Rby7i33EcJfr4WtscuA0kANloKzvpHDNWOvFQ7leyXR+BWhz82Aq+HeKvLy+N/ggfOMJd2pIudy2upUh0zUoOCNOznhuuz2aZJYvzWmt7Yan9/rGNPWqKNIl4GOxRXSQ+LLK8hgqmyOY5jNDE88nSzCAj7eM8mcrmju68TRhWcctn7LIdPcNklpEu3KnnuynRYa1aLe5UpAigHLEG5zhlwgKuBsny48jcitQn/ypGb7kY9BeRfM+r69m6YHoXucU1yNXfu/EigamdrhPrOrE5PFf6SAhOZQHl7fUZ9mrmV/84ZR76afaBvS/N9BW0bJn5RGkTTpzJcQfWM8j9iS0MXhf3hr3vShDavuOXzb0wvlw0ynoEry9RPP2kBJadvek6JWAAwVSg1L7KlQJF3v5WWCvnCqxNGlKCWiANYEeOInGlq29kG82S/q8+bnwvhUZmjPcDBskizC4j52bCRwSIi0y3KJU03V1ORoUt59aCHgMQTGLROZIT2T0Roba+GpiM5v4vIM6w6ovCM2Yy2ctavIWEI2kGt4GvRrZYs6Hak+NVaoojkyXtsbKw/mL34YfCYCQini0jFiSnJ1ocDPuP4EyJJOVqEWJXCXfPrn93hByMycxXzD5jXxBeZOu+6YCI1cyeURqM2uxqchAz1A+jqRVa40h9fMVguUT7/pgTYjoBOMD0RAS1GeFP9id5P6O8m8D9Z2blMjS6SEOBg/neY3csui/Gc77uyYshoHb6RL0T0P6TFm01D7WNGS5P0+JHvDEgM78isxxFFkqUXQy8TSdoxSldT8yHT4MCgify7TcDolRwM4oFcBPitoHljuRHGxp7v35qm92vamU11jKUSDHFanN7U/hvij6MXDolfgqEKW/stemDpJ5K0wDlS9fLC0bX1Irc7vMnnkz5S/i/wMm3/bjsec+aAgSMSWzBnP3kq5Ry+4/xkARd1g7ZbD4pt4iCa1/zcEgKytZwym6qS2qUhh5Y3JZdjlehypdKCPB9icWSnuS69s83GZ7pu7b7zsxphtBH/xKIxdyNr9VZXunnV5rgrTMCaPJRce3lfslUEBdOMQhVlcZmTYS1G29WVNvhJPABdVDG1rVzs1VmO34dHFI7IIiXtI4Wu8xrhQHLdqtKEhRD4Nz24aB09EPD5X3SoCHVt5ouZEtHKlihgeTxnw6MD+ht5EmgzVh9Xhs/4U/ep/n2SJAR8SOxwRvlOOgYl+Uo51V58Yojd8glc2z3ZYJI3XJP2VoJClRiiIzHaoFvh1/t0ZFvtY90j534T8uEGYejsogsYkLpbiBYQLufIJq5zOveIGtLoWWFXY5U6MWIHlVH8FWDUVs8R5eXgVBVBaIawGeEVz5cuQxbvPeZHgVrHQ1uCLBQmCU1Fg9KFqPb6iNJkofYVAb8zEHNcyke5TuRY4ptoJIhgEoS503BCE4GyuwxB7t185VZgVjX2xI8pcd6vvQGRVjIhwG7U8+FZNq4uHf2BoYXecPZf4lcxmz6iZDFy7tfJK05gJVJ+5yTFJsliG3fPGgFhp5ErekO6R5KdZDLGOeHWUJEX60mTYCAo0xBRYiWd9H5VNfZUyAT6Oystqkf4IfRJbzaH/SJt3jJH/FeI5rvcE936mWCZ6yOw2V7i0lkdU3PmoMcsC/W3o5D8Sxh6GM1s70ZvzZWIkZdiJ8PGPGRQANQ8RLesapnFmGVa43aU8rwyqhEiFhM/DgbJZhjLEsfMcizPM+z6F07aUasuFzoSikLcVGpHyg0wlshulw3RVUjDy2V62RZWeyTk32dwaSYUbjRQ2sfXINVMT4g00P+jNmZmu0X6gf1Q6sQk+ZjHL/3JijI0aQ1VDOu6/Tc9A5O7CRwiZ37zBXd1a8fmawS64kOy4g1c716Nid4f5YQmKkYkYRuSlCLYBnRf5mimUTW5UZz/N2xgLIXT9879wvnTTfUBwacw7os+iABeGxZ9JemsivE11ayF7lcTI8MC0tiqN4sbep/GGA9oNf3GbBkUxVclRSVi08UAaSPY9QCc+TNasDMLd4p+l1e/7o3uzxHtiFZZ/RhItJa8odFmhicM4TF4sLCsJfk3aeQWLsirqi7H4/N3oaW+u6q9IgCuuyGxT3R0Ezoxi4Yy8zWwer+NgAmc0obM1dNjJYLinScJV+mPhlUbH8oiunF+mGLQLle58Mtith3yc5mo6qn0CFVI+eAXHFa39jxAg2b7nDYXoo+OIy9XdmUsfnuanKBt3gkpSdDOXxQoX6GIYDyY4Mc+tlO7Glp1Iv5VZjN7hlKHzj70ttxFBtTreiWmlJ5P7P1rvsgaFBMRVOhhHgjDt7TaOEVxP9lZVw+Ua2KBD6fT2LfXVmsirCZnatv262szB9+pyxaVLqhBtcRhjOw558rnoqS06A9EqKecPoWnmiLgo6So8Grrf6N/sOhQsZykknmjSfTvtUGaS/YeZF8on1BN3n5nAeJkkF23LcRuYGVNMyLhfraYthps9nv7tQNcjT1RlwInY6MP9aKRAzrxi9UF/jMTQUuEh66R7RoMgD9ZkoSvxy3JgDUGdmetrdwjfeVNBnMF/2kfKT/uzdx/fOPGPzInRlEei7TPL14mqUXhdvlnrwONRfReI8hQ4cdrYUSdSszcLVZ3otONpJsgRskzzCoNI176pq52p7MciaYHoyiTlRMZ1Si43qbuhSBylEB2jjbKiAmyBjPhihHaIJ27omItHvDU5TlKyml+d5sNvjNh2xY0oIrm1DDvk273CoauIcUg9tFlVGTsyNiMGKNk5Bn43SZE40Z4xFAYT9lNXQHCsyysU9V55ogaP68dQuXutV9XpRcJ9Fa6DeHSYLncNCJrrSVoeuC7rDkkvwIr+q/1s2bWdBu+DIH7xa0bI6IzBzNjj/OF9gjGPHkU29bEmhwRdhrluK0s2hrFQt0IBZ7WdvLEcHyo5ssjthIoF5knrkhUsV8x23FYAHTh2ZqHLFn1e4oBOix+cs1HtBgOZwVA8Ei89XDk7OVNGfzYMU6VuADogHaa2Gcz8Yy35m7H0nujSje+uqGOvwMIC0QE2s+AzF8sdiliMIDnlwvYQQMNCs347AX9wOSzuR+AcfBU2czH7KIIT9jcNjjS42Mdpf/zzwweujI0seNAGov6m5k0xgKQGI6kRE2GmrajbOVwEFVDhyoRKXQXnbk0Z+ZUKI6Y5rw4TEuWpbRj9IhTbTltLz/Q9Zxtsiw0k2hQmCrnPPuR6sIE8Xb278pgYkmAl7hy4wvJ6lDVn+eJvuTa8Vo4V3CcNPixaOmG/x1uGb3Uia5FfokK3dS9LYleCcgyWfZ1o2x3JwpMxn7fJ/SY++JnNbEysfjWXBafN7cYmVsJVlXnUa3ZraPa5AO0mjPhMcCOlVPTM5f6T79WUywhN7+UX9gYFly2DxdMfuaQHD9FOpjqSUJJ+TeUILdlcEoCUitpVInvaAVyBP1amxAo9NGzcxyvH0OxdgTucCEMbnkSNO5FdvO1VqjAc+POanNFwJ2R+etflRGnmlMuMrw+uGsr5qMLn9eAM+5Qen5pW94x9pEejEewuhUTOSro1VV3fODRKlsLJzRiRVRh4SRiOjRqAmhzjlrxUJaQWVDvYrg5x1CCEJ7J5rq7GrYeqfPg2BZX0KODoewZmNnSKYo057wZtt+Krf8ei0yj1XUxCvMrPGRvYUsGClDp+0FH1fhyLtekZ4C+PjDDu2mbsdflyvJGH2Shtr8kO9EcU0ArqfWfOa9ptEPgqi6ZRZo1VCnA1TZCHDYeC4AYspu7U49PACQX4NfG6wWZ7LnLB0MJ0DFFhac4Uh4Eaav0k644S0iwINPMmSaYHX5WiLE6I12NdJecV+b5HpqnRxB+fybFO+8akYCn/Q1XSE7i9gvjGIOun+42yrI207V6D+vbwytFi8piETBBOYtj+QsAV5k+fBpATvREJjY/TriV8SbtqeIW4sE2ZJg9Z7jfa0CMcABYXCwJ5PCMgDH6oS8zEZX1mIy0ES4rJc04OT0TgcafQNNkQ2HcAmGE36iAZCUvg9UzsWanSO32YtVVtApFeoP+AAyklmUPvVBBVbGKo+GvkTMA4GLN45PvoVC7oTItjh8NUpXdSpgDpfzZtNdzH5n2HZ3oB4PhjEe4LI9QhOSpMzXpM6ZhMDEJqkxHnz2jr2Wa2yEHRiYZca1qIUv7/1JQEVX2e8Trx7Oe65m4f7at+4W6fQtBnjsqAvexcF1a8f1YJZB+Ha9zXNBVUCuivw9gUlZbj8vcwueCyKdFevXJC9IqnQ/g0XD+rItdQbcYI0WVnh51f3VPhCe1CSc5spX2IKBBVce20G+H5kaFVvpjovkvg6p+cwxshhrnq90PimNEQ5retYhxGgXCw5hug//anhUUZ+DaoM1uA3DhwDvNU+NxMx/HKovW/934AjkwQiwPseUxQajyXsWrZKd7gTGgYR6MQemsBg/F15twTgkkbVPs1CtLK/hAjhqnjeAFqtDM4R2JSJK/byGirc0aTCpPmGRfZWp+76a2CUZKcfwflO0wyXOj8OW5qQvcWlQ17D96dhzF4hDoy4yOQ3c51HKE7aOc6mF7R5YFmbaesBCbiR5UtccKS02pXsuo8CcFKbilx4MCFPHcAuulkPSxEYZz8wWrs2nnFnBqJwFToV7etFd2y+JNpnPVO6+07hmUy9albsye3PcYQza7RVHpAwZPYO7TSeRwyWOxJzYy+m9zyMn4Mj6wJuhGO9PDKFivMJOtviUzQRd1jIENLHMosjgWrBmQb+tB3BAJgC1vcNdVliYniKg4uXScEmNsjQeE5OmkyCvhfLACmo+GQIkto1jhYheTqr8H3webom2VTNCwlgYFwc3o2zSE0T70F3ajywqyWbVPo4mxl2NSmHSOFLuA+bP5OkAEe7LvsvUvthJa8p6CddAW2oqud3P0KGhk0/vfroSEOu7vwVnl6k7xTmEg93Umiez/C+f0tr5Ca2sZnWtqLOsravR+rCwIQrfoZfEifXWa7LE4ozOxWPSN4jU0v69Qz1vYWyn9p6ecSyqCHuyPYadr5aUbmX84xqBENmGVYHulguPyA66qWc7zwr0goSZBX8GAuPQqt+LQdZEBQHClhzgvDvLnLFyAbjyh+9b8JniAVYfUzQaxtJXA4s1uStLjsFaRpYc3SJV3xyJFQSQpAKCkU/veL/hMWnK1x6RxRaaIDEPK9ZDAlfdcdYY4c7lskFlyIIICL3bQvFbbxRGw6jUvzK/MjSyPVHr2cGLXfvI+fxAUYVDW9jzYvQ5035xMPwQMrfCwVfy288qAhVhrMzCLLbsDlD2+QAQLebxT2l74pJ8RFv/+achBM6Tr+rpXtodCB6Hue0jjFAQqpb9bMr7fHZ6JIuHBva2oej/ahJSbjB1rJPxi5j4upNixxGhdq2skGvECmpFfmDQg0nsuyOa2GQy1pNSUEAEgRRll5uCBzD1MZL9xZHg5SH44zqOzGZ6EwtzebuByZUoCmPa1fPm5XWBc76wvmlwbJGcvNJ0qpMsQ4dl6FFW1xduWghJe4BlKUs3G7IXZPejy4e9PUEoQXHgNCDWgms7Nq+3mqIsc5oPG7cwgIkHcsczeH0W2UD+egQcPehLKx62xlMxzcv9xriudtsZS7scSPZOrWmenfLCYuwDtf0hfIp8XK2qToqFJs04aDN0hJenFgue2RKjqIvVhEBr0TXujpwRQZW4WX22EveCcBsIY/eJOJfwKRSRzg9LVW1sbUPyGQYrgea6WqFG/cflIR4bP18asaE9x0ywkTnfpWBKcMZEZM2IXxMNXCFD2bkrNpFjGtWHxcinsl/6DB1blS8C/xD1khIBy3UKlJaBnGNdPGBJ2QGtjT+3zfUzHCLeAUm6zFCxKRcN/Vap7Z2DLGv/5SosoF9Gr+3Vyxt8rvGDfrHkWXGzDxtOvGHCxoBWVcJHWKHbOxQ6ONPtjRiPY6Akcb9296YMv8qdZKHqIFc+Q6FalXkWISv0PwGkBtw81/osjWqsvw+irlGLHdgZSQVUG6x7fK2dvfkX45PRJXHmgyIosO7SAQui1R1jqRddDw76Yl6nAm4Lr5AXOo/7Ju2Wcb9HOzOgMKBOBG6CDXqVyrhkWjX2FsCadPq81RQG+zSSFFY5vz4rbTa8Bl42hayHfk6xi/FKE4+voZWgymstip9IDd4uDa2214bvX7WkrH6/eTqMXhOYtHVihZljtW5bj650JJ0AD9vjRhUWurwN24Qe8YMj7RHeLsVkRz2NA4B8tTHJWTGLPaoX7gvTIFsCq7mvIV1kGThxGq1UfbqQh1t3+aVZ1z71h0f0nfxtvGoAUGia7HY2EyNxJ+WS8Aw69HplVaEPO7RF8oOFBU1sJvTp6oimbkSw0yXLpk7TPpxq79WxKtOMAkhCyE9CkhjRMTnLHtU4XAszJ+XAtSYrAA0mTOlaJ/spx1RafDnZYWf37K/HbYMuZTsiQwPVBksIbZ1j5Oenp0p1xNTZmamxGh20I8gkYdedc46W3d3ThEiD6rvGFbUdhRYAUD+CQd9R4ZnMmJddMqq3ZO4v8LuotVEggK67hlyKWBJOk/69MuOEzSs18wOapKmc3XJGkZtUrvsU24imIbp25cegUO1tqU9F2j1B7i+7O7PsfpyQGXo3fs6H1nsg6ZF2nZxXT1mbxrTbNVWL6lit23OwZFO2HAsJRSPn4dWdbUZcrM6OI7KyAGyfD0NdadxL0zy9gvkzpPDvUlBoL6EWE2CLOAzsWqG/oTyh/OUfUi/PfM28lEaa4rhFCn+8NUBaOLDLH+lUUSd955pYhWL0IUyvnOWZHQls37//8iYvuUUk+lfbWQAMBlpE6B3XoR9dClweKkRmzOF27fJ/cD3fIigu/7MZ/3BVJFwBEKONudtPJe296GMpRyefGlBE8yAck7B1vK+HnvWjHB73YZ4N+Gx7RpaQ+QrJHp3XVD6abBge3r7wosDUqv8WkQy0U/yquNF4OV5mIcUN5AjfpFdCFeehJd8TmPlbfiYn9i4u+AYK5P1F/enjKXs87rVg6EoxcRF+pLwwMbyrLnFjhnAByRntVRK336dmqJrbZ6fNq3F6vJB9tzXfrwXtntdN9uAgwEgJQ6FpUIAyeyX8n4vkwcz6VQrxrjPq4AkVdTTSX9Voh7a+H5xdJhtXc9JyLrrkxTGnytfjMM8FJtlTfhESSUHfCUofmhDzf4LaCduNhoP6fWujsD1grfHzyJFMbAYoQ7T3omCrLoxOz8ZMPpRvmEAdFuoIL9B4Y4x6WjmQ4fOnSugtDxyjsm5VVTwIFqT/1Tg6spJrjOO+I1v2eHVpKl4QLsRcypRS+OMYOsHL2kje8opL2Ov3UjYn1pIB/vuB5Dxn9CBCpLj9p/E3Z60vR6d49AQl6GTtBOlVWYbdvHEp7X0rqz8w3BrTcQRv+axhINiZhU55c3HTlFLZ7ZRWVGfOKZs4MHfqNWKV9PvjZqO9ozT/z/jLBtIPJOfbp3Xybpr64k6UYAimasO5vgupTzWo8E2n5vyWGpt/Ayh8eZI1Tmhcfm75wa9R9/V/pw3p79gWxKk3Aut4Lu56I6bj2slvm1/wg3IrUTcDFQyEmkZQio8pomaXt/qeFioer4u2adsTht+8A/69w8bAo2a9hbRnGoFkertkQ/lGvAUl2c0U1WhZ7Fd007KCY7x7QBb/ObDjQ3+B9X3PqY6PBR85WSEWKjnatQmv6PQVGk8nPRRhjuLef5d+MsM1gsl+8jGFRAqwz9QlB3ICU/MxHvAqusYgalilYey0+45/Ib7xmMUaR1GUuIwDQxkO3mBbZsq+y9RyragGd62xHBbEAriI1ktRyIC95bWB+TnqxJlHETbo2x8RtSAs6lFvW5gACWBHASy0qpMig+Ob54lASwhhqDUvvlRUm6PyAaTc+Vy/FMQ8mDGngAyk/XmoDrT49LKJ9Q0mZeJ/SCj5oBjK+4vADSAOy+tPGJs7HW73R5fScqJmUM/IIqNi5qtfc75bbGa89iO6EDZiagt+wGFexp3Hqana+OJPNk8RoaqSXugql7CR1K4E170OOMirWKrQUJ53RgAEVlUZRjuHN1Fq0LKfl5ANpb8hucKbDZJeF74BX+Dv80B3z9iM+aMm7AAlaGr9eTyDy9DJVJ8grR9L3mXIK6TnfOqOmYYP2q7sbDCCBABV1nA1swAF5ui9+JjeuAiwNnNjxPvihOvL1ZIRn45y/Xg1qb3SJqxa363WXvwFWRypnnLpfLBY1LSCmPKx6cvqHKq3fBOpQE7ghO9QP0UoR55g6/JB+cQHbczPTWEspv/4+t3KYNN0MlV34ocIqoQ8BvYmjN9Pn2NLLHn1kY+tUzNBlWI3JbYVlWRAA8lDpibtyx8xiagptdpiDCRqZNizgKbK6/NIl2j/uAspUa2Ke7XSlWjkbD0IxhnZmA0VB9P0caqMlBULcJHVmWfpe9i/kJKevFZhgJ/Iyk776syW/kKMhRXaYP1cJimFD6ZdwDdUAZHg6UY8HRShNUWu7PInYvowRJCObmyT023qF6SGPf5stTqisXki9VkQuXlgh7Uxn5uPepQmfvDKi2iLa3bMxbwC18EPaGsT0qbaBk+LluZeFXODr6MYIAsL1+NS9pmKsUJUgVGOMt9qAynDSMqfqT44F2ypPmCgVhrJV2r5S50RdyQ+zaPSK+NfpLyYjTIjQQTy9fq2h1IR39ILErAdd3OHXGtJ4X80HAQSfUOXFeOI0VIe58YLwd9NdEhvnSsg6g2ukkhZztjn7SW5XBo4G5OPjOtW7cnADS6o8Dnda/1j/3wF4F4cO5gKODAiGDfzkVGxM1Cp30pO6STeiN/ZJxIT80ghj8ybEb9k0CKaVeecyvbPKLKTHYrik6ydQAMclEEBwj7E0EKG+amxYi5R68A1dimH0fUJpJvcJphbeKJF82USQDVgoFTsZLICmDvzf6fLnzAwzZRB3PE1LG3/K/Jln2pQsV1WhGQhDyPhDdMNM85ck2wR+VyhLDyad0wmolOFiwZbm84T3CZh9Y4yiLSB2IGq5kvVfwXJ4Lr7jDPTDScnrOzEF8+05ISDfErlJ3Nu0XnqwNB4mwDaocvIWA/xJQTn9kXkvnsojwONqVg/z6JdqiQQQ5BdwGZi9fE71YY2PxUrUnL9vlWBDZVeGdqmloNMeII2Eodi4xOOs3nA5gqW20C1CyHOoY9du96W5FWjpqXDgtpFykfGUOUDywOO3z0jIfLB0OuVhsEMPY7CbXNWocZQh/W/zLhAO/inhbYs0nhfI7iwqw0wzWnL/TtnKS1BPLaIK6bGr00MMIMMyWTu8eCLwCjU/v8rEr1G8C88XwiH2SW8Xk0g2ZQxIJ6Ab5/rGM+z/DKhOECa/MtIHtok4AzA1ETlU0WCxEZSkVVUrLwgE7yyRwh4SLBglacF2mEprwuGAA5k7D41AEfgLMja7cVFMpDlT+02IJk6vR0/jfQ6v6FKVQ9yrrJQV3wulHbUJHBZ6bxk4OrUC+5WFWagHz7CXmJCyUQ5AspxP6Mp1EtbkY0Kpe+93KpJPHu4TyLea9f/aeKLgwndNrQLCK75TVsgSgc+ifzFUFFTJ3iWrJVgI4BbCcyMqTa9CKX6iumV5qOeVk8DfdSU5iKcEA4ymXRVUaWhr8X7vomsTJP2BvdaKqRDwKLO7i72H4LpSt65q3/WJJKQNYD/QZGmTky3o8wir20g4elSVXduyIVSaTkrt4Qx/uFvNqNwJVWQrb259L5h1BeLV4V9mGUZRM5ITJlnIoVWRRRQ/fWi9Mnyy+cbD6uIRs3UjIwCIs57c3nh6/8w9xpSGpdeavYHRTZEFlniyxCSpFqCpYsXzzn504vt80Vd89R62EVwS2iJFYqecE//ZaniFne6JUX9riYhIAo92QHswxBL4TEipVZ1KrjRzCwpAf0UDto3FaVc/O+4Nj3laKIpDphz+o7Vv/eoM0U/6FhyQfR2EM023/Wzqs1j3T65SSXed7rKhgrDuTxc3eu6vqzhavYDqPIh03LVm/UbN3UsVOkgnuQ53KO8/a/GlX/d6KJlDsCBQZLEf9NCxvm2FYXTRHFk5TBk0df6bKjLao5ij14XyZinlvTE3+xYIk8/uOKv2K1a3ElKX3yxt4Wudq1xEUxM6rzNqB1MWPW1MVP7lxUQsC6vY5L8d7nDgPxaqPQmtX1x40MHJGK4XqNhuCrSLyY1S6C+DbpOUMWeZv+RZyjIlAt9XFRISmdZbumXAHZwaOlM2RExBZS8ZFtS/bA5rqUX5L8G5hTeeQxg8MDyBf6Jdr0/ZuNFyvVYW05DQJNpmae4rBLsfi3a/mQwhsOdOXuLf1yOBD3xToIDS9b2OWy2AZqHESqbVqplMg3Ig3sWm/a8R5U8Ya7cS4MhI4Jk1X5eNCSP8tY/R1ha+NJ6LBSzhICCY3bDkXE4+031ivqJuN13OsvtjrHpZt6OQCGo0GKehbLWsuLaafrWxgushb5sosepD774n1OuVr9FhIsAFI4fBEGFO2KfMavNkqq7iyBMdslffoEjPovNAaZlcLh+tdsQzlsluzyLg9uOWquA5EaTV5ay8+cZrC0F+owV3a7YZ1cARyw3mqmk9rzIKyT6Rg6v4J85hlBprFCSbQsmAfYtqNmDYfrCsYPpQOK/w0OSCGvxiyvjJFZd+XfGAK6bQ8SZV6suB77T4j2sJUEZhlF7vHu0f6jYEUYFs0aa337ETSitNVvcoh3aNlrqgsP+yYMIIx+u9lpOFKKWdWRhNconzRjd8yD+sTn+ve8dHXFA4zcBUCv69tuPwUuOFHNx20E065XDb/IkQz86F5RYlRIMCyv8WP9oo+MXajDXrxk/P+ooSV1jH3SRhgLQitk7mUihdm7B2it4eyuWZ3nYv9G3rXIkxrrHh6AFFLwxXEAosYgAKxQidcPJlddhcgWg0Q7EdWAeGsZ+V4n33ZPkazxuMIfhI4rL/4emul4yF44voQ6arJJDo3Uq8LWOyHdRCxfZK3OY1TNeli0n8I3hMMCpjwm/m55E2W6Njat5Dv8U0GkDObSyb4c+hcEIQQi75UzZlv5NaTqu8jrwi/U7lsWMbW99LMi/+VljF6l5S16zbTCgaPM3JyCgAHZTKUPmpZg8MFjaI4pNdkjciDjVWvIMgucA32tZ+8MGh9IfSdwFJrvp5bKhlkazoFtG4JFX+FuxRjGFqw+vOpS7Dle3T/gEsHDYnWNJHF5odcN5dVE+JNdbGrkYSIvlJZeljN47Q+RMCqRPR7M9FtM4T+yw0xQb3g9RFqfJokvJQcnRgXcp37GJXW2eAUUlYV+ULT+ZPFUA5T865OGBsdfHCyI2hdcnpue0ZBmrHENLHfGUFq+D6XnY9hxC5pD5ofp65u23cDA6qsNA2TCb3jPYZ3FFBbiyIgyKBJ3yduyxXS8+miuW+OZZgcY3NW86BblhhJrd6RguNRUEwzH34QXlx1k7rbitc+iQ2MlRht3UcjfwVeV1H533QCHlfCvsI0iFSTbiWMpYCfp8vBvv9NiGQ+P+9bvbAHTfK5x4v1+4QF+u5vhDG1fDMDIoCtouLJgF+pqoaNwu23bwR4BV+YzbZLnaYvdin0TKIqn9NEVfdkLBpNqnCV1go4kakWLO7tGGTLaxib7g2jria9zX1RF/Jgf5wLu/sc5TG8EioNnT3MvLiSA5skoleFV/rBWne+ScXrHbEBRr9zcfOoavqhV3dHaoGVBHM3qFf29sPzm/hItLh27gURBUoeRNJ5sBA4eD9tgnEqerSUlBLErvQ1v/FAhoo/qQM9PSQmsmGIrbNoi9Ytxz1WDyS5YZq1CUxMP2UqlJ9kkwQDERevlu5slyoxKK0aMosD5D8XHa1UbSfGqlWkH+uMHjHR2QMWAKJh39sJsl2D3+DTUuupvwD4EDI4Z8oNssaOKbpBiEjBD/ea04rIIS3q9tSJHGKoP7LQXaagKrpUDHxeqoxEeEZ2zAOt/7NgZh1ErTDVjE59aaKg9HICQLqUh7pgEOP36DrWecSiOaIoJ5Jh8lbOVVgoY2YzYDkH7lB/9cNCBoSSik44Gp/596++wvGgQYU+ZgJ13wowv3OoOtD7aYZQKLsmgO6CMPl7SiEUMe1Fdwbc89MwuhUTNgCm7QioN++7kYEZWq7O72eYlwW+ZcV3XSCTZMAIroLKemV3edF+ohkUS6PzXHANtC2zdcrmOxfv/nrEyDgrkg1mdfTsWWdSohY88TVD0DjX4ck+UZt2GFviV2sFckQ6ujH6YSzMFwxd7plJ3K3oB+hC/pDLHp3FisQmuHI41XZHvAy+NhRaG9fOF+QbzgOROG6FmuxPQp5/sv6tfz5xyJlgOiGagldhXs0E+fLgJuHPz4F1ypQtavgTT8HDlDb+VixIRrJpNc+RCOQz7NNUzvLfW/LFrlnWGl3m/oHXkwWLtXb8ehD63rwH6l1lcUt53gah9NjvZiyzgoL9vGE9hPWVfsEu4nwXqtDycNm1uarvIsbG1xE+xSpxtwnN93Vy4IL3lyco2uQCv0rmavByyTz85JFdAvmMHtAirJY1+3fvKwMSjpYgVxyF/4wjjxBeWiJb5Qu1G0CO1ikEjoeirUq/yfhHCvQOgE6+4wR5cMcWJ5HZn8/6igvYMRtzsneH486Xw+0I69jMS0y9+XGWpVxkCqKKm6SUJ+DKi2OEWvMPR2FS7Vk4ZCAreUDBRuK/e0u9ztvk7y5srAwD48qPnJTBnVz7V5uRAofRuu7Jr/o50EIp0DaJ/YiUsDuz3xNj+zDlHiSq/xNJKPbXwFt40Mjm8yClzozZl2v3ClSXvcrvo3kpMYGkNqnP0y4k2vtG0UNFwwSklE5otH8Xmtv+Vr03j5MJcIYi7RDnIqqqqB7Cv17b8wxHUToov2PlOhBDVuuxuAG+gCdNYCgagJV0h92idPzzpXlphhexnQ30foZZzavyXoUGrrWfm2wf63oi+5Ed+qJV37f/jyUoXCEuIMW0oyZ+xzHdYJV6uLOYD02XipdqZfb5hV6vxSKH4/MajhDWt36Whhq2YQ81gENey5ZrmlwK6z1rbBtra9BR+2w8MLAkN9hEcWdCR6YTP33WvDEBObCIC7R2zx/IBnwNGaeChbenWvk0nQv6sdxDjeeKXePJS9j7LkWXWEuy6dlLE9XUNcpJ29774CK0TY0JPQms35sK54pNCLneoyNhzv2ZkQZ0kz4pJKF6relh8TjX7Aty8Qpbb0eha1D7n8JFjxtyHmiQ3X+s+EBZgzvDybAOCk6OEIK+5zc1ziIzcEDIqOzhFqM9JD9Ao95jtWkkaRgcnkBz+QN0qFXztT7pvD4YyLiVahlJHZXbhqbtjQDlVbgdoBKL1i4w0PZ89uUfKIKkHgAQo7bir4TEsjUUvxCc8SgAjbwx4sEZTViGO/jyiXl7xai/82TlMJDyR9woWHscOUO6ewtjUyi/mWaW9Tegyj1bXoD+I/DxeHPApIcBYTMBHapeI/6vKj7FwfWiFV3UgaIW6uFuHxNr25fXmKkgLA6N+viAQSCgPYRS6knQMskJL7FXkHSTQc8FTMZ8pqDxXmU0RGFt/ITEqiHPd0sojnKvn1SF/wMyTvL3kGKmr/tcm/uoU5naUZElbF1YwASiOUKPEBl4v9cRaMeUTxvkACxKycRyqIX46V8xf9a4LGSfsLdb9M6fPw/BVjMmskZv7lZjecUBmBpC+WIctckibibC0YwzE9rs1rjO1uK18hrvULc3+wTjbm2y6cP4aZqTZXTK8iOOqj+zA0c9jW+S9lvxelmG7uHBMxIVMsB3LorBV11KK+/4eZqdJzilZNh0kW/uQSOl7C+B9nlelZwV2Q/vs8yJGuuLGLmHlvKARaREMZLi9SC/tCN4fusa9z0m0Dw2AGhUUySi/TxnXP8d7AvPM5BqXPfcI7arh8lC293/2Ei6uUl4lVtG0ZS994OBXJf1mRB9LATC0vWzossLor+1OOS8kOCu1zfffRayN68R7JngwyphjbIKEzpqUdq1HV0AGcoySHl2ClWdOzH5pbvbyAf4MBQ9z+rV9tuEtEZiEyAfmsRJuUsX5Q2ljph6HLt2S9GeLJpQu6S1cEgdmc6RN5HxzDlpNQ5B4VNdyuB05L1Bo5kepBpoWvepPNOAIFDjFBSJ/cADEDSUNFFpOUk800U19kbTdRQE6Y6+KNF7I5lmXrqRBzIWME7uiV2Uf+6/MUd/fRw4HWALnJncaocPIcw8HZ7Q7Y+IAel9mmTcUZ35cXC3qEJzihKjAcI3z73u/SZLLkbHOKj51LcAVn5aTD1ybkY7UG5FcDu1wjHWcOPgkNYKBw804uZ0Dz1f+sdgN+nYEJURao8tZrfvxZ5RkTKjCMZWNv3vp3zUaKmtKYlYuswV6yhszKIwFuSr0kWbfV/d/vymIUQtWIRNV8+dlWe5xojyN7pa8CFZsEjOYQ2oCwka02n/WQWIpbFfv4VmYnvbkkZOCrx1zePO8Gu41hn7UxbaGd1MDcLOhzWTu28q06hEZVsCVquCFeZuDTqhasSLwx6wju686fVje/SsHD6Nl5klj/3sc/Nyctqjd77qIZA6MwyiMWvtyaMVs56O/fMorwlTK+qDT2vlJr5XEjoH8i470Xp9JlL7hX2JFAe7YGd+uMtojEsrAyGSfagafQQEc7BzsEQ1LBih4qZlQQzEUQHqYTDRxPwcdjJ3XvmkkKnhGEYc5OAly1fH53K515xLXkFtsgJ7R4VgGEiDqjxFrYP8dsUe8fzt+fXqucDDCuIdplXiuw/WpMosMUFQJTBjufWl3vRA8a3qWjQ6qf6qilZPX+R4HGGN0jKEVV1Cl+3qL6p0HxUsxEKdaRTDMeUVHqrRdMUFg9SiB1VZW3IMuLSJMLDpyz9LLLBUf+KuqmJPVKLm1qHuyulaEoqHSdP/tcnahtCUnEfeDLk9NflrD9CKrAjiQDNmO5f2ytHCNI+5jB8IKS9BxWY3dt6QlMdwQmtgZhCGzg6A1ygFCyZxSmtrhr8b/yI7eTd7q7ncfXXpLCLFOqFV1kYxoG2aDRETCWlwl5Djj9ZlJhOLpDYPizGLxm6k48OEPPA0efqTmuOadpHL8twzaTakKhCHwt0RK1OywH264Dir7nP1E4KaCj7Kly9JffLVqXmPQnXWbelMuVd9ktd4vLLUAou5bPiWCr/oVvtcNwjlhMfQH2erfnt9eibF+yHdx9I4WkuxaayAOYqb6X2hu2w152rxKp//VO4xa+izevKXbnCQxJ5QHkyotaa3kK9TbjyXp/013MTWjceV9m9/mBhDOgRBCGsy8TcgVMcAndWQ2vT+78UJH0w50odgb6cmr/Ft5zO34XGq9k+RZkjf/cKJhDmXJJGnrcMrR61fTJGxuXDsr9/lRr8bqxlxZ7/Bm5DiZhWrpPjvb7Cg452cTU+qidjCHskQZ2WEHxxWdASiGnOxDSjVd6lmIg31h0xKncfxbItFeo1JRG7JpJP3ESBvB6Ajy6wZ2ye1kVXWKH0uc1g/kFup6hJVw+XH0e6D2ooiZJIXktyYy0EQQrjVJXAgknTEbe1hzZZqs5/D4MvyrlwS2sdLubjDOwcljUNaE4MyFVrEVbyFJ+eq+kewT3Mi2pVKEopnKaC8a+/pjK9WX+lP6kOxliNC91ds1CRQE87K9YeGlXcPNwk/kMK3l6CR5YtluotG2be2bopW3xgXAP1WpLUQdrUy5WMtvp1navsD7zRyEqj3lmrVXVOFInY+fsfiA5rv3JePAW2M1vjDaF5mC+pEBG+G74ZowtLA+2SvCULu8eHrOunAXFjGtUWZawe7cAo63iWLKzMPKVWERYlnPbSPpCIEC5sBya5+KYEHQu3FyVGo/jfUw8tVrSSmkCB9SiCq++3CnJselZaqnkU6/dyswQ5w1ayCsiKzsVbpB8G8D5C0IbaFC/irQ+Usl6AVapxjl1+CiL4kVsFesPAW0in34x2K8SJSUsnS6rnrxBylNASGGvNBxX4MgBWW6glWP2K1Ko5+hsk+BSlwKRk+6XJuq2I4I/yf4sk4GbC2SW5HcJx4gyKUtnCewvoxFiAkDSVMcEHgxVTdrczTQmrGRZzoYrp+ZJbbH+ITZLmIjy8E5nZqm4C+sxpPvTq9r8diIyB5/6ArlYahJI4V5OvV+EHQLlwfZoDswziFcOmW7P/5amQPsxhvXp+f8iC08R8IRlMiu5ISFnTcOV5wutfXCe98PVItWtTTx6wJYRPTJN5/lVz3D6amdoLtEedsP7c1EdgNL+dYTeO7OH2COCiMtm/ACS62K4++kMdKtLuV/ndH/BBa0Px0LJQ2tERS8s/GO8CAuwA+Vek0O4JVeaNCs2X6sBcPW6aRx6GuWQjlpZ/p75oSu9pTaBShk+NVO8Vxe8NpVqZ1LOI342dqjmfC5fM1UeBsfLtOkmORXfI7LR/h9HB1wYg6kfS+Q8bYTio/IdwOxzLwCsak8nq0CigkgGnQvZaeUBvkyVKnfd05pcgS/sUUos30yXDnSEbpRnty/pmmNAKiAhFlFv2WibC8UzoUQTz3Gtlk89iJoryjSwTZVC3ueCLzR5laiMasZFMvJ0X0blhNBkUkOqXHmEK7J/5QhEk1kZWkgYKVtmyMl+qDghFYyaHiBhEg1epxNbN/aeAGx841iQ2Lvg3t/B54fqiSMi7nKtfRvVovcNHmaKjZ7RvoKNwGDKQm+nGdOPJ7teYpPpXdM7FPUe3JTJiRTyfIxPMH53onRQnzinYqrcCWpF2CqrwZgMMJpWnN7qp1OW/lAsql8WLHET5p7eiU9hcSUqKTHsXv23K7gBQlTYg+asEN7QHFQVALZBhX8CXONmCGzcjyEqcQ6WqGCTHFlw4+/G6TUcTJO5k7HC74WSwEyfjxedhNJGUzUGKlsKIZ85T8EQDgqZRnwCbhk0yatMukNhgq1MY4uNJE63r7mfbbKg2XFatyECDzNk6f217OE6p/yZl49PUtjhInjRw9szDvJjO+3S0p6UkpgaGRnGRTF9zGK0KD0O37q2JTWRGkKG7MRaCjZLzSwcYpNRnw8BVCaWQ6oQDruRdyM1O3dqIVCedE47m/BhVZsgMT/zdeyLV6gX5ik1YAOEbOipjAIDVensxufgH5ETyUx74Gj53ZPlL8W1HzFpzt62tt3VDRSv0+4tEdTPa1F/Bv1mloNyw6O/H95XepBlFJdn/VqIYZx7cm1uUjWc3Ry9G4FYwVt3uvJY7u2dZ70YZWPAXtLCx//BnoEwPWZ6RTokkHJXEO9WVaUO953+5UfxnjHJOpRizgpsXcRFGWMUDKcp4L5zXU3BKrm4X7KNVJmLYl7uIWLP4MVrAvwGh0mxMvb4hoNOaLF0xazgs9in1/eqWcwmd9Px5rcWYpeP1dVaSdIfnKTLO3s4pL0WFXK6PBxJgalZV1zXay0BRBg5Tq319ZX35NDo9VhJJ+0es4313ZXW8RM1LfGMHWrBjwt1G9DdAH3XpcktnHCFG3nEgscUjMddU2Xptfjz72UgAYBJR/81H/eTc71Y1U6NqczrCazXlse/o8CcfBjf1gXskcInFPKltpkfE0rueJxxj0gMD+Q0Hs9sVpRwNSquXLzWv2vDKBLx8j4U8ISFWW6881xR3YDQxMRYKNOre0+z6LqHZHFfy4Vp7lj4o6QoywPNtuxv044E/azK5SiuTrmXJ79EEgRQLhyFvGWjOOFZlRuvb8BGiHKUO6ghQHBSTx0Q/yk2LCPNjRg5R0WEtDbOHF9dQPCiqByWpqeLe+hxiF3+iwXBN1WG58NuZ9LxpRxg0yTADkEXE8TxA+ypHH6I0+VO8C4mReDW5N9Gbu/QOQ3P1BN9d8cKFZWMrZfu4Ool8jiDbTKi1OrwBS1KaK+aytwFDnqF9nKf/7BHoywh3tAX+6dQSudext40GS0ORDUhe5OiaarfeoA7IVOgNclRmMKsId7ToE85TLQuSDQ0T9SXc69wOt9p8xqwKY1F6uI0/yaGvZTVeeEkx23k/aQJOVYOxSNZhFkemsy4ul6WnsJnW2jHv/8vUXCFdN49/J59HYxmgPBImNkYLNYEzzunA9Eg3TT7ZwgkfW5auuOpVDsxth1kqmFPpXfDk+vCt40dL7iChTyIjakot15m4uhLQNvj0LC8lUZl5y03TnIJVVw2Um5sQKs0f14ju3SelfVjDtalnSDRMXVCMOqPh2Y5zL6itOPoxBkS/mzgE7k7DcdJtraMpW4je6s1fkHf9aC+vXWOZO6985w/N2pA04CXQprp6MorIAfU4z12aQHoXVmYaAzv8RDsSsgdGwNfLXaoupvFJgvHK95PzxdmvzFdWCTjgdw3sx93JnCOVqUDCv7IeDsmngP3rvrAqrGOISlDBoSLMToO6j0NgwzWqmoyJVfTYabieUCFOAaSks0w4iJUkT0HtUDo2crHG7R6+JX5O+d1hEOSpwfSO6nAxqe8myBKU/i2V5edMt1x9d53yuZRpq+IoDSd5l/Lwayd/2H58oYC3iGGCgipY2GmIlLRcJWbLUJ/QBmAzJ32m0q5lDyh+caUZiW2FQi6SR+5u6YrvJQLonYlRldMZLJXC36J9MgERb3EoAWuf7Jy1dOcgBqrFiQgAtXihBADUD/Vw5a8b2BfUm/JdyyM/Vog1mm7YUJV2W7oPCDal8TheA/WweSDBDxsGBhEwtt2ca6aJfJ39htGtpNaW0SjPYpgziTYkbh8tvDnTjdhLCoC96sqHM2OwBhlfSxzeCKbvwaqluPl9+J8LOESL7kSmyLmXakzFx+dqXrNse576pTBJ4FJeWKIsJFkw8cAycqPt82YV72PlpH+BZtvptzI1PO5LNxtG8WDRarkbdHqrrHfIQjJUBNUz6XFsc0GGhskT5HDs1U32Q9x50jSTcl6eAEeA28Vsjv6v8dLIBvwwuhUtaFQxPkudSKuZWG9ILrOif574VT3IkBxNJHAP3ZpzIRG2AXhBCvSEdF+QNRt1D9d0bj5k6c/FaNydfzZHggSfNnKL1/1TsGCDqIQKYbS9u2oz3UI6rHiLjEaMpdb/gTW48gi2Km5lm9F9YevSy0NXAEE995IE7gLhtlw3OT56rgaxA3C2EIaZBInN7vrCVyTzCn+nVt/tdXRVVl8WskcAqsjaNn/9dpRAwfktbDVbAMbJ339pnp23rKV4MkpaGoRGmQuzvQbByFVr9tiEQ4T7Dgpo7DviZO6k7r7b7ilLlPtU/CTLLUzT08Cr1vbnG40+GFbVoajb+ZV9CrELW9cioO8xW9ik7WSW+6ImVdmTiYpaUgAJUROZGMxSJ6PiwVbGjqdw4a+nJ/DwTS7KpYtN43wEdrgR5mv2YIqoPIp8Z4XNkKAiJTwj3FFMjIydhTH9WkEQElShZ9Xc/v5lLNdV5OThRoKLVPdOA0adyXPl2G0BhRYGodf9EKsQFbW9nR00g6q+NqIrYN9GPIAdT6B+U5a1KayU0lkg45q0spD9zNoPRjzjIdDg37yr8FD1nPGgGSqXR6hA3eFgdyL0RKEOP9Up3xRbUu5DzrYIQZd1ofZQib/SJbSjNqo+uO1bRmezTox9xRPf/2vFWaS3x6P83amJuCbkkhQOGdKlT5dE6h0wOV/yfkQa2n17MBzzhFMXn36Iq1z+KtNm9aw0pgIvCysGArQe2qKOKMuvjz4tkRYlGl17fvM8kMxYOsVhWTcp+pXRqkC6X1sbn27MGzIvA/eksSn8uD00kFGgrqnZWfAbXwVqnDmZ0FdSauMT0R2zp+5VP1ASwYKycAWSroGjVAl7SNl0Tc/dRREH8VY76TWwEwTw0kTcKJPL3eveq9aY7uW2PffSWYh9ekNqNYnz4SEjRxJMGCQzoDXPuYLGO7XYfjMRaB1RSMJTjEwo0C+snP082wtxEhhe1NCsqZlZpIQjhXRey9UN6I7c36rgjlLpcumHeZBNBVrBnWSzXXGZcj/tNnDuveWDl8C8wyYEKUDlHBBWcSDFHt3LM936qLMGZOziS4i9LbGTE+MMs11zkc9deEAGbb2GuwvqIXFDDADheqDBk0YLOXRm2Ir29FAthqGZxnNz30/ODsh6lHLo+4bb48LkADNCNIw5iSTLEEdiYsD7/+Bk4/gV+Y4UilNLiHpMUmIQYrarJPqzdfFysv0yztacHyZjKEFuVueqnNRBGiSYTvGcz4gEH5mgxp6O8J3fgbkEdmlSshEzGXAAGb31hXOv40tS/25FcIsWJdTRueq3mCjrZGIV48W5gE2EB6icp7jse40zNhLWGPifk0gQQ/++FKHbB6tOzzkrvrnZ0zEGnBwhaV3LczJ9wjao2nAZxLEuIAt8bLr1xz7/CXJLl/mVV48R4x2LSr6ThBqFVy5qlsKW79BCePWve7EQ/wbA+da+KxSqIItb94DTJ9fQgF0IwDQcyuxq8XIQs569ctV0SgcqAiCuClNuAB5yl+NK4d1Xe1nkkt2Cvhk/RlMUAsNNy3/ri5mE+ZETkfrbBMbJMmd599nJGHP7gagEubWMhaxu+bXb1RiH53DSOZoGJekxR3lfdzwwwWULXrkWl3H/RQDTaxOzJSgFA7mo2bCP4gGQoO3ixrBG3hJryYD2mDJY3PwR5/1JMXECpP9l6ao/8EDdOTVgjb8QY6zgXLuY1eCs6JNS8757kpTAoE4O3prEDI8Rj9GlSrlaxRsWqrk71npb3rD8+f/I3Vup2Nj+DNke7fjIPjq0Wi7Y7BBwW8m4V+aBkHRwa1KpOO/iBoQUQ1ZHm2FRbIOM7lK/OyzoKcgQhbdENYDZApSERV4y5pr+hsATIJhPkCh/OZNV9Z0neI4TsY757nmAGnp9lUKGw6HxmKS0Idp7f8NH/pFLUEw1VtVQiW4LoN3Be6rKae8rojwz1n/wgaPAINsbN2YEGCGwhaBdzmZ3ftsYJANiPc+kF+SL+vnS2A8J4p30ODtGi6ojhDraX9uwZynPd3E0pIZe9ZBjBZscvYSKLyXkl24Sc1C3Kq1JHb7dEbCIT1sY/RYGTjaCul9w2Qt+hSYSwfwkdhrRMIFl4vuOhdbrmR7Ghnk/Aq2+N5F6zd5GUMujrI+5KruG2LVyOtI+KI7Tu91NR/KM77EHIRjvTC+Sk2eRP0dI81r5oio7pZgBYQGctlRAaIy+owjwaSVzw8/Y3hGkzDyaU6oj+UNA1Wrx96YG9BMKMsbetxh+oJzQXkYImY77oqEGCAAJUXq/Pxx0W7TJZLwIp+cCb4s0JO+o+WDeDgKjBPfUochhju8IKZa6grYcOwGA3yFEvn8swvuB9b8L3dHDagBhjp+vamcoKaXpFreuPybje65/tmDCpU5q/52y1l+6ouKTlW7xIBSu9oaKHbbHcqEPuwhHvMM1wpKVZDWO+5f4ph4IkCMF3QoOS1VWMCctgA2dNXFaad2OT4ufac/Gtp3vda2Ab78q2nYuD5DpTK5b9+AGQOkiRemJs+8WUJmhcdXkkvLHRrEGmImOZAa198yx4EU7Pcd5LZhx6q314Oq2V3vYc5lWYIK0T07Rz2DULllONPQtMXVY3+4YToLw2ZkRQBmLWtk/IGB2Dm7WHTRwkjgfrG9KUm1txcnXmeriJyCDZDBZnZrbUpnG/SWLm/q3hfezWwErRFhgRAvKnqoPJM1DFtAuZqkno3o0vvfG6e3uCXDbNKPSvYlcGi4mbbRH4EKYYKCwKcCthK3yTcm7pH3+P4Kb0RD2ERjtkE6K463bW95H07G0Q/x6Lb0pOGKBby7C11a+NDFAIEPor+qOLYlLCmw4GGjDxlUx9XqlMEsdaeOaQk5jvnAnwDB/SaQijIZQcKVOGp52zn5jBSAV63KvcMOiuL4/VQMW1nOPgeP/FbdyY7jVF6uLmorwU7XmNLMZPqBkpsVP8uDdSoIfRK4z6EnQviWOAhSrGwiIsqvJw5XMp9PyRjfiga1EHhPvpTVv/83l6h4Cc9xlzVLLX8D5siQhlcmektMtjxlIlKYCFUtwHYV1Z/hu+e0HRPi6ecGok0bu8vsVHD5b1grdPlNkk0aAZ6aaR0qgMCDblCwOIwPa7htjFTKS8+tgYGZD+fY1SqFru8gLHLSbECz6yuDjNjA3NMqppXd6qmyrdIemp5foir5Sz6at7rnq6onMqDyaQ9gSfiiHeJKg19+HZ/1xs+oyA95IA0I1IpH6uk82bklVHpRJvALkCu/YE8rgQFKw7lKVh2cr3FS2+T8pj2q9Tc0cC+Akm0XLFCfOs8xxZR/OobfMviff4BpyNmWP1HIoHeovsz02QapZZ/NH/SkMfoUKKWnCSnBtPzCs6uI49ALb761F6kPSIuxq4D+xJd153Acu/0j67dD3lXtlFtjjgG1GXLZssJYmgXMNVY92FJKykdIciwEO26GBEIO018IcdiMcKsgAowfEKxbG/svD/WVmcarkUkuOAnKF3gp8fLxuNwX+SDp8gIovZNvoDz0reeeVnPvUflaXmUPB9qw29Kb8b1lyUmX58gIJSytcfwRSz/BOyYaNEM1AgVufFD8T8TOeAyZS8+RVNFYxqQKzk93zbJjwGEKYMVJmA0qB+gORNCRzjxTDRilE57J8c3mIbtoiRp4OBXpkToAa9LRWgFgq6EZkzoQ0cgLa6xoTOkufc1binvlopS4Ozt+1FEytLwgxRmAnrz+oXiI1Sj9Js+HiWs6qK0+zUW4DkeSWH1F9WdWl0zGn5BtnTuwlA8pDglioUh4c/QRRiONnlpwkc/ybmVD8aFebm6kdhO7MuCeOiS7xiA6IePg9zobZigAaa57jpihiRfo80qrzf4KRuKGCNChy1X+rXUzIC+PLCaWrkcZ3QRvak0nOHHunaI4GFnBfdyHCUJeb0TI74ZmNz3783UWlfPQuo3kpuzKNVIzzSOuPgX952vKzOKkgDQYFPGXFY+etWGm/11GP6zsfx/4+82e2wSx1Sj364KhUWR5FEeqeEL8CyYpl8w4FsEJzdOUEQtt2IsXnTFDWXNP7SIdi+pRhaoPmkkdSmKQxy5o4jr6hZ5NU+fbU/I1rLbIy0p3pYUrDC0EbvuUOIFWbXKGzYvQwwJcKCwf7S3W9jsedOFUBzkK1uGn5LWyXZ7oNPJRu2D12UNgYkpA9N1hFSD1+fVxHcw+4sdRz2y2Op3szyeT2M40yjSFVY5zzJ+hrpirwnFnglEbNtMQ/gLXNtAG1zZMG6VwR2dy7VOrlmnilZTn5VHOtS8s5ovAe3D709iB/mQYCLaD/21hPunSPnQDQoO7qESQ1+EtR/8mBk7cDQZg364eBwoEWEdxek49lmZiaNYydewRMHwbkqPqswWpmds5rZDynyHGpmqgOgshtaKPN8uPXuSVx6e5iC5ltxcstAgIlr2n6Z/uEAtUuVWKKr8GZFgBoGTxtxINFIQ/Ux28tD0qn5Y2PDCCpbXawrexI1XQlt4tTmVasPz9llv0UyWE0tBNFb7a4wUTY5ibZOn51Qj5cgPOK1aVtg1m9ZHKgmudJ3z9MJOogdyhiLTjq2ajuH7169AwmSlSQu6tWUIMEsa0+l2RFmYPmwofo8+HkwKiO87sMJHvF44W9OhV7OgMCyY4B1gyca4lClmJSHcx+iAna6/X6H+AMUPnOr8tTLce/Bncsb5WkQ6Vj7BVrdCt1UtJOSEBAnqGa3bX74P69FldWcfWxsL/fpIGEIYseYpKfRl4fSuHp/foNSk1FC6xEQuzIRbvwdVbEoBwjFPRjjdaDH+ZyAxB41QQVMlv44Dx8RpQrF4QXiPBbZz+SdyS42XDPk45McbHyqeSa5tlB7DHdtUsjTFmxK7bJvGRdafFgfBrAInx0tpYTC83+xPVIV8e+S1VjSXZbwvJypWKTp8XwWozR6kupWtDEiI+P1RGiDswcdbUxuD6SSDQ/RLmXkcMwuH4ii3KtT54mWv6/2eZ5JWPTBgM4eO26FhDlG6bBOGq+KztNKAv7XSsmcgcfwKhnHhiEf9I0nDUo5zah4vJ2dz8ZTsGizdJh3uC60b9TxUpWq5eXiYwVIpXZWaHvvpkPPpRK9UJF3xPHKKkNENSVPLIeMdNW1eBug6QSbxJifTnJArweYleMMaeBgNDE4hRLlF95XmHw5rqcN08+DcxdeK3m1khJg+zAJnz6oZpZV2Hdd6f9GDiuaycs3tsafyYKp6EWj22GKqtPb0Mnd+9Eh9vTV4hKzgRZrfEGqLIH6dfwQDUcOk0+lvYBBNr5iOaCakO/COEL4kSn96Iir/EJJhIUpq5fWI/GBEdx6exeG7MxJPTATweRxPDwi6dma/IMg=?iv=wDDS+Cbpqw1TX2BT52Qf9Q==", + "created_at": 1690251675, + "id": "fe6e8976ed1f8ae9906457bf5a0f7ce6c2382844872628aed2b1b66eeff79558", + "kind": 30000, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "238d972238ce203e5d6d6e4b6f9d7aa3f815027b0e9adae80ae64ea7d59e6b2df08f4707962303cb9a4eeb19030a468961dec44590892c2af4c7cc5a4b14007a", + "tags": [ + [ + "d", + "mute" + ] + ] + }, + { + "content": "", + "created_at": 1690251675, + "id": "8dc884509db4f009054d39c4fc6873245f6bbdfad7322f4dcce8f9668d1b4be1", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f08f2ad807861b07ae6ca36048250889fa4bd88c5b0d767d6b62ccaabb48e602c5fb86a5cb58e759843abcf23755c99e3fe1709a1c1f86427e540ba684ae835f", + "tags": [ + [ + "p", + "2065474608976bb14e103389871a8a9d2c91ea1a71a49247228d8ed104afb068", + "nudity" + ] + ] + }, + { + "content": "Tell them to wear a contact lens.", + "created_at": 1690248352, + "id": "df011422245d4c13a97f589c45296cdc8e033f778f3ec62a79f1e601a6361796", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "73f25c2c5344fa80aa61af19e32c8675d44c7e9822fdc00b6998c6151aadb23e8ccb9e0c01d2178ebcc98d1c1061e7f520d6246a2c4906e524052d9a6f6f22be", + "tags": [ + [ + "e", + "2a6f72d18ce554ad81a90a552dc2d5e5e1799b3801139a299c567c20bbcaa615", + "", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "", + "created_at": 1690246668, + "id": "2f7d62cd826a517f6153da75112d62bb4591bd7a9980632f11692653ee176fdd", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "deccb7dd37b7d77db7712d8dfae4742274644bc443064c8c9237ad4d2663f8dda75d670faba06785bce2e4eab532aef3f3a2bae6d4e6d4b3fadf39addfecef50", + "tags": [ + [ + "e", + "db6e3abe953de42ee3e4499c298973d0c957f346e6a56ba9681d881a5a1c10b1", + "nudity" + ], + [ + "p", + "7b2dd801b71077284f0f64333a4a9e7c6a32699b68fec8776f86f90ab497109e", + "nudity" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1690246668, + "id": "10ad1d3b9fae2dbb52b04eeef0ec80f3347f4675c5c2084f752f4eaaaabfe152", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "90be5cc5f4906d57ab629fcd285aa74b6e8bb5d10114e6c6b2f7daecb0b9b7335ac86f9390aa721a0cf3f5d997590a88d7f5c1b193a8e9956e526e80305164db", + "tags": [ + [ + "e", + "db6e3abe953de42ee3e4499c298973d0c957f346e6a56ba9681d881a5a1c10b1" + ], + [ + "p", + "7b2dd801b71077284f0f64333a4a9e7c6a32699b68fec8776f86f90ab497109e" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690244222, + "id": "111997ddef6b12be593cb96068e0d016be081d4a718c5cc2c618d9519f1fce57", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "23e2c107252140431c0c845c38f3cabed50113ab04149d100ff53b8f30f5fa8d991d3f0c59573114577c39d38bb0634dfaf89a5c802fa5304a28131a31de56e4", + "tags": [ + [ + "e", + "3621f31be0bd7c5980c3497fde8f6d1d170641084632a62178439871249ccca8" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "I wonder if there is a crazy person out there to reuse our jetpack compose ui elements and build a desktop app with it. ", + "created_at": 1690244215, + "id": "bb42bc6543d54910a38cf2a213da81d4d13278d130208dd05e56b16e6a01fc3b", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "814a28dd2fed0807907b63426fe61517f9a18c10dfdad23481919455282e076436c2364b3d1802608e3da96772dba9636c77822b3acdffd3b12b37d8de21e573", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "Devs and designers welcome. ", + "created_at": 1690242675, + "id": "5885b2edaef4155e17df4e0dc7af9db4121165f0708652955c5d11957c871601", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "888aa55504da091f0d786e330df76b2c29d8349e034dd529aa583fbca266b46d6f163638b1c67384f3568f9406266a37d3b4eb8435e2ba09784914db0ede74b3", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "FbLT7gYTHET9t9YxQOCC5rVKnX4AgpL0uZscQbjdqdmyK/HpPN3ZFtq2AAUqatmOV7dg2oOIafZ2sTYFSqpnXw==?iv=hvlO/iOLcRyNPebLu91jLw==", + "created_at": 1690242653, + "id": "c2b2c99123f9509df30ad497d5c7497e3dabed7a1f478c015843e4bd8de8772c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "add8ac7f7ad0ab40dcdd158c2fd33f66f2f0ef113a296ae372a98e997950af6c4027e666243abaab264c86e9a1291999edd152f9bb603131302721d8ba29acb5", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "ZYM8lrGBHNK4kHXEOVZzN74I1UfT64sIkxDRJWopfnVSr3oxREm2+QAH5JSCzL5kwwLWUMZFueIj9s8178zs2c4onTx7JDc6ByCZGhjbY/gvAo2yK23fChDGdHoQPzqX8p53KTM3a38XHA/sNEO7YvKsoYd94R1Q0UZgQiF7ivVb5QLDFgyTSQVfKiH/ca6I?iv=d2t4j8CP9B8pJ3/cfoT6MQ==", + "created_at": 1690242638, + "id": "3c8f1e613e96ab9dd7b35573dd938cf21877df1dded96de49e74456c39470002", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b6acf30bd79d64fe6056ea864f9e1132daf296626147df9afcd161a9406fd264c3f0106004894dc0f6310da30d76ae7a4804726b1d1d655ba193e008cbdabd06", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "+vCJukCAGqBNb4lXkeHcUbWo7Co8S3U/1hr8gbWdgx8=?iv=pYsP8v0FjaupHym497BEBw==", + "created_at": 1690242635, + "id": "6bd12d078b1579fb62f3a8bed7bbda20af983dbde9ee36844e11cff195c101b3", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dd5c8aa6588d26c536d1dbccaf84e1208b18d411b2080c971a5af836e517a97ed20f44638dfe8d6ddc7760c33e95a37ae64da9dace0dac229ef79808564dc9ed", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "BRZoGneJdOYKd0brs+QhEg==?iv=SbDOAorgoLdzFI1CnvDJfQ==", + "created_at": 1690242630, + "id": "682b4eee106d4dce8e6dac8c736abc9f16762b47934c30b12447299b2cee5387", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1408fa4fc6271e0ed4b90de165db36850ca4d09f9ced27e35179e3360d8180731df2180e07b429f76e1ac977cf4ff981a5220e3fd5fc6bd77c72c20dbb039667", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "IEyJCe4nxKc3oD9jgM1oORxiGagpXRf+veHnumcq9jN/7Pv+xruUY9rxYmyMbRw3Vm6tgusfzyIeaegKjfzzb0GKOjwkJ0ATdJcbJG8hGWV+2pMbxvRAKVRjy+bI8dvlOVGrhqlF5N97o4jcaTkPbDsBrGXePHXsIJau6O0omYa4sMSH6IIEeO3LHU8Qhgsi?iv=uwKdPGDkXcjSbo54u4xX+Q==", + "created_at": 1690242609, + "id": "7ecc386b5026262c448a0266cbc9fdb0c86bf5894681e48bee04fdf410210bc0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "87fe2a64db4728b2b7129590bfbb573303a6d825323438fd60d6a6f433a0722045ea04ef3cc8b0bfe36e4650a65875eb1e3d535bfda7b3905471d1a8ecce80e1", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Zzyg37FBq7HsOb9i4CqR0Q==?iv=I+N33Ubcnk2BOzO2wZiYww==", + "created_at": 1690242607, + "id": "dcf996c003eaae0bb47986c539d997bcfa04d4a7b6cea4dd2dbacdfa30fdce92", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c5846d1006c431459e88cb3e50b49697756155c0c54d4a7565005bc7f89acb93d43658322cd5cbc107781df72c768f472c3f47c9cedda26aaa8046cb644e85c4", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "4fH6cWjO2fRxNwnpk/WEfGQV2d4BrdX7ePzU+u0eJYEQ3oQfSND3qqn3L2gM1o9q?iv=Kf5/gUOJ2HKcG3EQbPSjfA==", + "created_at": 1690242606, + "id": "857e84d663936020a3c3dfbf418e7af5ce93fc64f370368694e5e1966e6de565", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "39d1bc609f8b11bfc0aae3f9e65f43de46bab94370a44e23439b12e56b647c79169b789a802bc552e7b70b2790a1194073ba1993e2a7e6494867cfaa720234bf", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "{\"about\":\"Channel for coders and designers to discuss Amethyst development. \",\"name\":\"Amethyst Devs\",\"picture\":\"https://nostr.build/i/7548146ede4c08de8be3e19ee3b2d1e7af90fbef12d2c247f54e8aa23cafe763.jpg\"}", + "created_at": 1690242586, + "id": "89b9c20216de8fa443e1e10d808befd8cd0569f53158796b45202c82e1659b02", + "kind": 41, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "341bb56ae426689fefc6b769d5bc2bd3597b7f84725327e59c775a79c0a5d774e42ebdb9af6562e829f280f5586cb545be4786606c7c9291edf65395ff3c290b", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "{\"about\":\"Channel for coders and designers to discuss Amethyst development. \",\"name\":\"Amethyst Devs\",\"picture\":\"https://nostr.build/i/7548146ede4c08de8be3e19ee3b2d1e7af90fbef12d2c247f54e8aa23cafe763\"}", + "created_at": 1690242548, + "id": "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "kind": 40, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "edcea8f6515a80792fd9e28842a6ea8c4d0de17788f56c3045198242a6938f3a9d3fed5e24e5e58d516e94fe1041478a6b4074d7c10a887d3db84a6db7000011", + "tags": [] + }, + { + "content": "{\"wss://nostr.oxtr.dev/\":{\"read\":true,\"write\":true},\"wss://filter.nostr.wine/\":{\"read\":true,\"write\":true},\"wss://nos.lol/\":{\"read\":true,\"write\":true},\"wss://relay.damus.io/\":{\"read\":true,\"write\":true},\"wss://nostr.bitcoiner.social/\":{\"read\":true,\"write\":true},\"wss://nostr-pub.wellorder.net/\":{\"read\":true,\"write\":true},\"wss://no.str.cr/\":{\"read\":true,\"write\":true},\"wss://nostr.mom/\":{\"read\":true,\"write\":true},\"wss://relay.nostr.band/\":{\"read\":true,\"write\":false},\"wss://relay.nostr.bg/\":{\"read\":true,\"write\":true},\"wss://relay.nostriches.org/\":{\"read\":true,\"write\":true},\"wss://relay.orangepill.dev/\":{\"read\":true,\"write\":true},\"wss://relay.snort.social/\":{\"read\":true,\"write\":true},\"wss://relay.mostr.pub\":{\"read\":true,\"write\":true},\"wss://relay.nostrati.com/\":{\"read\":true,\"write\":true},\"wss://nostr.inosta.cc/\":{\"read\":true,\"write\":true},\"wss://atlas.nostr.land/\":{\"read\":true,\"write\":true}}", + "created_at": 1690242548, + "id": "4efe027b19cc54a8f4c1ab8cc9f89ae3d88b3d93cfde079d6c8d9804fd6d1ec2", + "kind": 3, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94eb3d76bf5be95ddb2f88fb9b5130316c8da617a5be637de39d46a81276df5068fc313935b6dfb138a75e28a3c3787faabd9509d503d94770b14aca46ca67a0", + "tags": [ + [ + "p", + "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e" + ], + [ + "p", + "b7c1a5ef7ccf5cfd5976fba251116ed3f3ae3d3ed175a9ce5e3d33a890b4684b" + ], + [ + "p", + "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6" + ], + [ + "p", + "80482e60178c2ce996da6d67577f56a2b2c47ccb1c84c81f2b7960637cb71b78" + ], + [ + "p", + "bfc6af8244dc2859efdfd0e81a6a79f4ee395bc78acb3202b6f287a1ca3a27b3" + ], + [ + "p", + "b9b0bb9edc3ecf01389c570ea6f44c55a80b9db066d0bca8a22237af185252e6" + ], + [ + "p", + "5b29255d5eaaaeb577552bf0d11030376f477d19a009c5f5a80ddc73d49359f6" + ], + [ + "p", + "4d62dd5e6ac55ae2405940f59f6f030a994ec2b3ecc5556c8dc542cce20e46dd" + ], + [ + "p", + "e75692ec71174e698df1f3d1f5771855bcc4e6e568489d2eaad489d81064ace6" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ], + [ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9" + ], + [ + "p", + "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f" + ], + [ + "p", + "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + ], + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "p", + "3235036bd0957dfb27ccda02d452d7c763be40c91a1ac082ba6983b25238388c" + ], + [ + "p", + "d987084c48390a290f5d2a34603ae64f55137d9b4affced8c0eae030eb222a25" + ], + [ + "p", + "472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e" + ], + [ + "p", + "e9c0a9c12e3a04edd79afc77d89b6c6413cc942ef9e61c51e51283cbe9db0c8f" + ], + [ + "p", + "b17c59874dc05d7f6ec975bce04770c8b7fa9d37f3ad0096fdb76c9385d68928" + ], + [ + "p", + "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" + ], + [ + "p", + "ad46db12ee250a108756ab4f0f3007b04d7e699f45eac3ab696077296219d207" + ], + [ + "p", + "1b11ed41e815234599a52050a6a40c79bdd3bfa3d65e5d4a2c8d626698835d6d" + ], + [ + "p", + "85080d3bad70ccdcd7f74c29a44f55bb85cbcd3dd0cbb957da1d215bdb931204" + ], + [ + "p", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106" + ], + [ + "p", + "83e818dfbeccea56b0f551576b3fd39a7a50e1d8159343500368fa085ccd964b" + ], + [ + "p", + "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63" + ], + [ + "p", + "c2622c916d9b90e10a81b2ba67b19bdfc5d6be26c25756d1f990d3785ce1361b" + ], + [ + "p", + "eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f" + ], + [ + "p", + "090254801a7e8e5085b02e711622f0dfa1a85503493af246aa42af08f5e4d2df" + ], + [ + "p", + "58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196" + ], + [ + "p", + "e41e883f1ef62485a074c1a1fa1d0a092a5d678ad49bedc2f955ab5e305ba94e" + ], + [ + "p", + "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240" + ], + [ + "p", + "645681b9d067b1a362c4bee8ddff987d2466d49905c26cb8fec5e6fb73af5c84" + ], + [ + "p", + "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2" + ], + [ + "p", + "d12feb34b3ee120423b818cd8dda47000639bbec9a6ee6d3317ea886ec5b084f" + ], + [ + "p", + "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce" + ], + [ + "p", + "1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b" + ], + [ + "p", + "703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898" + ], + [ + "p", + "6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3" + ], + [ + "p", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + ], + [ + "p", + "148d1366a5e4672b1321adf00321778f86a2371a4bdbe99133f28df0b3d32fa1" + ], + [ + "p", + "b8e6bf46e109314616fe24e6c7e265791a5f2f4ec95ae8aa15d7107ad250dc63" + ], + [ + "p", + "c73e75dba8adce307479d65575019ef5bee4dc8042dceeded3350ff89e9909f2" + ], + [ + "p", + "ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ], + [ + "p", + "2b1964b885de3fcbb33777874d06b05c254fecd561511622ce86e3d1851949fa" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ], + [ + "p", + "9b0247ea14361a453ea9d77f9fdabc858fac10063424ce0bb3aa560237ededcb" + ], + [ + "p", + "7d95baf9ac8486d06798348e580a944e2110740e88586af731dffebfc1301e68" + ], + [ + "p", + "c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6" + ], + [ + "p", + "aef0d6b212827f3ba1de6189613e6d4824f181f567b1205273c16895fdaf0b23" + ], + [ + "p", + "1a1a7ff211f3762d6a5a849e3d29d288fd43b618423c9d030ae7d64b951ea183" + ], + [ + "p", + "546879d1ef626692ca8a54f9943e9629197b97c58ea1b4a3abd6ff968b280d09" + ], + [ + "p", + "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1" + ], + [ + "p", + "fdd5e8f6ae0db817be0b71da20498c1806968d8a6459559c249f322fa73464a7" + ], + [ + "p", + "08b80da85ba68ac031885ea555ab42bb42231fde9b690bbd0f48c128dfbf8009" + ], + [ + "p", + "34d2f5274f1958fcd2cb2463dabeaddf8a21f84ace4241da888023bf05cc8095" + ], + [ + "p", + "597b42de56a9e0c19ee2d0cde5797dd58d48ce8dd25c732b4c873af11161f9fd" + ], + [ + "p", + "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d" + ], + [ + "p", + "0000006a13e10fb648049b5e78632a0c2bf09eaf6a9d55d081b82baf86c951be" + ], + [ + "p", + "9c612f8b770f0e3fd35cdac2bc57fcee8561e560504ea25c8b9eff8e03512b3e" + ], + [ + "p", + "4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e" + ], + [ + "p", + "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ], + [ + "p", + "246716c303cb0df99b45eba30ff058e506bedd4193957df34ba61fb1929dc73a" + ], + [ + "p", + "c5d4815c26e18e2c178133004a6ddba9a96a5f7af795a3ab606d11aa1055146a" + ], + [ + "p", + "df4cafee85f79769545851db202a8856f82dc917548093c760f3094896e987b2" + ], + [ + "p", + "7e2cb3b6793ffb7b38cecb1664c47f6216b4abec00c18a8d1eb9f6dbc0da1e02" + ], + [ + "p", + "dbf3d7c79a92995ccfb135997ac1612f41637c8a805be393204b3d1c2769d127" + ], + [ + "p", + "dd81a8bacbab0b5c3007d1672fb8301383b4e9583d431835985057223eb298a5" + ], + [ + "p", + "330fb1431ff9d8c250706bbcdc016d5495a3f744e047a408173e92ae7ee42dac" + ], + [ + "p", + "b945e8537bfd2ca3d36acc393e6ce948ad08471a44e5bc2f7eb1409cf5046619" + ], + [ + "p", + "8c3b267e9db6b0115498cc3efcd187d1474864940ae8ff977826b9d83d205877" + ], + [ + "p", + "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" + ], + [ + "p", + "25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8" + ], + [ + "p", + "c7d32972e398d4d20cd69b1a8451956cc14a2e9065ad1a8fda185c202698937b" + ], + [ + "p", + "045681cf8d47413904ff6429753e6d5a41e05e8d9d50dbd4ec0125380d886f3b" + ], + [ + "p", + "da0cc82154bdf4ce8bf417eaa2d2fa99aa65c96c77867d6656fccdbf8e781b18" + ], + [ + "p", + "9fec72d579baaa772af9e71e638b529215721ace6e0f8320725ecbf9f77f85b1" + ], + [ + "p", + "489ac583fc30cfbee0095dd736ec46468faa8b187e311fda6269c4e18284ed0c" + ], + [ + "p", + "6f32dddf2d54f2c5e64e1570abcb9c7a05e8041bac0ee9f4235f694fccb68b5d" + ], + [ + "p", + "62cef883863022a4f1d60d54857c9d729650702c9fe227b0988c0b6e36c4bcce" + ], + [ + "p", + "eaf27aa104833bcd16f671488b01d65f6da30163b5848aea99677cc947dd00aa" + ], + [ + "p", + "4c8b51820cb56aef2462213fe6927ebb6efda2b94450db5ff8c0b38eec020d89" + ], + [ + "p", + "57c4ec915796158cbe0b7763f6dd0fadcb17495a8fd8db8d27f0122116504232" + ], + [ + "p", + "6e0f5f69570cc2f2565864b0872cfff43219a0b1946e8d46e9d9de1beacb3ab2" + ], + [ + "p", + "58ead82fa15b550094f7f5fe4804e0fe75b779dbef2e9b20511eccd69e6d08f9" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ], + [ + "p", + "933d97b01272a03d281deaec23d446a3f9c72c8dee83e30081f0db191c7e20e7" + ], + [ + "p", + "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc" + ], + [ + "p", + "8cef25eebe711364a06f2e61251cb3361d05cc75283606bc52d74f674ca0295c" + ], + [ + "p", + "c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1" + ], + [ + "p", + "de7ecd1e2976a6adb2ffa5f4db81a7d812c8bb6698aa00dcf1e76adb55efd645" + ], + [ + "p", + "3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d" + ], + [ + "p", + "3356de61b39647931ce8b2140b2bab837e0810c0ef515bbe92de0248040b8bdd" + ], + [ + "p", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" + ], + [ + "p", + "d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b9003833fabff271d0782e030be61b7ec38ce7d45a1b9a869fbdb34b9e2d2000" + ], + [ + "p", + "958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c" + ], + [ + "p", + "520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626" + ], + [ + "p", + "07ecf9838136fe430fac43fa0860dbc62a0aac0729c5a33df1192ce75e330c9f" + ], + [ + "p", + "b154080cb49639bb079a6a53c1d98e7130eeab3c61aa95dd9e38f9e400027cc7" + ], + [ + "p", + "deba271e547767bd6d8eec75eece5615db317a03b07f459134b03e7236005655" + ], + [ + "p", + "e6a92d8b6c20426f78bba8510ccdc73df5122814a3bac1d553adebac67a92b27" + ], + [ + "p", + "bb1cf5250435ff475cd8b32acb23e3ee7bbe8fc38f6951704b4798513947672c" + ], + [ + "p", + "af9d70407464247d19fd243cf1bee81e6df1e639217dc66366bf37aa42d05d35" + ], + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ], + [ + "p", + "7cb13cde0670e590f02cbe9ea0fcf1e05edbc5cc8a409731fa5436440181cf1d" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ], + [ + "p", + "ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49" + ], + [ + "p", + "facdaf1ce758bdf04cdf1a1fa32a3564a608d4abc2481a286ffc178f86953ef0" + ], + [ + "p", + "5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54" + ], + [ + "p", + "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ], + [ + "p", + "e5308b9c04ec5cc2bae04b5a70a7da79b28501a0676c243375813eb0cf8f4c08" + ], + [ + "p", + "9b99745fa17b337f8a6857f4369a4e844f914bd739c41bfeb939eb0930d41d7b" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ], + [ + "p", + "9989500413fb756d8437912cc32be0730dbe1bfc6b5d2eef759e1456c239f905" + ], + [ + "p", + "e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e" + ], + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ], + [ + "p", + "7f5c2b4e48a0e9feca63a46b13cdb82489f4020398d60a2070a968caa818d75d" + ], + [ + "p", + "1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b" + ], + [ + "p", + "76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa" + ], + [ + "p", + "e1ff3bfdd4e40315959b08b4fcc8245eaa514637e1d4ec2ae166b743341be1af" + ], + [ + "p", + "3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594" + ], + [ + "p", + "1539b0762732585a92290b32355aee503a3532959657f554b12caa979097421a" + ], + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" + ], + [ + "e", + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb" + ], + [ + "a", + "34550:f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0:Art" + ], + [ + "a", + "34550:3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24:android" + ], + [ + "t", + "Amethyst" + ], + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690242409, + "id": "0e184dda2b69d84cb2e0ae4ee2db7fc936ca33345f2899dea56bed3707161a06", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ad4e6c68c56053aa6c7bf62e379fab9a8bc5379fcb9cd413a32e4ccaae77a4b09b6747588006dc386a091fdc991ba84faae7e7362027f3173511f9c7f3766372", + "tags": [ + [ + "e", + "c37445ad00b5628b11bf4cf91e318f5372ac977d73778e8ee17613d5e357c98d" + ], + [ + "p", + "d2704392769c20d67a153fa77a8557ab071ef27aafc29cf6b46faf582e0595f2" + ] + ] + }, + { + "content": "Check version 10.7. it's using less memory. ", + "created_at": 1690242311, + "id": "7afe29b6be3eab6b18c38ba80db6b2e3e0154c5d62c7d10b96b4a73ae4e543c3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0cd914ffb217298fd679625ea29319e953a09006bf542270817d563dd294f9eddd432f03e0b702d6ff4664c0110f3c5505b3c07fe7f8a4b1eb5dd283d0a900d9", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "3e646fa6294a2a87e6ccd619c39a50974d6c70216eae79d700bca6c46179a389" + ], + [ + "e", + "f469473308fa5e222d3e5358bdd2eaf6b1c4c135f4ab4bf60dd6cd1c8ebfa7e2" + ], + [ + "e", + "649c1cf41e4c73a75604829b09a3a9f7042a685eaeb747a0158e2c70a6f39cd4", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "43d140f61e46792199dea8a8ab4634dd21f1aedaaa4fbd29add506f8029f1265" + ] + ] + }, + { + "content": "Does your strfry relay implement NIP 50, search? We really need more adoption of search. ", + "created_at": 1690241996, + "id": "0369c6437dcb238de1a62e0dc40432c0c3218774515b85bdde00a33921beac5b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5f4c9d0efb7f470ef3900e7b4082bb447276b8e33d89b64aecfc6f5580dbd0bc5246fba629504b0cd8f7cdaa1b6d0ed1ffd1ae2d528f101c34b4403942d95541", + "tags": [ + [ + "e", + "296e0cd484e6d03d7e316cf0fb7dee81c159fe09ff9497165f3eaef60b8515d3", + "", + "reply" + ], + [ + "p", + "d2704392769c20d67a153fa77a8557ab071ef27aafc29cf6b46faf582e0595f2" + ] + ] + }, + { + "content": "yWAWxGt2MBu3qv1x45J3CEVpaHvQq1lV7E1BuNGgbLKZ2RYbrC2mNJcmG4TRup0s?iv=sndyUVWm/4nas9Os4pF9Og==", + "created_at": 1690239415, + "id": "257c1c59ed183315bc7e62b01e6bf4c6f60ebd849b410113f235cd332438009d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "53496c038f4f10700b95c8c665a637522c417bf560624e5ccb814c2d4f36e5f8c7862d70fa3a7c5baf9ec6f38108a327a3966a9f927890c0cb3764df125ed850", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "7PIEPW2iCjCHl16lfM4WwnzZVxdZmAQKwu6iXqRlUQUOs5mTn0CrgPwRUw/kyfRJ?iv=2aJX1EIfi/f0hT+pcBHvrw==", + "created_at": 1690239404, + "id": "222a45a2ff03a0323452d28f264c454c2cc776d0f447c6a3d1cc8a3851d7856d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "99eb19b3d3548c6e91497aae7c8df7c467e1959c958be85336b6b16e50df644cfad4860c872eeb6fecaf3c816dc4c2961da9944f65b398f4aa8cb0e1c30b99fe", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Play has Google's in-device translations and integrates with Google for the Push notification system. \n\nThe FDroid version does not offer translations or notifications. ", + "created_at": 1690239264, + "id": "4a3236c13d92cc3cae2017b186a114d1bea42452ec0da1d7f7b6c37d691cc5be", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1c820adbaa8f6005e262c05843ae9360928a33f90e313589c98f5818420ac458e49ea1ba1695b3919555e849b949cdd35d4e13889143bf9f5afd0ea16315d601", + "tags": [ + [ + "e", + "19c3f76a4283f23d1f9f422eb114864ff4ccc56d47c6f6e4f8c10d6198bcedce", + "", + "reply" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "lutGFIB1gMuvUV+eCwKXKNYL7z6TIm2kDAlbYQVPx2GQ38X02lKyoY1n2hFkfEnUPkVhuXzDpduaxSXtvPkyhOPgWksP7H6jeOicnK9hppM=?iv=HME/2DSrRY30pbrUFJKFlg==", + "created_at": 1690238899, + "id": "39b67b4dbc474ea200d367c0c9a61556fa34b6867479ed4fb577f8461611a2eb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0bd1227154260816c06eee5008a7c9218032b827c2f6d4be9e3428e63b8735f47afce8c5fa6fa4c6c9df572385010dcf5c405a89ebf1776312ff6fbb2d7e4329", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "2/8yfvCiP34LktEny5ATUg==?iv=UmruzcdgxrWaGP0yiKeb8A==", + "created_at": 1690237129, + "id": "b73fd5a3c23b46473847cd3a4b4ce0f1f97d5dc774ad9ace37a99386f84298a4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2af9b7d79b24f5b53a916f1afe8b56ce9f9370e326b28d5dfdf5120bc0ef325576eb48efb4d108b33f8752effff8331329d704539a616afcc4131d8d33206547", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "Q9dvA4/+KObTdjEDP0rnDA==?iv=xiQVTdvQrHxPo/72WBkT8g==", + "created_at": 1690237059, + "id": "8d9ae6ec863f8e8825b8ef87444cf994127f3f48a379b50ca2a3400cb6c7610a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9769552001ddc7666d756297d8de7654e202af09fe0e6f2a633affd452abfc04357fe1a6e4a9e265b27c2873a5ef385fef305866bb0c9dd135f775a407b75c67", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "C+LkAFAhExz9RWkcHhP3Eg==?iv=skqU97xEJJAVJfodGfTJlw==", + "created_at": 1690237054, + "id": "a438e87973913596c44ef1fcc3476217af9151c1eb924bcdd2cbc209b0a5746a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7202d13005954592606d39208c59cc172e870ab5734b17bc3bbf6c46116e1e184cfd524ef76be0d48cead9efb408686080aa758fbc3fe456c02d82c39d1d3dc1", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "bUp6adO8XbYh3Sr1zpH0Tw==?iv=NFUyntsKUNXwb1GCVHrkGA==", + "created_at": 1690237016, + "id": "9f231718910c64f0fd5815ddba10b6f86bacbb554f47e02e66c91527de9a84ff", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8e0a14112190e4dad8552470767e464275b8eb66171a46075aac39601b0b814c0fa64d54ddc24adedca85f6a270b8f69c332f7d4e898b1e136043151d432777e", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "You would write a message and the message would not be able to be decrypted. Message would be lost. ", + "created_at": 1690234459, + "id": "ce6fb302782f0ada5c5b3ffec15a906b81ce681a8ff0bd6eda7ddf5b8a688538", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c62e0d9fbe9cf9d901d50be9a06ba57ad45b12e2de3c11477d71966a402d1e93dcf0c9a06c8b2635ec1445581d3823853a1a9edf60a57a2165532a60965830bf", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "bc4b1cf5a43e096cb581ef42dcf175781b767dad580f4451d61538ef4646c71e" + ], + [ + "e", + "3f1570f86b94170334e666d69d136a2181c3794a15cdb31c64ca2083ac3e653f", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "9110fe860a5e40a41e03d52d306a7b337ecf32d1090812ceaf423ec9b94954c8" + ] + ] + }, + { + "content": "I used android's base64.encode instead of base64.encodeToString function. ", + "created_at": 1690233995, + "id": "bc4b1cf5a43e096cb581ef42dcf175781b767dad580f4451d61538ef4646c71e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f84634af97f84d1367c715079323912aa1c4e95c075d18891ed5fd26f347fa2c406722be048abc1f3c72a86ea8de970b4afad2ef83752d52eb406729b9a01ea2", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "d86189ed8e68f18a3c2578f30a2c210cc38aef8e2acea0d6161e2db4a83994d9", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "9110fe860a5e40a41e03d52d306a7b337ecf32d1090812ceaf423ec9b94954c8" + ], + [ + "r", + "base64.encode" + ], + [ + "r", + "base64.encodeToString" + ] + ] + }, + { + "content": "Qz4ewQ+n1+swMl4fTB6D5A==?iv=bP1lZMkZaTb+/TvOpcaYOQ==", + "created_at": 1690233455, + "id": "2b9a446fa07a6f76ab0efd6320f86f45c3724a75c1a7127f479a5772c10ec0d8", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "66a171a9de4a483d2f5503eeb559e6c788e14a4e0022e94cf6ac37e1edbe06c291814944b1843a4c9baeb8010c696cecc0076190b22790bbd076e209bd4beb18", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "GFHPMUZu/xt52Emyqe2/yOgPgRyeWFlJrL+1Y0qZu7WZj62+TMnyYzbSh45Er8qiu5HMTd3NBQDGdb7fcpqCvbqYIM9i+GZTaUGLxK4kN0IEx3uToJBURlAsvXQkoBZB3r4hx0nxYpMMvZ6vF0G14A==?iv=HoxnNfugsgE7IR3UsrlW5g==", + "created_at": 1690233445, + "id": "78bad12a81ae89278fe090f75f91c59f179366c7cb2e8f137af78500b69500bb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "89ffa0617d9dd878a893848d3d2d4a891178d9785cd5b66d1c1b94d5ee1959638224ef5b3ca8a6fdf7fb3d3dd9f802f9c358e513a325abfeaa537ffe6e5e96d6", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "VxwuKCcgYzAgWi2niqNgp5PDat8/kgR5kgj6os7tBTKDJT3/gOqedH8twDrv64BZMZ3LvtdZGlQzLIcgNE7CAg==?iv=o0QVnuaCOW9yrN67IvWZCQ==", + "created_at": 1690233329, + "id": "fa22a5e06437a59b12babf7ca2f213817137741062424da50bab867f668da877", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6b40692264d9b0aac45d8272c44a4bd07bf3623ce9993bf3074892b3c284e90353b4d022ded1537bf4023ec162cdd259fe023a19dab7195504ede33fe2f255a0", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": ":inky:", + "created_at": 1690233253, + "id": "d41516e423d2b7c3f4333c574f4120bc16ff6a820e42e2b946a389269a87267b", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "891aadb039c5e1e887f3c689e274d9b17710d085bebf71e61695020d74ed6950ef65699e7a0477e463b457b2784edfd85047a22d12207c5a60978c5f0e386eff", + "tags": [ + [ + "e", + "7d48910e219b9145d0dc6728032f393dd6ab4fc7cbaa1f230eb0e70b6a88cc44" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ], + [ + "emoji", + "inky", + "https://yunginter.net/e/invader/inky.png" + ] + ] + }, + { + "content": "gP4BTdaw8HAFyHiTD1l16g==?iv=AsRwobD8FAWgbZI+GS24ow==", + "created_at": 1690233247, + "id": "25fa7b6874d69b4674b5b835bf6e5557d51abe7df38e7f6da991c2179abe7135", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "48ebba404b944c20cb201323d7a729a6482895ae0d05284d752f6d8b22368aca598cd931173cd34b5c31a1182380bf6e33df572608ec32b92720170ff9968609", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "S/toRHn+x4imuEgpE3QBog==?iv=FcftlhIXXfoW8Jt8IAWWfA==", + "created_at": 1690233237, + "id": "a98032bead45c8a3129451dded37a1eac6e01da6504903c93fa0a1b62d59247e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4e556f453e9de83f83ef00f4370189735df123e41226f8a33c9927b02739aa5c9a3b2dce93e5cbd698a28192acf77dc80a47f0be880201cc5e50a6ddc80ea80c", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "not really, the F-Droid version does not have any Google framework, so it does not support Notifications and automatic translations. ", + "created_at": 1690233206, + "id": "9c597be469ced87df16e5085a19810af53ad36c5cd6b2d1f0bdbac24b4775e77", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a368541efa152d50641999646842fb571dac8a24b754e71023cb30d33d89d94b87098c408a3a2b8e68521a13a771942d919f1e9fc5e2894c26181333213ae97a", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "3f3832ea69dde0c130aa13be5e9461a48548322be88f9980d68f5bfc0585c4b1", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "KdASbRnhHUXkBGGr6cZluHyz26/QGGb37ud5Mi0PXXrfNaqv8SJFDbM0SsspYGfkphxbkm+OBtqQ/uCOT+Kfv6yebKDzZc1OFFqtLFQ6FhnoB3RdB2zlzx5YLeBm2+zi?iv=pFCz97lTnwgdru7KL9vVNg==", + "created_at": 1690233173, + "id": "d4f4bf086674bbc8654ef1cf38d548e067a68fb7988c530c2f5435906c063c35", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6743508bccfdfda8fce6e04e22b39f66ad2a7e80dc884a96b6a5e8e74c3fa31ec3332c6544840816ca43caf2e0ae8aa4f7808c26d788f3943931f99eb7fe0416", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "f2K+pgicmByWLG1+7gJo1+gHYNOWUMZ+JtDF32q/JYI=?iv=2Pq0gyLVHIi8Z74r49cG0A==", + "created_at": 1690233137, + "id": "e4b88e84fcb8678183bce2e37d1cac638f752e07dd8c94a74729f65862de4616", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d61dd8721786c9969e0359a926f736a4fec4a7608c158aaef480fc4326a54541299ab3ef901613498829e7e4701851e43ef454dde8502fa42e2c955cc56c5bc4", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "bz+BVeVRvzE8GMyhbckpyg==?iv=i7V1ONc6Sfrao9ADzYJ0TQ==", + "created_at": 1690233110, + "id": "9c5fbe5cdac9797ad05de2ed9db76a5dcb04bb95bedbce1d46247007bf4c3d68", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "293198a3b3a0c3d9ca30e84b4f03d39e3eb2536e02c91572b41e55c6680d775ba611f1bbf0bde379ebe774f1a752716196ffe4a13c1818f0b5f702f209ba0651", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "+", + "created_at": 1690233094, + "id": "eab230246fad596b82ed49e50eb6c4ca97ad39a63f1170e7b3c630079076b294", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "771b470b3aae05e08353b4259c3b4c0e339dd257759f2ea3b75518237c775b3f15be3d5c188cdc72b78dd666e5e2c0d7a96681973ae7d92c531c2c217c418677", + "tags": [ + [ + "e", + "8e39d79d31dd146b5be3e2cd8fbc54dc7c0e8ddf1936e982cf223ac21045aefd" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "0.70.7 if you can use the APK. Otherwise, just wait from the PlayStore/Fdroid/Obtainium. ", + "created_at": 1690232704, + "id": "b6244ed1c0c63352a8df29cb1a689cf8d920681aa29d5b19d6cfb8d43f5b3724", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2abc85bacebcb5da5ec1c8fd88fcbfcbbf789a6d53afa7d9f3f95ba377a4aa940dcfb4e6286c2eb56cee6bce2a975f2b24a6b7dbcf14fe0c884c7f1fdbbed7b", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "3dd05c239b3eead3467fc3966010c9236722c0dde65a2fef32701ceca6845255", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690232541, + "id": "98450fc53c6bf56129545c1cfd1710eee3c337381171e996a7c2d827ea1f550d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f0a92f7211f3806a685412084ca06a8d62605780d509f3f2f1846e2d08c1a19fd2809672a71081839b2d46d8cb9d976a2e3e1437336bc14ab616189b624410af", + "tags": [ + [ + "e", + "0916d8bb10357ef41e467ca5df22f1a5972848b6e37e53deaf776498eb9930e1" + ], + [ + "p", + "444eaf65c2d35511fd0dbdbb0fe3c74139fcd144de00d22d57819ae5fdc76ae8" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690232403, + "id": "f698c1f427edd31e0e568f9799545b441e4d9ba1fd1ae7283eee93ea5b921245", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f1345b5bea6f2ffe95c042a79e9fbc50cb81cd55e52a822090f465e06bf0e09eba273786a6c6081297e6a82b85d081438621c05ccb76a6c27ba1fd846143fde", + "tags": [ + [ + "e", + "05844e361974d2f429b58f3121098397c14efcf6b5f01cf12792d406c9d94da2" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "### #Amethyst v0.70.7: Coding is hard. \n\n- Bug fix for the DM encrypting bug\n- Bug fix for the back button going back in the stack instead of leaving the app\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.7/amethyst-googleplay-universal-v0.70.7.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.7/amethyst-fdroid-universal-v0.70.7.apk)", + "created_at": 1690232204, + "id": "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8bacaafc49225c0eef14edbafa84cc784973437975f44228742e91293f1c21b413ffb83324a91ab64400193c5765d3bd7d72478b40b912a810303e41941916a1", + "tags": [ + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "A/e5KObtOus9SzOgkNcHMlfQgCLHcWdCivaz3lfPoygriVTNoNDuO1XudVbM7ZY5ChnivB4u6Gj2odhYVTT7hg==?iv=SxkaG4il8/q4Zn2uTtmiDg==", + "created_at": 1690231960, + "id": "fb28220996d832bdf65a884c43c3e0fa1887225543313e7c223af1e18a3863dc", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5a54e12b362c66c3a4a4234d15c5f2a2f988a1786af4c5e171f86eed16a44ba327c80ea43f65c68a80aca17354be9df3a45fe00be09df3180d701164ebe725a9", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "1nx3MjyOTjE9i+BNK/hEZc4/b79lFMz08xElOLD7ogY=?iv=hwTj8iR7LfwqoHtLz3NZmg==", + "created_at": 1690231858, + "id": "f24bc09b4f67ac27baac41681704a97a450895223c2a3a1d2df7d95d28e91261", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "021cc84269b26c4e73ca5614030b49f18bfc58e65a92c3cfec8003d0210a327dd87479d91fe2e470e826368a1103eb05af1c018daca2fb96e2a316c2ef04a317", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "+", + "created_at": 1690231061, + "id": "30c908f6de4b1540adc20dd9ee8c54f954806753041c4e09f1a45475317ae2b4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f4121d7b80ff896340a85fcbf0812b78a7b0bd41d184bedc20d4cc3adeb57068fadc4feb615b25a42288d2ad34c7360de53d67440ca3db8b918091e527175be5", + "tags": [ + [ + "e", + "806bf35434fe8274e3e903ddc2bcbd761edc4cbd895e2a184bd879519b22578a" + ], + [ + "p", + "f1ea91eeab7988ed00e3253d5d50c66837433995348d7d97f968a0ceb81e0929" + ] + ] + }, + { + "content": "Lvf0zVwlwHUtikVDSv1RXA==?iv=uVw4/+FGz10yZRjdp45HCw==", + "created_at": 1690230486, + "id": "1834ceeff3d3c1a81054b9b4fb57f8ad08e277503ea8ca0800a6fea26940562a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bf4fbf6bab096234c166c1ffaf220eb91505f27eea0e8ccb4c4b2a87c00fb534f51662669b98664546cbce1324738d2ac227f7c5a035221992ecb4252d1accdd", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "73jzDyeieTrjiwJoTnNKZw==?iv=x4Yn/MQpDRXqOFJ8tN47Mg==", + "created_at": 1690230482, + "id": "060df5b6bb4d97478ed9f54bcee5cc1f5abeebf1fbf162f2bd10d46cfb3b7e35", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "01024d0a7c43b80be39140a28d311a66f86ac105cd465efd3f9698b4ff3e1b432924a2385c3aa4d757897e012decdb0e385abf892be4f398778c1f015191848a", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "[B@8d25849?iv=[B@9ec054e", + "created_at": 1690230354, + "id": "ce065bfabf37a72ce7ce604c69da4b6e7bf1eb175f1ffa3f256580a47d637c06", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f4558138d334b34b653c44eaa5df46432ce1b5a93b4eed8d67bf09b6ac07824db55c605cc5d177274a4766f05f2a3ad6f10f20a4bfe0b323245dc8b0cc70401c", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "ovBLFoTTdd8TOTzjD69NYg==?iv=Q6muB1flX0fXudU7Box7Kw==", + "created_at": 1690230346, + "id": "da53a14a794809086bc065c1cd60e2c0f42a5a94809cd6f60b14b6a2df2022b1", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a0314cef159927d1d2925942595034e2e7a3ee3f1e97161106114f9b40e2e9012bc76c823cd42f3370844b3cd154d6132da0439c9ca2bf94148928ed353a37e5", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "client", + "coracle" + ] + ] + }, + { + "content": "Folks we have a new bug when encrypting messages in this version (we updated our crypto tools). \n\nI recommend not updating yet. \n\nnostr:nevent1qqs0lvtq2hfp39hy9krkk8ztc8y8ck5hafs8sexs6umzfe2azz5vjcspz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyt233p2", + "created_at": 1690230275, + "id": "bbda567a7e9ada22cff285af2b7af45239a0bb91022355dba78e72a629f18345", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a206a2202235726776f0fe90c52f8d780a6c240ed0cc3a509ac4d444bb97478856f59d497774e5d769077d74deb863ec7278907d507b12d06d16aa8323be7d75", + "tags": [ + [ + "e", + "ffb16055d21896e42d876b1c4bc1c87c5a97ea607864d0d73624e55d10a8c962", + "", + "mention" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "", + "mention" + ] + ] + }, + { + "content": "Tu2w55+Du9shxbY5c/XJTAH7P9gpzolIVU40qRwMZr8K44HlgcZgXUnf+Dpmx7JdFTignWLS0dbc7c3EtV9JfA==?iv=kuysIu6gBOGKjEkgOVQTig==", + "created_at": 1690229940, + "id": "2e9380fa35888db71b7a34f718f3498537d8fa0f908eccc32bcdaaab4e3743f7", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3ae5ad7c3d55d7b6fde18e9f534494724ac7032fc6014d494ba3fec8f70236fcfaf8c50b224490d1ba4339a8498b98cb3bd7f9f9b8b3495580b882b655bb5a9c", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "TCnpqllIWnsFTtu3sjc7OQ==?iv=SGT6iPifAUL6ZaC/lOaDKw==", + "created_at": 1690229836, + "id": "4ac2618bda32746e23dd03d4f4b8b284c86e6f1c1b3729f755ef67c22a2147e5", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "07f8b94b38d9f9c13f2e6b05c62097a7f019fbe62c850d2ea19dc3e42a98952e06a42b922fe8ed14308ef0050cbb64b0dc7562185573ccf12dd18f5b1fa596cf", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "nSTEB5yiTJ4P5rUuIgymnK8WzsU2D474gZuIUMnPpu8Sv/pJjmXqAUSnHvu5xVzC?iv=12q9C6xeozl3+7XGJOA5Vw==", + "created_at": 1690229516, + "id": "049d7746738bcd7e3d08699dfeca3ff343c0e87cd7f120fc88deeba0b7c45900", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e3c2791ab4e94d129a5b639dc95e2cafef1f891435cf994974d153c42e7b0c834f18884f0651d0ba2ec5dd8b309f4374ae402246bd0bb3d078cc5e686a491f75", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "[B@a24d242?iv=[B@ac9d253", + "created_at": 1690229467, + "id": "ca97e741fa2666883892aa47fa6c1afaf5d92e9b7f9b6777227de0ebcf947090", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f91893f237a82c249d3b950ab64fab6e26906a495e83336b93389a8f88130bf7edc961702b6ce0ef6e4cfd5992551d34c76211f6a974533b43ae24cd0dd2870", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "[B@30f641e?iv=[B@b42f4ff", + "created_at": 1690229418, + "id": "cee035666cd2135df68562f2a8841a9a8ee1fcbcacab0e2eb529735155d35809", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "38dcbbb36ff2b9a69292722d136381308f9d1dd71fc20305b6e3544bcaf9a491e78e7c5688eab8efbc306e6feb98fea6b9840bd8387742aeb4c931d099c2ee02", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "### #Amethyst v0.70.6: Slighly more stable\n\n- Fixes a crash when onNewIntent is called before onCreate\n- New Post, Relay Choice: Select/Deselect all options by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- New Post, Relay Choice: Fixes missing switch when url is too long by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Adds missing OptIn when using GlobalScope by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Refactors Crypto/Hex/Bech classes and dependencies \n- Simplifies relay connection status\n- Moves to OkHttpClient on URL Preview Queries\n- Moves to OkHttpClient on Image uploads\n- Refactoring of the Connectivity Settings\n- Avoids crossfading animations when loading NIP94 and NIP95 content\n- Moves playback service startup to the IO Thread\n- Activates Strict mode in debug\n- Updates Firebase version\n- Updates Hungarian translations by nostr:npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.6/amethyst-googleplay-universal-v0.70.6.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.6/amethyst-fdroid-universal-v0.70.6.apk)", + "created_at": 1690229318, + "id": "ffb16055d21896e42d876b1c4bc1c87c5a97ea607864d0d73624e55d10a8c962", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2fe7528a93ac8f89d1d1af8a112e3cab60e616c244b29ac10b87afcd452f7f2e55916381ccfbf24169a0134e7e61e3ca4d39b3fac0acc4d7209edc2d03b4532a", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "738f69184aeda675002b687fe47c8e9e2f7b1a267d6f9145b1193312f97c18ef" + ], + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "f0rxFkXgSlpTYJRIga6xkw==?iv=pkfpyqUbMS9WPb82bwVHBA==", + "created_at": 1690228620, + "id": "8deec47d7370fe50914436b50d46e3e9781bb5a01b9cdf57744e05ea9a44ac81", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "de222d3c13251b4f350050b27599a84577dc45865cafdd1802581eae735291346c4063a57bbb48d136ea307fe2564bdc7a5553d864c8e95bd01622fbed8cb24c", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "5IXoOnU0zP2tbig0jrI/eaMHmHTmqe4G0hMJM4JTvQMfnXV4ARJu7K5X8P0dtIFmuE/LJ/rXjjGe8KK6M1LSIbFoE2u10thYAGCspZTFmqvvaBqtWO1NiRlLoEYHK+h3U0W8VAv+/RsMRjUeEVEoVTeCf6FvLrFZpjL5x4pB2uk=?iv=rvsakuPh8qVCx3bTDHtXEA==", + "created_at": 1690228608, + "id": "b4babfe9e28154ec2c49e4ce6ff357946adc7218d8faef5fdb97a6bcc0aba755", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "69fe9bed3f5bf09a70cebf02f93a8bf7678ff2b6c9f536ee824546fd81d943009a950ff4ecb07f777ed3a09316f7bd8025283d2ab40c7b60b44236a77e5c5237", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "e7YlqY3i9/YsfIv436ynsmIgk69dMdhiNLpU5Im8cv9o5IigoU9O64wUr/8phC38KRCOqL379X03kC7/MkoX1Q==?iv=C4RfGcjbmCwARvXaUJ3Yog==", + "created_at": 1690228538, + "id": "a3078be67b927101c85c94b18d3612186adb9668d40b8883ff3d4c158774cd13", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fb4c05e3a908b05329b306ecc418afd06f745bcf7be1c48e6ebd4940c98787c557d6ffe88b697916879770a3543cf56d2939090d07e00c67f5a1522b0dd1c7ee", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "wFpRKzRmd0CE8Er6sSdFOaW+SLRywldsT9xuc3gyxJ0=?iv=4n0/TMTuAAmzPgZMQLqixg==", + "created_at": 1690228503, + "id": "04fd69bcf970a32314cf0d89075521e349cee6e1138847cd30a91bc24f6359df", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bc0182f52b3135918c9de527bcb295571970940586b1a563b59edd81e64b9d81b72a67b701f10d3a78ccb4239088d93fb5506e84ddfed0b4fc5df4f7ece6059c", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "G2fjhfEAILx+EERl5rBcT8MiB6OJpJO+YSBVFwl0NHg=?iv=jN2FU7QB9W8RbTfPSMuIGQ==", + "created_at": 1690228433, + "id": "f06da5c6ccebd61777bb9ea7403f8cae990348e04eb533a321e603d986d5f818", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "331f43a63744baaeef49eb46e8308dabe78dd699a81bcd9113724924296352225f0025daa5cf0fe4accf425224f3d817861dfa11425260f47002f8a1ad6b8e40", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "Zv6wgC9t3LH/CepLmgk2W18K+/TJSmVd5spsy0DXu/z9ihTGoyOrUQ36qwVGlXvV3wDsZNSh0GbKmIcqtlBUVrGmbx4xH5/SeT5j4M/VpQXlfK5sH/9YaSi8ljvx8ukKsJoao4abVsfrY88FbnZEhw==?iv=VR7r/xc6QJ2HkDfHLSsqXg==", + "created_at": 1690228423, + "id": "b4b7ec12901324d608dd17692f09c22c6b3e8b34d32cb68159c9871225fbd1e4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "710df5f81ac0dba5bc27dfa4272e9fa33e5057e1c573ab5a13cc4d3e59eef31aefe5af8368f1d95c33b9bf5aa1363e0c889a45780ddbadd151364b59ea78db71", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "NtMkCfX5hIj1XxHNo4bfPBzMGEBs9fT+OVdIG4wINrRh6HEs4Qz87Bn5bFkagTc6?iv=b/mnWmOf5k2wSImYITU8hA==", + "created_at": 1690228342, + "id": "4a70cb34f63ed31520032823021c713467df32bd648de0989b37f35326ea6f16", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0638f065e9d88fb8f930b2a5f97dd6a8845cfe3f453060d9e5b926d4866735deb1e63ccb04c8c70de43a5e0b41820d87125b15aef716e2167bd52d97e5ad3bd7", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "KsvZMEAGgsnCrhObxHfleeyGrrrUj0+7b6W/dmI7kKbgvZdRrxc+99AtetHSgEPH?iv=8Dzaf+ZPo20rymUi9gLMBQ==", + "created_at": 1690228325, + "id": "aa4fd0a517095fdf6e1805dd65e2933eb9060cd48d1057408cadd8f0bf0b4f75", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "70fca6c5e521629504e3e70eb57fd5dbd41564946bc53ba875ac8f47794b699c3797e4891326777d03451e03623cac3fba5dac221a55a8535f2b2b47496afd96", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ], + [ + "e", + "3b90e6bc6ed1dc8d54cd7d16b31fd55227ea422dd6d3877c6176fa0f1adab26b" + ] + ] + }, + { + "content": "hOoDDnlWlF0POet6CinuxFBVdITw1XpHmXkuFQm/ijXZ5dJO1kVbgcMStOh1ob4/?iv=RxSDrWU5WZL5AAwJpM6/7Q==", + "created_at": 1690228290, + "id": "ea66a129fd8ea1d89ae1edcf883385210b199834b7659f88925772758005111c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1032781f5e623eaa8a0b936aacf177f86d2620db8bb62a2e80d5d151ee020268b1cdc93f2ac6395175f55ecf62bb75f44c355a05a5a39b4448964c50eab0d2fa", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "OgJTFpAbiLGECgMTDUUqaiE7imMVLEo6xgC+UMXsdkM=?iv=vWJbTiA1NAH88r6W5sAkVg==", + "created_at": 1690228276, + "id": "1b79f0dd1cbfc588c73267a81d7a5fe339116f5b3e7482dc0e888ccb62c49b41", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6e0bf43c28dd4f887c5636d86b78cc6f5ab8b49a78e3c47998303d58e0b4750b7c601a36d77bebf37b0419d85d3b0b492f57f40c9fed6c934c68967acb2ec98d", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "Wa4cXONJIYkjZ7UF1W38RXF8RGOYF1Ga8H2Wu9f2Qm0=?iv=jz9nL8hnlLnyb0nicyHBiQ==", + "created_at": 1690228095, + "id": "071d645bd306db51ca237a9b97978b50553734023bc27866b5e63372e5325edc", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "51208ce6b3fbfd750ea7dec69988b5b1d5d460829dede36e9550f3c38102a275f0fb556879c9137cc0bcbbb2dac53c6f89ea5d72742ed00e715b571ecc502046", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "aP/EaL6KMt0cKss6Ou+Cj8gSeEiLNwz4lxBmo21lLp1Oxrbpyhb7AiPB/yaouzi6L34ERksQXG90Qm0iL6Yxmg==?iv=dJU3gVfMgoeCeTN7FmTL3w==", + "created_at": 1690228088, + "id": "a5464ad10b971eb8f6f5111bda5a64b9d76fad5b4bfdb9f26aa40db369eb56f0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "59c5f55ff8176d65b6bc2249ab42f73c53700a12aefac764eb34b47ab2c8497b59629a3b1a1f58f662c2e22f9df8407fff59d5765125abb9149235044b7565b3", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "UBrSmGFZyQMMAmYON6Z0ppQTcoU/35ZllF3z69xyc9a1TtJr+vNCA/9+FhZH5ahhIbjTE6ZRjk/d/JGyXbS3gmkVkYK01xGi2kkwVCW6JD0=?iv=05pAWlAlwMAVbWzQOoq7VA==", + "created_at": 1690227805, + "id": "58f3fd95d3afee12a33547c3f793d7a8def09adb3ffb6ef3760d69288c66f56a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d973fc6baeeeb7af8d476f13482c3d6a1d14e68ebcdbe22be72a31b11d9258e0b5ea46a84f6341028075d2c525d22ba27a3fe5dcf9edc71f9f317c702e9aced1", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "uppuL+KowTG72APQiuYQzEAKl6opOeCI6jaBifrt4BvrJnP0SYvg5JvAyijt6XRmAJs9HjO83GJ8nExLYZsPqA==?iv=CcFmyDxQnHbOUqNJDgbEZw==", + "created_at": 1690227791, + "id": "44e95b224b6998c70fbd3f20bcdd17345fe2fa881e0cdd81c80a10af00e4aaa7", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "368cb4004b40583c3fd71a37de17b4977b7c9d8bbfef8d976e79d090175d173b85b902f514b2ef2123af8c5fa8e71bcac2d43f72d56f3caf12de3775618d8733", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "EaRIVyoO1jSDRUYVqLN8g/yllaPPxFSAnmbTSdtyab6O8Y3YGMkRO0Wg1RRphDRM2nEaTZcngkvg40ayVQzgv1mTqhxkEiWP+YFFLV5H6Xc=?iv=IDdLzaZDUjIXAq4s7YYPpA==", + "created_at": 1690227754, + "id": "276c7ede298ca1a7b642c28e72cc9efe263e656e2e7c12fbe6607b07fe7871cf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "80997a5c0787a7fa042568bbb43c147117da062fae9801275015a6cc21596b20f6944eb51c34b809890a5d2499bc1916e988d57718e6663c6ce19d7f5127308f", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690227628, + "id": "efef075535252bbeeb40f003ada1a7f295f583a56bb38443778318d17b6a128f", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f5659f79dd35cb3b5ae418cc62a7e28284cace0ae69b66a2b39e81770e368bf6dca9dfbbacf6522914bccc78610c601d887af774c946ffd1e71f2e57908d783", + "tags": [ + [ + "e", + "23a9662d410b7000b2b387b0fc7edbfe709449e8af56cd88ab0205ace1cd2c74" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "cd7zf88DXaEqGtgPPfoyAL4HcfQy/q6DtAUhhu8MnJSTEHb3CKmN3uMi7EJbWiRM?iv=vgZdKLxwPhshs3Mh4O9C0A==", + "created_at": 1690227507, + "id": "cd53313a1754ba05f4b6ce93adfa39ebd09f3234f978a911fa054272296f0191", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b0ab7330919cb4698aa671989ffe34a4f0864b311d389c9935238fda82ed238f471ab9f211cfcd883866e7aa479778ed6522f32b35a288f9a4caecb469df1e9a", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "we5Ld/YAGVvuGerqzNpvT0+m2DKMxDuuEZsdj+ONxeo2NIzwzP2VfsU6gfu0MBp2?iv=i+YzLjzuAAtDTXNdLEluig==", + "created_at": 1690227490, + "id": "0a7a433e967a942c56c61148603568f473f0a05f7abffda23f493fe5c4dc385c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "460c4329424abf9fe6ab5dd4af6cedd96aa0d9b7e367159f61562c082d4d893142a6c07dc339ea320eaedf3eeb2641b344f26b7102fe1aa83c900b7163d07d0a", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "+699S2I9lB0O3ivY+8B1btsWTArJsDJJCrBn9FgU5JX/H9emLLrkzql41Q68FLgh?iv=hih4I9oS/uZGPyD1e4nJ/A==", + "created_at": 1690227474, + "id": "30dc3c835502332ea13fc39e86ba050d541408eaaa5aa8e9746cb552dfdad582", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b5b726b12a911c57c390293e71e1af2f7ec22b062d1ca70e9eb3c7d3d6c6d8027269cbd5b3526b7446b4fb9f437e3edfdc1eae09064772144db84311e537691a", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "1UIB/MzWT/7eZpeBwTDiL7rCj4X6zANvyLUjidDqTb5aQknFxJs6y+wMpjwoSiZf085nzV8EGMy0Q40+bZ5RhQ==?iv=V3+jmN+XsSkq5H/koJE+6Q==", + "created_at": 1690227338, + "id": "fcec1f5080b1a3804ddbfd8e8f4511767e866ae5197a541948fef8a48c97ab12", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "38596c01b3657c7fe45b3dc9b4c57e350e865acb0e4551ce38634c52ec7031c4db8d9f9a653072669556788ad0c10d1594cd143a56ded585319a44600b5c03d2", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "3xR4YgFOWvu+mwlBXCHBcxKkbImhQBAwqrt68/YWq9bhCLUnmAb3ug9hIm1vQACw?iv=FjLifC80ldIYZoUhlZUZhA==", + "created_at": 1690227306, + "id": "b98730e230d2e0d2a5989591828bd102b14c5d9daacb8bef89070c63a3c516ca", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f50a1cc8c049b954b51841132ea92e2b913c550bc81a9bfbb5fdc03a2aa018c233e11048db7b45a13826f3a5ddf3f7242f31f5d10aa1bd4e63ed5577c4183fda", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "oC5quZsBkzBT7QbwF8IaboEGgtQkfp9vsJHBhCZC420=?iv=I7afX/EYBNynEoXLx/Gfog==", + "created_at": 1690226941, + "id": "c1dd8bdc3032122a8fdcc7f646e35e499eaec0872f0455f61732e28b268467ed", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "afd4b73d65d83a14af2f8e33795a25c2c736a3241b670852abb254851e99fa2023a9a82c4d53b46af8a0348cad40c29fcc9b295ab01d6e8ebecee711baa12d27", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "on it... I need to massively reduce the amount of memory we use. ", + "created_at": 1690225524, + "id": "b127a0cf66668468bb6a1bf83ae6837cd394edab9b0c9db875f68a29e7046262", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d956e9f64a6ce9bdb2332ea3c6ac9f2196a848ac011d6898083bf924d7bca3a377469afefd05e4baa8b28270ea6e4189f6109f42c83b05afaf5e265dcda3bfe0", + "tags": [ + [ + "e", + "b346ca28b9063b0d6d9d81715a716ee5472e99e7a0be48d1e53e94dcbcf2e807", + "", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690216404, + "id": "cd60ef428467bd132a1c9aad9f99eae847563c8ebc8eec0e41a753e1e060c91d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1d5354a460dd7e1a29a7e15f783bd456c47f4439679862a799e01c4ffdc6a7054440272d4600ff8410243556e6d653100063c78822057e24cfe402471acb1061", + "tags": [ + [ + "e", + "c7662cbd99c71e97772a6399985b658316a46cba499a1fd8e220e09c915534a3" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "+", + "created_at": 1690212757, + "id": "f6a137add3e53b6f5f447ccc35fcf84a0a07e69dd06bd8aaa19d6808c6a20b88", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a370fa9b8e5a070f7cd98c966f91b6c2c763cbfad9412a94d3250e55a7b0efdf2fa1812c57f5d34f699a16d4a598eb9241704b50baf113872de8558722d1994b", + "tags": [ + [ + "e", + "3a9d72b17813409f277e464f823a36d81415bad69dab3efe7851abb848751218" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "People's favorite punch bag. ", + "created_at": 1690212178, + "id": "c380b568b96380709a679b97f1ff03bda1707dbf7c486e2f00610475e1db889b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "815038531ead8cb887ef5648aa6389dcaa51475a57295f87cfbaea64c69f8975aace31240a926c26b1f650547fe8f08c14e92a82f147e178204e3a13fc5a58b1", + "tags": [ + [ + "e", + "72820a1aa7f666673f7542abb7ab672b330582efda66339ad291189c2710a3fd", + "", + "root" + ], + [ + "e", + "6c78d72cdb718c818f872b463abb331294663b339823388b8284359761581d2d", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "577de06dce160a0379163a4bb7b680be3e0a0e1c68de6e6ba8c01134b44064dd" + ] + ] + }, + { + "content": "We all have multiple identities", + "created_at": 1690210832, + "id": "dc092b256c585056a6cf94f3b1c5a560d5524a7ffba61a85fc84fbc56727edf7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "05d53d798b60ae9b7970cc027106c04daf72458f42be131d2fbee901b90e74cfd5e6a8962a03f593c5354ef0da5daa436b68e1fd2d7b6f9790dcefed92711009", + "tags": [ + [ + "e", + "ff7c9d42b017c4487ea0608a61b792a5e52e86d5816521ca25cc7da2053aca7f", + "", + "reply" + ], + [ + "p", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106" + ] + ] + }, + { + "content": "Yg/ItlxjLuKDzlhNZxNzxUFW005logbVHbRYOs1iCjc=?iv=VB8/JXO4MSxwX+I8wS43pQ==", + "created_at": 1690208557, + "id": "4efcb194e78ac43494f6f83c4113470880931392bb682bdbaa1dea63b671fc22", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3ad28c322bf12d3fc32d4ce05a42468ff299676b923ab408f9f72d1ec04ebc3565472dc2e998407800584fd92fa4cd22dc1d3db87f3b9b51716daa53a473b236", + "tags": [ + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ], + [ + "e", + "eed8c3d0d10c88c26ddf9afdd5f15d407da027fa811b6aa33ed634e81835ed59" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "Yep, I just have not had the time to work on it yet. ", + "created_at": 1690208431, + "id": "6c4bd9de4e6142f6aca5107f8f88bc021142f2e70ea25a7d569d38f49934de48", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3688bfafa385f849b731462f1f10324b54f5a08489ed55b6fd1fe52d190d2d4e40426c3ab3bfe79a2c72564df5021aff7e7215f69def3148175f415222add0b7", + "tags": [ + [ + "e", + "1b3d29f67935201aab597b4194a92a56f9d55ae23a24558527454b2284b82f20", + "", + "root" + ], + [ + "e", + "6dadc904610d86e20a7c364bb152237fe0185df7e3ac8ebff5b2f89abe751c04", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "456cfdb590144b9cee34dc09130217a938446b2558f06624f1cf160accd0e57d" + ] + ] + }, + { + "content": "Interesting... is the Community available from the relay set you are using on Amethyst?", + "created_at": 1690208409, + "id": "9adbdee8fce306d17e61e72d0931bd5697da102f95b5d17b0508b3238882e557", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6ead4535864f2f8b092158d16bd2b4811d0819464b0a5d3aa63aa46f482fd1895b142f52a9e0320933f3b72f1e2e31b7ae488eec14e1acaf0440ab9aa083d217", + "tags": [ + [ + "e", + "3eb890730dc61b60344b8b2f832943fcb41faa9288049a3e41c2523f7a84d39c", + "", + "root" + ], + [ + "e", + "3576a8688b06d3e39f107ab0b7da6aefc03a32231223af6a52f5b353711f94a0", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "6bf33edbbfe348c1f04d9b708fd51fb6004485812adab49f1d70ad0b66d7c715" + ], + [ + "p", + "6bf33edbbfe348c1f04d9b708fd51fb6004485812adab49f1d70ad0b66d7c715" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ] + ] + }, + { + "content": "+", + "created_at": 1690201062, + "id": "be0a53286c98adb52eda88de58f83793c159454533a225dfb5f141362d5635ab", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2ac7b39051870c9cca6086e889f515d0168a0156a1c636a0006c0d549cb5a06a3eac7d3f4661470e51a0692e51e60c9ddc10dd5c43fe89765f565393bd5e50e", + "tags": [ + [ + "e", + "154e8961290541af45697deaf2537663da54456cda58565fe8350e4db201dfa1" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ] + ] + }, + { + "content": "Hummmm this one? It was the 6th in my feed.\n \nnostr:naddr1qq8yummnw3eyx6rfv9mx2mnwvypzq9eemymaerqvwdc25f6ctyuvzx0zt3qld3zp5hf5cmfc2qlrzdh0qvzqqqyx7cm9ctex\n", + "created_at": 1690198600, + "id": "256b47a26fdd08ce0e2040850d7328a55938c5bdb2eff7598f15779b15ed781c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "cd94f05a2af150bbe32a5297d897eaaf53510b7905a28bff1c4ab581cea8e6701e4646dc6c188ccad884eb5f76ca4a2d9f6d20933b6c3d7dbc30a46638ccdb5f", + "tags": [ + [ + "e", + "3eb890730dc61b60344b8b2f832943fcb41faa9288049a3e41c2523f7a84d39c", + "", + "root" + ], + [ + "e", + "b08691e0f9e4d9254981f1876a430ac9a9f248bf26e54f06109a7002a78a6e91" + ], + [ + "e", + "dabcd7ee3d178ab8efba9949dc273ad96c106e2d2394a986af153ddc17a6390a", + "", + "reply" + ], + [ + "p", + "6bf33edbbfe348c1f04d9b708fd51fb6004485812adab49f1d70ad0b66d7c715" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef", + "", + "mention" + ], + [ + "a", + "34550:1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef:NostrChiavenna", + "", + "mention" + ] + ] + }, + { + "content": "👀", + "created_at": 1690198560, + "id": "b24c556f1f979dc09c6720e851c7707ae907ac272a6bdde25d3d48c1df51b800", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "720c6e740aab5bfe7caa84dd8fdc82bac0d6c4666369f79d8217ded86a91c48691cd8ed0ff59d8b63054eb5e6efe72770d42a1186f1052d70522bb27872c990a", + "tags": [ + [ + "e", + "9aa0a176088c0c2b2e7be88c65c49c0872aeeca70602776d915e9d05ff7005d0" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ], + [ + "a", + "34550:1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef:NostrChiavenna" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690197065, + "id": "2c60ea99918ede4de03f2d193a5bd90eaa27cd3eb77f4248e7545fbf3cd10ca6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8c520ae3c6846e504aeb7154b11b4f1f74c4408f059f756eeafa0542675c3a8306242ce3b1b10865ef543351f2c85308f9ced483e93bef5036c4bd96fac6b959", + "tags": [ + [ + "e", + "3ba8420929d76e222190d23f28cc8a1871c0a38ee15bcabe749e78e4a3685d54" + ], + [ + "p", + "a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690196883, + "id": "f40a4a59a641bd45869ff6950d16a03c09c856afd25ea81e84f53ae22014c3f4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "17d0aed9b66ba569d6073a56ce2bcbe82c14e5efc9f0173bbaea699d7b5f4d8b675125d067c36e454015e44a234f7b04e356155de64de5444c74ad1632393f39", + "tags": [ + [ + "e", + "e000d1594ab01fd630fd7d635150fce287ed77a1bcb5087360b0000d016176eb" + ], + [ + "p", + "c43bbb58e2e6bc2f9455758257f6ba5329107bd4e8274068c2936c69d9980b7d" + ] + ] + }, + { + "content": "🫂", + "created_at": 1690196838, + "id": "af60844f79ca0fd418e34883cee61a9cb0cbea8e890ce110267c419ae256fd3a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d3bae96968d29b6f1ab47e3aa6e482fd825802443e4baf9fd09bd2bf260a346011e6c336589fcbb0ba20b97689f46ca35f6ef9b912c31c2138a6b6a5a2b2161b", + "tags": [ + [ + "e", + "a284954804a64168b23f5e9f179ac5a2dbf8d01812deafff3e3c452adf872f7e" + ], + [ + "p", + "8decf18b154e3c1f8450df95a501581e64dac7ca09bf9a83e95c783b695ab6db" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690168165, + "id": "f505e57ff50b2fa767db7bdfdf4b294ed7f6f8ecc92edbeadb7086335edf8d7b", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1a6789532ec1ae5b91d992f091d88ffb5889e4d1fba76da7bae3a624db24ff6d9176dde9323b78fe517f8c2c1769deb376591ac670eaf65f2355919f8410178e", + "tags": [ + [ + "e", + "ed5c3ff7a4ce9a16367646b9de3b342e06f5ce9cd1db92547d9764d2d54105ef" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "", + "created_at": 1690166988, + "id": "480e76bf0c518a68f30fec75a613f7e93ac3b40567c55c04d3d99c4939dd0fd7", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "80fb0c2cc3ef946a63ee4fc9439ecf6ac68b30bd3bc2021b333cdf456c04f0e71c9767f97777fab6170cbb2a536700c368954363f9510d585bf4a033bbabae71", + "tags": [ + [ + "url", + "https://nostr.build/av/9a5ab1d24c343007c57d1fcc15c0062b8aa10417b9853373b494dc0a4ffe3865.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "9a5ab1d24c343007c57d1fcc15c0062b8aa10417b9853373b494dc0a4ffe3865" + ], + [ + "size", + "378735" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690164742, + "id": "2743a30ac796bca3d84379e02c2c151a683cacd0c95e436c9944c75fccbd1775", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "950321f75b1abce3ae87ed53692cd681f9f75cdee99e2bd3f55c2dddbd5c3b677762c70ad15b1e465588256e2a93cf0a2f9da9fc0118dc62b82e993f31656618", + "tags": [ + [ + "e", + "1e5fa48d69a1f37cf7d5777c664c2db8a305f3348fdc67cd85de66a23dad1e3f" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "We might also need an extension to this nip https://github.com/nostr-protocol/nips/blob/master/39.md", + "created_at": 1690164717, + "id": "d03b49913572ef0628c03cf431ac5cc7e05f37c23ab25f418b798be8aa2819a9", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d9f9ab2c46276d1684de49938273d172c58a04352ca3e1b8c6616737d79875925104285d6286c0e1aa5a0dfe2f4cd266ed5d7d3187b229787e6ef2fb7a9cd7b1", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ] + ] + }, + { + "content": "Ohh I see. We might need to find a way to verify that this is the public identity and not one of the links to be kept private to avoid mistakes. ", + "created_at": 1690164593, + "id": "402d8aacbec848091fb77eb84fee6cd7483bb051bbd5bdeac068561d37056752", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3861611c96d4e7d62ec3689c8a3afead9441f4c3f927dac52a381c4322c2747ad84e9294596b687722dd6743e7e22e1aa6d875c867085d6319f4126f1847a469", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "150fda08135c4a4d05b7b2efc6f41f519696d4543dced36a0b0cd4093575c780" + ], + [ + "p", + "b70bcc7ad2ef996c7939d91653aa1472e0b7b3a741d194fbb26fe8c1786ed4af" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690161505, + "id": "a88d6e1c7b8b9e83839c15f05bd1b9244dd1e1aaf69408fc53088bcf88d938e5", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7bad72a24620794f1a337602c5416d6980420f20aaad77fabafbd03f43391053ace15018cc9044f48bdde6475072a6d6b51b25d10f0cae061504c260f78b06b6", + "tags": [ + [ + "e", + "8e11fddd2171c00c8cc93b5c7a3e1dbc1789cb41e9bbbc9d3cb57bfaa9b193b2" + ], + [ + "p", + "93eb23ad1d9274e3e284babe1d507f2c80d1eac2f3ef54969361a8a1f926cdaf" + ] + ] + }, + { + "content": "I am not sure if that is a good idea. The point of SimpleX is to not associate your chats with an identity. If you place it in your profile, it might break many if not all privacy guarantees. ", + "created_at": 1690161330, + "id": "3fd93179c24eb3122ccb49fac0be6e6eae50f9e091d7b0eedf350407553ee66b", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0ef0f98973d9a18a12a62aa4ef939eb8062dbdf0e63146d5d6fa3b3aceb7ff9c419a48956244b2d06470a3da5c2d35f9417529e9e87a0f6123ad867640743703", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "8ad94c49e2d56130ff28c11159233d834aa0acffc2c26ebff60d9fb61eca3258" + ], + [ + "p", + "b16a48eed39254385a8754f989045d2b7110e35123ae188e354ab2b3926d925a" + ] + ] + }, + { + "content": "+", + "created_at": 1690154636, + "id": "9fd566b3366e9ed28e0e21bdbb64dfd32d47df582a7e0dc1bdec1658bf698b0a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "57e6ef3a82458da584a807f665839564e929d309c137c33f30dbbea7396b797fda170328663e1d10eeec65b2d9dea67e428a2414dceef3a3d1da0b3b23487d44", + "tags": [ + [ + "e", + "203ac0002170f40a7204961a2d9f767cd398a86da66d483677ce526e7da523c3" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "", + "created_at": 1690147047, + "id": "dcab77b29cb67b63a70a816f489781e662de54c9d7700c326e5eea796ac1e3f8", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "60dcc3d5b4ee38c8f736d5655be8846010b804e59587550f80699eef95a5917ca9cf5f83e8ad60422e0d6b052f9934994e03a6d085a48d9e198fcaf1891fdbb6", + "tags": [ + [ + "url", + "https://nostr.build/av/21267a60f742ee85cda5b6025cc96a56980082d18f299786f60dafd8f1778b2f.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "21267a60f742ee85cda5b6025cc96a56980082d18f299786f60dafd8f1778b2f" + ], + [ + "size", + "6007727" + ] + ] + }, + { + "content": "", + "created_at": 1690146416, + "id": "3873f4f5dbf76a19d6704b05019017f7c42270fc3aa08e3bcf7b263c637d976b", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6b0dcfe92b2984f3f02bf6cc917b5283909dbde0690bc50891805342cdc1e5b987f240f18ebd748152297e06e0d2eba483bdcffcbad6324c6bac2e095584a213", + "tags": [ + [ + "url", + "https://nostr.build/av/0a04db3be1b25964c6b524df3deb0b31d33d8380d5a4a05a1dc66d541f51ca64.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "0a04db3be1b25964c6b524df3deb0b31d33d8380d5a4a05a1dc66d541f51ca64" + ], + [ + "size", + "9910725" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690146413, + "id": "97abf6fc744ca35ac884549ad7bf7ba7607b282047badd8422c4837a14eef612", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6fae6d3f4351602af9b930fae47439d469d5cdce3d73625926a02a94397488ac359012551a2aee439a45a42f993667c69487a20c53fc18fbebbe90cfed00e15a", + "tags": [ + [ + "e", + "2b75ed8899dac01b919a7e6c78803145cb8643a0def9069b0d31d8062810cae2" + ], + [ + "p", + "7538994d9cdbb870be588f44a44a16fe7c2cde7ab203b7dd5fcdef76b8a2ced1" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690146218, + "id": "f75d1da64a3ecdf3fffcffc0e117d86329896647429acde4938d8180e0ee0d4d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "97f0d2c7ec025279f71b5a25434ef8358bc1f40d05e46b7d69868dd4d521f12beddd77cf3509dc0283b91afd9c103c700c49f10d7421b7ad8f8239a6fad92aaf", + "tags": [ + [ + "e", + "aa79b7a1b2833c8ecde7623691870f290f70cfc7b578a02de9fffcf4b0bd4cfc" + ], + [ + "p", + "693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb" + ] + ] + }, + { + "content": "Hi", + "created_at": 1690145711, + "id": "1fad5cbcb12c2dfc878755e9d36253994d744c3234bb7f126e108681c20dcacb", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7e89e6af46236aa7d6d1ecb3886561e6b3ced76a8eae6dbf21ffca4581a9311dd0affc5ea44f3ff2fd6a10fd8efb9bba5e756ccc0afa8b0994706fa9e84cd2f7", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "72b24ba015112bd0d9dfe0b19036500113c5727bf509c5f1f6b66a54a0e24f68" + ], + [ + "p", + "93eb23ad1d9274e3e284babe1d507f2c80d1eac2f3ef54969361a8a1f926cdaf" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690145702, + "id": "64cd298c3d576f3e71c3a49eaceac2fbd576ecfacc5b9310fb55f8059da287e4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5bff5e7e5353ff5626db58fb2d0422298a7fa34a3584473f2a07645d103bb1269f4de2e10ba9465557b49e610ee58b5bf2eb86e208e504e849205983b512b912", + "tags": [ + [ + "e", + "72b24ba015112bd0d9dfe0b19036500113c5727bf509c5f1f6b66a54a0e24f68" + ], + [ + "p", + "93eb23ad1d9274e3e284babe1d507f2c80d1eac2f3ef54969361a8a1f926cdaf" + ] + ] + }, + { + "content": "Sorry, is this on Amethyst? When you search for a hashtag, the first reply should be an option to see the hashtag feed. The button is not as clear as I would like, but it is there. \n\nLet me know if it is not showing up for you when you type #word on search. There might be a bug somewhere. ", + "created_at": 1690144629, + "id": "062f556e82c36c6515f86f4e7c27c22fe464f0096aec5a4d10a131ae9abb4547", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b55589ad969ee964ddfdddfcd59bd6ed59c1283f1660b3b8e80b3f66f7f4460783cbb94521145999f3b86df2a43cdfb6b384efc6c8489600dff8d155dcf4c61e", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "508286bb1e1589f1339a50fe488c126287cb7c68a627433253e949b03c2aa9ed", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "8decf18b154e3c1f8450df95a501581e64dac7ca09bf9a83e95c783b695ab6db" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "t", + "word" + ] + ] + }, + { + "content": "I don't think that is an iOS vs Android thing, it's just the app design style. \n\nOn Amethyst, the notification is supposed to be a glimpse of how your content was received. There is no need to go anywhere else. \n\nI think the idea of meaningful reactions (which doesn't exist on Damus yet) is confusing for those not used to seeing and quickly parsing so many reaction possibilities. The emojis tell you a lot: Those who agree, don't agree, are happy to see the post, or the post left them thinking more about it. We want you to look at it and go: oh cool, I got a few people to laugh at this and two others to think about it. Nice. ", + "created_at": 1690144150, + "id": "e9f43494d02050b4cda38b4618ace86b14ff46d96b816a9434959b9e7699b036", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bc9e359ecc2d6a09470631bb9f8fb32b608d1f40b16c91d60f4a52529b93efafca74f22224b1d1d621bf1b7c26891e25d37eddf7b3b82118c789a117c07bf3db", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "00d94cc83d7c26eed1a915d4a927e6897627997659144f2bcecf6e761b16f71c", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Neither in Nostr nor in centralizing apps out there. It's indeed a challenge. \n\nBut we have to embrace it. Or we risk just being another centralization force in the world. :( ", + "created_at": 1690143743, + "id": "e3623f238ba397fd9fb22e4f4a72d159f4cc47feb3663788da9a282d6f1c7a56", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6795f9ce277ef0eeb41be85317d6b68ab8b6ccfe8e1e5decfd2814a134bf5407c3b5c24988e4b151dbcfcdaa53e98809423a1ba085c49cba8285c1292ea64df2", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "3edd892b48748d552d2426ca32e8da671b966bf6476668460d14b91104f4536e", + "wss://relay.damus.io/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "It's better than Twitter's for sure. Especially for complicated reply structures with multiple conversations at once. Twitter's one only works when you are following a single branch of the conversation. It works because it forces you to go into your follows branches and ignore replies at each level. Which is not ideal. \n\nBut the current view needs work. We need to code a way to \"close\" branches so that when they break off and you are in the second branch, you can click to close and see the post the author was replying to. \n\nLots of work yet. ", + "created_at": 1690143600, + "id": "78de29f56dc8981d1c04a08f49dfc0f4aca4fdf6b7380fe53fc06c758543c066", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c6a343af4611e7c0288a040eb91e91fae70dbbddfbc7780d6b311303ef10ef46bd28ece5381bc54c5d9aa905a4af363a43ed4037269c699b4ace98ec1157c31d", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "8300def46c069d0f10fc9e16b6feef71c1c810821e73bf76e8e3984d4531f457", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "I wish we had more forks. The more the merrier. \n\nFunny story: my initial vision for Amethyst was to require every person to fork the repo on GitHub and when they did that, a GitHub action would change Amethyst's name/package id and release the user's own version of the APK, ready to install. One-click deployment type of thing. That was the only way I found to not centralize it on me. \n\nThat's why the app is in my personal repo. I was going to change the app id to people's usernames at every new fork-install. It would have been beautiful. True decentralization. \n\nBut people love to centralize. \n\nThey gave me feedback, I listened to it and decided to ship the app myself in the stores. It's worse, but it got us going. ", + "created_at": 1690143259, + "id": "76688fced94a5746890fcd7c79d23674e7e84e36f29ef3ff75a1f4b7de62b1f2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f1fe4a9f07e4137f4d5f98f570b28054f56d7b84c894c50b0fda60739557cc5246ff09f0d76c0a3cbcc4d733f20b61b87ade4df22e34d53078cb846bd8a167f4", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "999243d5a5643ba565a8f9f295101d66c70050388e68f771f1ed70688de120a8", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "It is the Reddit model. Basically, posts in the same number of lines are replying to the same post. ", + "created_at": 1690142625, + "id": "cc908556894f1207f2b6fa108a61c6b1d6f47a93f56b4cbe430d9f18048eaf15", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bfb61f208a2a20d8dece7b0e1e1946dbdebad5768c00ad756ab003b08ccdb2425367ede7fbf930c1480f73a569d4d11aaa546f912419f6fb618515d88919e6dd", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "b398f36ea69452fa77c6aa09b2cda032efc3a41a4f826a9bdcbdf935e4507cdc", + "wss://nos.lol/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "nostr:npub149p5act9a5qm9p47elp8w8h3wpwn2d7s2xecw2ygnrxqp4wgsklq9g722q's new icon set for Amethyst is awesome. I am happy to have more help :) ", + "created_at": 1690140751, + "id": "158cd5feb11ec8292d4b9441a0b948dc5d91027b33792990ce33813fa6c39297", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "96731754da8e1be93910fb52675c1aad1b51a7ed2429d534f9f1703856632d61e5e11bc1cba457f153a5ab20a307286e1f96b690f84b469ef3db8fb8f794e662", + "tags": [ + [ + "e", + "5a03b6a7d21f8b76e5ebca4e04cdaaba732a6da4537e7b75d9c57381d84eddc3", + "", + "root" + ], + [ + "e", + "47f1806284a61de31e8cc587a5c66f84fe25b0e6024b40c2851dd1d607aa72d2", + "", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be", + "", + "mention" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "👀", + "created_at": 1690140234, + "id": "0d4467e19a71a4a5c372a96bbeb23fd46f91ea39230791ecb203852f80094c11", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2ba1790c77db5062ee64b7ec4f8fac7ea5d0cac65eb6f95a828e005d0b9ec6b0da429014c020c9a7243ed1f16414cad16da8a09705d1cd122a0879ec2d2676f2", + "tags": [ + [ + "e", + "d63d391bd694b9e1ec5e2de2210771ba5596ad18bd996a4059466a1c0af2a100" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "Then I am just confused why are you comparing this with a notifications screen. ", + "created_at": 1690140198, + "id": "fb72346b3a189ef576609b2b2e5af56f52546722d84094f41733675aa55e4db2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8a7d4b654828e11f8dc94355ecb50b507ee8430599b1187b0ae63b534b5f1b283c74f3b78e641c0301d2bd8c9d58be9e4e276d7be6afbcb0acb4123fec6a0922", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "bbc7290015806dc41ab15d37f8d5b6b66e469371f052abf36738017e4d41db71" + ], + [ + "e", + "d6d3bb1689a95eb9e69e9fe143951c2c2fe402740a4bf35fe5255d86fd9a0227", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Also, it looks like you are two clicks away from notifications. Is that right? ", + "created_at": 1690139942, + "id": "bbc7290015806dc41ab15d37f8d5b6b66e469371f052abf36738017e4d41db71", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "eb3b73acc14478f87a3dc92f946cf8831a63cc1c647bd7fbe1ff585ae7b0d97f588852ed42e8ff68d22dbd0b866dffc045f50468fe0c054e9c0898f46ee900e8", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "7cbb8c290f9c2722e094cf95af3e51069ef97171300d49d84108f66f53de7558" + ], + [ + "e", + "bdf5ddb752eb601777f835846a60f8e7ea34fe0e807afefa515e700ece0040b0" + ], + [ + "e", + "6ea171a5dc97b971fea2a551cfbd418c308c436f2b851aaab8737367f9196838", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Why? Did you ask your users to see if this is what they wanted? ", + "created_at": 1690139749, + "id": "6ea171a5dc97b971fea2a551cfbd418c308c436f2b851aaab8737367f9196838", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "060d4e3a9202d09af5388d11c51089677f725845e96d8f2c7d07747a8be73dfcfb33c8836057d099974d9310ee23161eb78ddf3842560fb7040e8a6acbcde8b3", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "7cbb8c290f9c2722e094cf95af3e51069ef97171300d49d84108f66f53de7558" + ], + [ + "e", + "bdf5ddb752eb601777f835846a60f8e7ea34fe0e807afefa515e700ece0040b0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "It was in the very early days, but we did. \n\nNotifications tab is not the mood for follow and detailed info. This is more for a regular feed, when you are in the \"get more details about this post I just saw mindset\". On notifications, you already recognize most of the faces. \"It's all duplicated\". There is no need to explain or occupy more space than needed.\n\nOrganically, in over 100k downloads, no one has ever asked me to add a button to follow in that page or display the about info, which should speak volumes about the need since it's one of the most used tabs in the app. ", + "created_at": 1690139394, + "id": "e1c04a093203e1a39fa51775e79645d9303330e3d5fc0a6ba4f1ba85568fe260", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9bb5bbe24d10e80f88d117e52c5ec9598a7d45ee6941a0bda09db4aa9efc9ec2623a80fb10453ed7e097284648cf15628d6812a7b41c6613b7772e404cc5e56f", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "27cdfcce9c8f291ea1519567d2d3180a131f3e276801c2b0b208fcec3a4a56de" + ], + [ + "e", + "39a3badfb3a96cbca72aeb6b6319b7ca2b2aafda06c9060158c9c2b56e71dcb9", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "User's last seen is a very good idea. \n\nnostr:nevent1qqsxnj27sn2xud90fyqre6jgakzxwcu2axnj9kcec9jch66cph5u7uqpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygymuzlquexn3g56nnkf5hyw7hv88s4l55mz5j643kjl76du8jacrcpsgqqqqqqsuu0vvw", + "created_at": 1690138954, + "id": "c9ab82b0a6b2ef1adf14106f3c660f9fc06fc063d54fa89c092f64fe24d0193a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "529d56a80125fa7b0e0ba5d9592bb457473929449c775d5860c142d64c6e08a655381bcae282540ab3198636cbfe9aa5074fd237344e93c38dd087ee9e8c0629", + "tags": [ + [ + "e", + "69c95e84d46e34af49003cea48ed8467638ae9a722db19c1658beb580de9cf70", + "", + "mention" + ], + [ + "p", + "9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e", + "", + "mention" + ] + ] + }, + { + "content": "Last seen is very cool idea. ", + "created_at": 1690138907, + "id": "6ae05de6606182d1f3f3afec4b42cd0fcd37c4b12b4807f1f1971f16d4a42273", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2c593ad41af45511a937c5f95e50aacd4ff953e1758faa13c96f71a5c68832f08cf513926fe318dcf52bdc55ae28ee3aeb615a3b12aac553474cb4f4406a8396", + "tags": [ + [ + "e", + "69c95e84d46e34af49003cea48ed8467638ae9a722db19c1658beb580de9cf70", + "", + "reply" + ], + [ + "p", + "9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e" + ] + ] + }, + { + "content": "So, you have to click on each section to see who zapped and each reaction? Sounds like a click too far for me. \n\nAnd why does it display people's about info and follow button? I am not there to follow people. I just want to see how people reacted. \n\nThis is weird. ", + "created_at": 1690138829, + "id": "27cdfcce9c8f291ea1519567d2d3180a131f3e276801c2b0b208fcec3a4a56de", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b9e256fc3ab93ecb522625669dfd5f50bd4468d2c1d0f5328341c559c4d5cb1f44f707bb30949a93fa9d91ac8738786d433f4141e0bd0db625984ab8e6187344", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "546fbf068c293c15c5c48232570d12e81e551891fbc611b1fc186a798ad0dc92" + ], + [ + "e", + "2d59b8711e9aac9f6f6cc1025ce215e744e94979f4af33e7b9a586918e24bd18", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Show us how Damus display the same notifications. Now I am curious to see why is it so much better. ", + "created_at": 1690138505, + "id": "7cbb8c290f9c2722e094cf95af3e51069ef97171300d49d84108f66f53de7558", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5cc460d2f1812da1372b2e1e5bb883ea09e14fd7b33c2ce0fb3324e1b77e5f21cf451226465950061ab59d49e7c766f6f9010116dd0a68a2bca796e2f45bfc33", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "1874dc3c39bf3dab4d9fc3b6022ad850ad5a4aa7ef4d40aa24b019f4f1e4b841" + ], + [ + "e", + "f8c6955a54a8f9c254d85c8c28d9dfa22b63d6b98c7903a8e407a20ff1152f18", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136517, + "id": "9f8f3d9d1e5bb57492d8fb2c6a654ec0953515e5ed009f86fedd08fd788cbb70", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "db59f7ddd49bcb186e12483cc07b819c45f8dd0e1719c3a6dad063595eefb7df34c963ccb206cbcabd37041b4df4d6b5fa4013d4b39217d59cb2199e76181e75", + "tags": [ + [ + "e", + "10fe6f028555c905ce355d042b50605f0eb13b925ba4742855537ffb5a50be60" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136496, + "id": "b30e186018667152d83b1b2a366150907bc4cfc5158c2a74e095fc63eb75cc0c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "14a4b1db558c64a679e02c944b5d63b7d41df6faba4f02d700d92e742a48a21c241c161415416ced8d5bd3663553b071533cfc2b4dbed88979f07ba41176d22d", + "tags": [ + [ + "e", + "eac398b0b928cb10dc9337df29acf7cdf621ba05a4b300792b8aa4d1125e0c55" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136491, + "id": "ee2956b81136a28caf94c12a47a4879a75dc2c204a911ebfbba85243613d4cb7", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7bd2df393c2d65b4240a001c7e67efd7a069f83f50b51f8047bbd3c1c45eea4d1967b066aed45dc3bceeb054df9078d5f451e43427d7063daa1266dbf83b44ea", + "tags": [ + [ + "e", + "df6924922f7fdcf06a802348b28b26cb4eb0eece4d0af995e8c352b6887d04aa" + ], + [ + "p", + "5f2b6a543f5083b79dc22227867507a12a999e365f0af741b1f5852bc1974226" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136489, + "id": "e28b0e4bcf4c16bbc83a7a4d4c4d3ce4159a3798a7ad7a4fb2bb95e5178d0da6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ca878cea73fb03a532452b6fdedc6cb0ec1a4259d9e5ba428cec6b27d8ce2e09605cb079c5d251a45072544de907eeef01219cde2264a8ee6597c2382cdf3b84", + "tags": [ + [ + "e", + "0127ccbde49aec4c9f88ea5f18c2f442383f403d59ba060142f08535f09ee4f5" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136483, + "id": "ceba0d38900e9b7f3ffa6e2935c3e9654aee754f3dd3ad15ff416a061bd6beb1", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "954b2b32fdf7cc393722296b1b5639ef7ba9889a0e850746c8a91f8cb817facb9cf16881f6a1a6f7aa51e924baabf19c796e33eec1dc41c9e30cbc30c9225552", + "tags": [ + [ + "e", + "bff7c2588efc33f94e24ed9e9b9e24e560e5e2c4460cb6bbd6ecbd5cb074528e" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136480, + "id": "23751602d5fefff93fdb7d202f5c0ac4b856d93f674ac24db7675693b1e8c33d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "122735ff28c4bd0e45b43618767c1a12df1d1c22244d78ed24906ccc9f5194da01540386e75dc74775363fa744b4e2ce338857d4f0013876806e5b9c6c5dc5a3", + "tags": [ + [ + "e", + "107fbdd98adbce0798b6858248213be4262ae1301fbac29e2e5ed7b838534b15" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136460, + "id": "e1404352314e0ea33f8b70f23a9f833e475bd118b0f49886c0d34b01dca6be80", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "286b6470e0c2a51b07d1c7cf72d919d7e06d0f303ad2d6bf05c2a525e37fa17b6b9c1d05d7c80f49dfa954bc08f51c865a6e3e5b0a5feb184d5f96adec613530", + "tags": [ + [ + "e", + "5fb517843bf3accd6ce60e69d5f7b964323f582238242f6776a58c9daa59a3c5" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136457, + "id": "1d8c8f7707f365909ecfffc890bf58ac28489b81f1fb71ceb506b8d9a1417140", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e8a21f42780365e514b9bee4dbf8335722a9520068f312da2b7b8185a8a6041de17e2b631f06edf1aa7959f4436658855688e011b971c2653d83a7f36784d6a6", + "tags": [ + [ + "e", + "28f176f094da6a46ee195eac39ca4154d0d94d86d2e545532f697238f3e52f9f" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136453, + "id": "f81ec9b259fedf292974205e6d2523cb527f53825e7b16ab794bac30021e5c3c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f207099dd78a41e9fb3f09359acd0fc37199cac970cbeaa0f96e01d3c6e2d1f444f4dc4c7624e5ebed94ca6e936a7d7c60a5f43a753e822e67d4b04ae709585a", + "tags": [ + [ + "e", + "92e9e94ee0da6fd08c62fc2477a8a2c825fecbf0ad91a2c5d060d2978f20d6cd" + ], + [ + "p", + "4ff36a4d67fa327ef1a686a575d0106b4781600900f68ccb5a40f7e455530baa" + ] + ] + }, + { + "content": "Ohh interesting. Thank you! ", + "created_at": 1690134858, + "id": "20e91ddf23a4b87c138fc41c50bd6baeee6bfa352b2f156a51cb914c29927847", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5cbc7535ec7ebc60a21faa27e3bb61d3ad5b5381270209fc50ba7b709c40df4c02fe5c7865b7881aefd4bafbce90abd24f45d058beeda22d94eb6ebc388a9695", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7" + ], + [ + "e", + "56ea1f92baa9008920ab905465776d55cf623539069f406b0cfb057fd5442ef7" + ], + [ + "e", + "1dd32c99b5e875b90617599580270c16e2f7e3be233f029bc73345eb591ba294" + ], + [ + "e", + "3ca18ec56b18d5c17bc31576b80edb9ba355b4cc82dc7fb671effbdf90a79562" + ], + [ + "e", + "682a31ccafec8eb46dc6ae3b8e94471e6d4bf2f98289236e164481f50cd76630", + "", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "That is very weird. It should definitely work. I will investigate. ", + "created_at": 1690132862, + "id": "3ca18ec56b18d5c17bc31576b80edb9ba355b4cc82dc7fb671effbdf90a79562", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d35335a2ee1d8b813b5a63bd86716561356de9c0208162927e3efb1ec6cbb89df04d3d7ceada1c71848d7456037999df947e55a17a7083960b362ba252d2b246", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7" + ], + [ + "e", + "56ea1f92baa9008920ab905465776d55cf623539069f406b0cfb057fd5442ef7" + ], + [ + "e", + "1dd32c99b5e875b90617599580270c16e2f7e3be233f029bc73345eb591ba294", + "", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Does it stay on after you Mark it all as Read? Sometimes very old messages arrive and they are all the way back in the list, but they are still unseen for that person. ", + "created_at": 1690132343, + "id": "82ed575f4d9bc4f19660e277890c34ff4f0aa0d023b86d91fbb809d3457483e2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5c3e81917835a58561b20a6d559a85530aface929422fca1c06e837975932c33124217e5073a13e251fa8ec8d7aed6ccc5aa3fbadf7f996743a9018f21d275bb", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "Back button? That's new. Any particular place? ", + "created_at": 1690132236, + "id": "56ea1f92baa9008920ab905465776d55cf623539069f406b0cfb057fd5442ef7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ee7f13431181d04a1c8b0ce211c2ee788c2890a978f35636c8fde70fd1bd56feb16230f40c90eabf1b6ea3016d324d08e09b24e5513dfccc334ff5489e29b217", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "Same risk as using SimpleChat with the same outgoing IP for all contacts, right? The relay can bundle your messages/queues. ", + "created_at": 1690131040, + "id": "5a97c5a5397b6f222d660713269dbd08ef14933f1868d5f20edae897e0be5ac2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "432af5b48d193d0d069dcf2b292edceaba44f0381df378d1e491a9ba5d84a4e0c2d5f755f8a9e8df0c8d55cef22b189014c7a0a138b4119d1a3e9a4b2f814c06", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "1fc5133e2cf877c6dbb2ce5478a5780fb252c77b7683b7871d4944e1f309c82d", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc" + ] + ] + }, + { + "content": "+", + "created_at": 1690130924, + "id": "ac1fa90872d0848def9120db1cb0ee16fc13e90b4e5725365fea651d7a939737", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d8a4cc59e04de535673bb2eeddd7a65348d8daaf3d5c7c694cf4a0df2f1af7ed7d74f7f0d075264e77144932b9701f7fdc35eb2ef089837c59d2b376e80fb4e5", + "tags": [ + [ + "e", + "1fc5133e2cf877c6dbb2ce5478a5780fb252c77b7683b7871d4944e1f309c82d" + ], + [ + "p", + "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc" + ] + ] + }, + { + "content": "Simplex doesn't want to have a link to a known identity like Nostr would have. ", + "created_at": 1690130888, + "id": "273b7d10c62906e48866911ce18fe3735099bc1f2b352e1c7b8a6f26195d0cf1", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9014e9eef179975957e241cbcab1e9b5ffa04b9a527b3447b67dcc17946a8fab0209c384badb255d9e81c7e22a44c1402f8699d794e4fbdfcec22b0db2acad8e", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea" + ], + [ + "e", + "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38" + ], + [ + "e", + "a34f5bf152db8de7cd90c7dba18ddb1ce8ca0309b778af5db99dd9e0afd0055b" + ], + [ + "e", + "1c3241c983d9423dd321ed6cacf17012a38b3a637e1d886a19f21f068a6be1a2" + ], + [ + "e", + "26929187391d11f6428433b304a0d19e72c3766a405cb31edf54f5a6b668db6e" + ], + [ + "e", + "f71fdb383382be27b75a45c8d201f43a9086a5704fc948165d428b52f3fec539" + ], + [ + "e", + "5bfcab4ddb3d59528974588f1c16a1e7633d7720e2fe8ed370e856c20b57882a", + "", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c0605aff47b9330575f4865671528832323c6d0a8320a8568c9e044272e266c5" + ] + ] + }, + { + "content": "We can rotate keys, but if you discard them, you won't see the messages address to that key anymore. In all cases, you will need a collection of all your past receiving keys to move to a new phone, for instance. ", + "created_at": 1690130151, + "id": "f71fdb383382be27b75a45c8d201f43a9086a5704fc948165d428b52f3fec539", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffc4b2fd51e22628d66fdf10f01b93551a6ed69c42f115f9e880858b13445e87a913a569ac1832c6394d9658fd600dafa297c13b9228ae3b9bc06c251f95c7dd", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea" + ], + [ + "e", + "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38" + ], + [ + "e", + "a34f5bf152db8de7cd90c7dba18ddb1ce8ca0309b778af5db99dd9e0afd0055b" + ], + [ + "e", + "1c3241c983d9423dd321ed6cacf17012a38b3a637e1d886a19f21f068a6be1a2" + ], + [ + "e", + "26929187391d11f6428433b304a0d19e72c3766a405cb31edf54f5a6b668db6e", + "", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Not if you need to save all these keys somewhere (locally or on the cloud) to see the same feed in another client or device. That's what SimpleX does with the local database. Keys don't leak randomly. They leak when you are inserting them in a new device/client or when somebody gets access to it. If you keep your keys and the local db together, the attackers gets both. ", + "created_at": 1690129188, + "id": "1c3241c983d9423dd321ed6cacf17012a38b3a637e1d886a19f21f068a6be1a2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9378974e955039364618a0ad76df16e4c2ba1e18fedbca954896008fcda96ff469886df88c648d5590e99b6849f8d188a959731ecdab7f191554a66696bc2669", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea" + ], + [ + "e", + "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38" + ], + [ + "e", + "a34f5bf152db8de7cd90c7dba18ddb1ce8ca0309b778af5db99dd9e0afd0055b", + "", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "The new idea does use ephemeral keys to send. We could do ephemeral keys to receive as well, but I am not sure if it actually adds much security. ", + "created_at": 1690128558, + "id": "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ea935c0dce11832e87a7dee58bead19a6fe74eeaf4dbfda227f82501ba4b8da987eb9644e4fa6feeae8e5da99670878fccea63b351267fa5b732859f121bdc25", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "I don't see it. As long as there are two clients doing DMs, we are going to have this issue. We only need two good ones that compete well and people will be lost. It gets attenuated when people use a client on the desktop or a tablet and another in the phone. They want to see the same feed in both, at all times. ", + "created_at": 1690128385, + "id": "43baea1d165172f1fb7ece392e3d227921d7a2558e0ae25bc29a39c9889528bb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6899b4e30252916d97848ea0f26bf89834528e7d1fc47c566c61a16246f32f6041db75a44c62e5fce44f3cae02a70d2ffbab6b58982f649411698f9425a23956", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "cefaf39fa34dbc1e01b28930f269af13d9d77c90b2e121f6166b4a7933c998a2", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ] + ] + }, + { + "content": "SimpleX chat relays do keep your messages for a while just like Nostr does. And the receiver also has a copy of your messages. So, it's not that different than in Nostr (with the new idea, dropping step 4). ", + "created_at": 1690128254, + "id": "ceafe9e1ef47db0d1a62dadcfd38dc621121beb555a5937579832d3dfb5cbcd3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8d5efb0bbce9a5c0cc7fe2855e2d33747a2c904fa2c37fc2bdefbad2fcb8a196f3a649fb658dc87e8268bdc6c4eebc24638c78149ab0e62f4a8811fabdb910cd", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "f1208fe67ea4647e66f4c1245c42ac8eb15fa94cb635969b025ac5fec07ab0da", + "wss://nostr.mom/", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "I am not so sure. In practice, the security is as faulty as with Nostr keys. The things that would cause a key leak in Nostr would also cause a chat database leak in SimpleXChat. And since on Nostr, people want their DMs in every client, there will be a lot of export and import happening, which yields bigger chances for leaking the entire thing. ", + "created_at": 1690127530, + "id": "d1d6ae797dd7a9900dc6696e3bebc884f5d06a523d010e6cad62a70aea755589", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9748ea22ea7336a436133085fa8052a1b60f146ef9ef49bb27676d72e55f047165385fc409a3217fbb01f3178a76deb889674d9cf51a9f657a5fe9693a068f65", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "90c441fe992e9277e2b5ab4cb40c249d1d3ca64f41ea4dd33e1e5734cd6bb869", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "+", + "created_at": 1690126785, + "id": "0601df915b7a6b6e0aec792879ed95a7c05edea54e339bfa330be013cd9087f3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffa70983b791fb19bb5a15da05b4e71b6a9d5d65a7df796e1c7d9633187fb1b52000a21d18c8f8d29ec278bab88bf159c24a1620ac0b64cfdd95836951c43cef", + "tags": [ + [ + "e", + "0001d512384cff57a0a051ca705e2913269c8d7ad2563f7717f988acde2e0d71" + ], + [ + "p", + "2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5" + ] + ] + }, + { + "content": "People can opt out of the 4th step if they don't want to recover their own sent messages. \n\nI am not sure if there is a practical solution for the DM history leaking issue. If people want to keep their history between clients, they will keep whatever is needed to recover the history together with the key. Once the key leaks, the rest is also leaked. :( ", + "created_at": 1690125971, + "id": "8e763cfbd52dcae5c9d14e772a36d1114ab129b9d1fee0725387d4979f43af44", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9da781d49e1d952b4f11633bbb67e22d4ecd54460f9d3ff99ce5908b83d49f4d123dd9996a62cc65090279a9c3798bc913d5aeb136d53e9faaa59eb0b2d0c06e", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "ddbaa33cfe39957dfb381ad963d74951902bb907f9c07e167c2e336a27d34e95", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690125054, + "id": "9f7e02cb022e9efab2fde0dd13493ce8c9753c4189be3e057cd3ce88d7a03c93", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "224e654f8599b4f25e53dfb49462a359bcddd37ff5c9e0374e11b538cdef9641e7197babc7984de626577b10940c54ece892f6824ee49548d6e541a756ae5cff", + "tags": [ + [ + "e", + "098f01242f5d18f7dfe622ed292090b7c008c55f8185e5b972cfdbb8df7a4c00" + ], + [ + "p", + "ae39bf0aab59bfb1bacc784bd7b230ce762a671eb33d5c0c586ef7e96c8ae25f" + ] + ] + }, + { + "content": "Yeah, I need to figure out how to use less memory. :( ", + "created_at": 1690125040, + "id": "b735f0cc3f6fc10d2a15bf6da690ffd8f94e1f03cc7ea24869b14b10d7fe8533", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2d709feacb2657707eeb80219d626cc397ddb8815bf5b28aa5592f486d66e45dd2163682cefbf063a263ae4079e0d482c685fbcd2832c0849fed9c310b702aad", + "tags": [ + [ + "e", + "e362d1324f7672556d6e8fdfbac3fc3a26906abb2b32824b43ba1d2fb80305e8", + "", + "root" + ], + [ + "e", + "a122d53b0340257911cc8e2f7d940279dd5e4100a708b70c9b3dd20eced7f18b" + ], + [ + "e", + "a38e734e65750bbdf679e6564f52e49a19ee8424e590319935f05625820c83be" + ], + [ + "e", + "669c66fe822ba472e5b4b603409f9116badf3a1af439de357c9ec159b894dde4", + "", + "reply" + ], + [ + "p", + "269b0b280b99e4724daf5718a6c8e412c59269cc472a70b472b1acf0dec91bca" + ], + [ + "p", + "356875ffd729b06eeb4c1d7a70a1f750045d067774d21c0faffe4af2bf96a2e8" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Yep, just an extension of that. ", + "created_at": 1690124736, + "id": "8d4bd3ea01e934d6553372da70175e07fb63708e34cd2cd6e99b903d7eecad32", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fabc98d48f0f3a3910418899c1eed968ca9b15f7b2b4cb19c32934fd4ae83cfcfc480e26e074fd091d331ada5dfac6689350921c6df53bfac7a40998ac6dd6cf", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "38a91f7ab267336f20745f74312d4dba3fad6c8a965cd61aa8b294e86e7b4503", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Isn't this better than SimpleX Chat's protocol? \n\n1. There wouldn't even be a queue to correlate messages on. Each message comes from a different random key. \n\n2. Send and receive payloads can be separated in time (the receiver sees the DM immediately, but the sender logs the message, or better, a group of messages, in the future or the past inside Nostr relays).\n\n3. As we add more private event kinds, the anonymity set increases. The public won't even know what is a DM and what's not. \n\n4. Yes, anyone will see that you are receiving \"things\". But GiftWraps can wrap Noise (events that don't mean anything and should be discarded upon receipt). No one will know what's noise and what's an actual DM. \n\nhttps://github.com/nostr-protocol/nips/pull/468#issuecomment-1646858226", + "created_at": 1690124517, + "id": "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "86eb89acac53fd87f975c1b3799113c9841ee92c0802a15547e9ae6f4914233a95224a1364c087434e758f57190229db17fcc1d8a45951d785824bed063beac3", + "tags": [ + [ + "t", + "issuecomment-1646858226" + ] + ] + }, + { + "content": "Really? Hum.. I don't see any crash reports when writing messages yet. Is there a particular procedure that I can replicate? ", + "created_at": 1690123683, + "id": "a38e734e65750bbdf679e6564f52e49a19ee8424e590319935f05625820c83be", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2bd6a3dc8b8e16a2a3474e59d2b7fb8052dcc374cbaf2cbf387306fb8b482e54f972a29ff2ce8e9fa4acf872334e322dcb0ac02498862c4f96940e28f7e326be", + "tags": [ + [ + "e", + "e362d1324f7672556d6e8fdfbac3fc3a26906abb2b32824b43ba1d2fb80305e8", + "", + "root" + ], + [ + "e", + "a122d53b0340257911cc8e2f7d940279dd5e4100a708b70c9b3dd20eced7f18b", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "269b0b280b99e4724daf5718a6c8e412c59269cc472a70b472b1acf0dec91bca" + ], + [ + "p", + "356875ffd729b06eeb4c1d7a70a1f750045d067774d21c0faffe4af2bf96a2e8" + ] + ] + }, + { + "content": "Each page has it's own setting. You need to change it 3 times to get the result you want. ", + "created_at": 1690123610, + "id": "b6fc9e80837196ee50661ec7dccae9e4780efacb2f1c041eebced362819e9066", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d206951c7acd7ccbadad68edeeabdc91952600c8f438055fd57a0d096c2192e73c28fce4b78cca04f04a8b249b762997cb2242c589af79090ac2f4e5e994f99f", + "tags": [ + [ + "e", + "c26effa4ceb95606db038a88d7be714328a4819be98e52fc7536c8deaed67b14", + "", + "reply" + ], + [ + "p", + "ae39bf0aab59bfb1bacc784bd7b230ce762a671eb33d5c0c586ef7e96c8ae25f" + ] + ] + }, + { + "content": "Yep, block is local. Report is when you let everyone know about a person or a note ", + "created_at": 1690115660, + "id": "d35a705e248e0d748938bd403a3041895396babffd227e41eaae2c8006a1929d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "44fb41d3992aeee7343a65c3ef11abbd34b6399c8fc7fe244e2a0f23e928ea78b7c9f68470358f8e165fbda7e68c088b754b2942e19c362c1eb0212df6489977", + "tags": [ + [ + "e", + "c038888ec69c6c8987f5b2dfd224ddb55ca039689b0a20a22287303ef8894f36", + "", + "root" + ], + [ + "e", + "e8091b484dff5afdf25f0f4a4d1199944cb1ee13f3938f78030fccbd6da92a33" + ], + [ + "e", + "806796cf44e651a4f91b9e3f645024a1df19e0fa62aa95db444e15e8939377b0" + ], + [ + "e", + "121b4044bc989a3b0139372d7ccf27890fea3a5b77af370311f778ea57695bf0" + ], + [ + "e", + "ab1d298ae27795f0fea2c9446fabbbc41b2e5b6a127bf4f200598a71295eb569", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "0c9b1e9fef76c88b63f86645dc33bb7777f0259ec41e674b61f4fc553f6db0e0" + ] + ] + }, + { + "content": "Fight free speech with more speech :) ", + "created_at": 1690114179, + "id": "121b4044bc989a3b0139372d7ccf27890fea3a5b77af370311f778ea57695bf0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "56f0634768de22c8bf3f396e30f3249ed6a3f6d68be81ba9c61f82d77b92699f58320a0f59be3c69aad77b81287af066211ac43f298942940fa2846fcf412671", + "tags": [ + [ + "e", + "c038888ec69c6c8987f5b2dfd224ddb55ca039689b0a20a22287303ef8894f36", + "", + "root" + ], + [ + "e", + "e8091b484dff5afdf25f0f4a4d1199944cb1ee13f3938f78030fccbd6da92a33" + ], + [ + "e", + "806796cf44e651a4f91b9e3f645024a1df19e0fa62aa95db444e15e8939377b0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "0c9b1e9fef76c88b63f86645dc33bb7777f0259ec41e674b61f4fc553f6db0e0" + ] + ] + }, + { + "content": "", + "created_at": 1690113754, + "id": "dc34c01e8f71b64c206ed9ef324beed9b233a298f8df3227702b0ed2907603fc", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "789c4078488b2eecde89c63d63ce93f8a6803cc704728465d3ab4657128474de9b17cb12eb8b8dcf03cd0659c1d34e248829ab3f6358f079baf3d71817a1aae4", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d829c52bb54e2125989bc4c7876c2523f0c7945e20ed1f59.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "a61a285079ad066ed4a31e6dc59bbd6e187c32225c66895342f7ee6b82121a5f" + ], + [ + "size", + "2054861" + ] + ] + }, + { + "content": "Him: I don't fall for placebos. \nAlso him: \nhttps://nostr.build/av/6a92ccde091c71316a28f8652a40147a82d7e573076414095cac12b9d2a3b1aa.mp4", + "created_at": 1690113542, + "id": "ad1d69d0565ca03d3329fa91e93e7d5e7ec21df3d8729ab8b8696379f04605ed", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "95758a89bbc4251e022aef31d0e5f98b342e26f20889e836e39ac59ea8f02131897053ecf4b9e2cdde66ff78dbfd209afd35f0dec754e0f6707a1a9974e7d1a3", + "tags": [ + [ + "r", + "https://nostr.build/av/6a92ccde091c71316a28f8652a40147a82d7e573076414095cac12b9d2a3b1aa.mp4" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112771, + "id": "6b5de9eaa52f1c889dd1c49b44696a53709d119784f3b8859b27b350d75df2e4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3e79410fae3b7223997edc61a6da17df8ec3978723a2931680d8636b64e7cb9ed420d0ec275aecd2f7733df56978166ae568076f106b1cc1ac81ed01c0236950", + "tags": [ + [ + "e", + "ad079dc69fee2dd362bf444404e71b71a4709b790bdf7cc45c4a38b95c9b87c2" + ], + [ + "p", + "ee6b33fc72adf103af33ce25a018179e4458e79d1e898bbe8d460eaa7818bb90" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112700, + "id": "ed1aab29b697e44b5a6237e7859df7e8217b8d2f42627be6745a7031774e6939", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6cdb924b825c5982738b2bf16090f49356fd76eedd098c8153f6071164cc8d8c53956d385f8fd7f7f1e26cb5f35ac3f07f27f3128dd122efbcba58bcc4c061a9", + "tags": [ + [ + "e", + "b69250498d25feb15d65e2e8270154a65cc241593be32cce4efd299c78619e99" + ], + [ + "p", + "a12fb7fe051724a34ef3409ebcdb377b9d1157a79b7a2fcd8d008995f190d9fe" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112681, + "id": "0bebf55a12f843aa3fbdac0481db0939a5926561f1d76a0a3c7ce3d8cb3cafde", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "952547cddc189995dc5aa3b2b34e4c21d540d7cb3203eee559419feaf7f97b4415f00a51dcbb8a719ae7b8d3de87990ccb6448622a64489142cdd5dae0bf6577", + "tags": [ + [ + "e", + "b5a56ca5ca5535609b08a9d1f7985cdc0c96d7ecf5fad484145ad0b1060575ae" + ], + [ + "p", + "be0435d2baff7c44dcf0c38ff53a26720f2729660141a5dbc0dbcc25d0cc619d" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112674, + "id": "3d1f0707a14218b295716537e7ee77264da6f380f5f868f66e625c83e9ae4e95", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9b4e2c701c7f36b0de297227d627180594010e157a199e9801db8f869d99e4d6e8baa5693ab30e5aec75bc35b0496868ac79373b170ce211992964383c49bd45", + "tags": [ + [ + "e", + "40970fd0dcec2d6e95fcc70385b37cb4387d234acfe7ad483ece089072e5adb9" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690112212, + "id": "e24e855089aa4769587b61792cab3314a521703da921197aa80543b6d6a4a778", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b1fae809b4a20dd954f78877c33c872c647fb73043c945c7ccf24355f95b73bc382a2b3490f87791b320633dcf307c4178ab9bd4a8a416c7673f6dc13a10d779", + "tags": [ + [ + "e", + "9ab63d4a2a5d3c5154f1da7359cc4dbe78be286e120e863b3d5ea7aa0ae8d23b" + ], + [ + "p", + "3a5ccf9f1eced28f3a34db176054f17e0fe25492a33d734b4c0482a09a275eca" + ] + ] + }, + { + "content": "Report is part of free speech. You can say whatever you want. People are free to react to it. And then everyone can react to reports. \n\nReporting is part of Nostr, not only amethyst. Many clients implement it and relays use it to delete users and posts. Amethyst just displays them for everyone to see. Hiding it won't solve anything. \n\nOn the warning side, you can disable warnings on the security filter screens if you don't like it. ", + "created_at": 1690111912, + "id": "e8091b484dff5afdf25f0f4a4d1199944cb1ee13f3938f78030fccbd6da92a33", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26b2ad6f938e396084629c10d48b85835bc4f0571fdcbde974e5b0eab052cd0f829265bdc80efd86790adb8ed2e51207dc6ca467d62bc8d949d8cfeccdbeacae", + "tags": [ + [ + "e", + "c038888ec69c6c8987f5b2dfd224ddb55ca039689b0a20a22287303ef8894f36", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "0c9b1e9fef76c88b63f86645dc33bb7777f0259ec41e674b61f4fc553f6db0e0" + ] + ] + }, + { + "content": "Change the keyboard. Graphene uses a very old version of the keyboard that doesn't work with newer UI frameworks. :( ", + "created_at": 1690111377, + "id": "600b878282a93e2fbd1533727127ab4ab3473fbe0fa80c263e9877bdba46e912", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5ad458d6a0de654da1370b0e88b6ad2478182e78a05c7c4d0df5d089863f4aa70f0683ad871f96b89f7b87af7b68c6fc9893f9d7a4ee393f99b579fd73eef712", + "tags": [ + [ + "e", + "6d195bd88b4a7d4b3522a862fad439d4351e0bc87659a5d50a34aea8c8499e1e", + "", + "root" + ], + [ + "e", + "1ee94a94cc5028a217a1d8159454145ccdaceb73206cc85791ac47d84d79e1ca" + ], + [ + "e", + "c9045bcc1bbba5a5934ca475cfb9b6eb94dd7863b4d56c841d4e0f112d541882", + "", + "reply" + ], + [ + "p", + "27154fb873badf69c3ea83a0da6e65d6a150d2bf8f7320fc3314248d74645c64" + ], + [ + "p", + "27154fb873badf69c3ea83a0da6e65d6a150d2bf8f7320fc3314248d74645c64" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "3a5ccf9f1eced28f3a34db176054f17e0fe25492a33d734b4c0482a09a275eca" + ] + ] + }, + { + "content": "Nostr is open and all reports are public. Anyone can build this. And I can use as another input to the filter. ", + "created_at": 1690084953, + "id": "de7c95cf799612ee6fb6f4e4d3d353e765fced03e8176ad112433e12c9950c08", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9cde5c0b0ad14f2d7466ae995af622562284576202b40ebbea462a67fd763ae25a54fcb83ef43709146f9e088cdeda3fcd9490ce167052d61bd1f5e3f3a914dd", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "595c5a0f0cb5b6d06a0a7219c62d11a4283e122b3469fdc5bb57ed30e4ab25cb" + ], + [ + "e", + "c65fe276c2338588773707dfa2294a313721594ec93a04d6078624ebd2d13632", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "Just build a system to expose false reports. Fight free speech with more free speech. ", + "created_at": 1690084772, + "id": "595c5a0f0cb5b6d06a0a7219c62d11a4283e122b3469fdc5bb57ed30e4ab25cb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6f63e1c98bdb9b2fa9d34c47afe17a06d0a5d9da12c1c4d37355952e34ff4db9e0707514a73b4f86b7c4190584f4a31c7a2124493ae088eacc61f02c81779bc3", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "b2231c9bc27017f9f57892ef75eeeba2a31a255e43401b689ebf3660caa6b22c" + ], + [ + "e", + "20a22ad4b7db92df61d4f8338846a3b322612d52efbd60a9cfc5c8d6aa791acd", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "Nah... We have too many kids with the app. By default, the experience should be conservative. They can open it up when they are ready. ", + "created_at": 1690084580, + "id": "b2231c9bc27017f9f57892ef75eeeba2a31a255e43401b689ebf3660caa6b22c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e0cf116ac3872a4af3ea99bb84255fa46a6d0e99054a00936c5bdca4912d31c37f4687e27af77b17d4a3919c66de87ec709ce6d1432806e68881b54167ece253", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "094aced9e82bcf37d554a6967aeef0f7cadad963ff6f84495b16b26269ddbc26" + ], + [ + "e", + "07cf4cfdccd7746e1dec48784a78569d106efce752d6dd0c0b196c1c395613d4", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "Allows people to block you, yes. That's not censorship. They can always opt out if they don't like it. These days, we even have a list that only shows the blocked feed so users can confirm they don't want to see your posts. ", + "created_at": 1690084378, + "id": "094aced9e82bcf37d554a6967aeef0f7cadad963ff6f84495b16b26269ddbc26", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f0bf30ddeaf6e19c1a13146c98142d36b3559a909d330c866e1e2a9abd196801a88440a31cca574e7da2b1ce8f37c2f57374b720a570fda697b25e1aa54a2041", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "52b562f2f95654d2b2a84b742285337eba5cc400ffc33bdbe21e7ee4a5f6198c" + ], + [ + "e", + "7994583bab3bde80a01b4a9b8703913eacac1e4fe7ee20cb21220ea5b5605bfb", + "", + "reply" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "We also need a data heavy client to make people accountable to their reports. If a report is false, everyone should know about it. Falsely reporting people should damage your reputation. ", + "created_at": 1690083028, + "id": "52b562f2f95654d2b2a84b742285337eba5cc400ffc33bdbe21e7ee4a5f6198c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "39e55f79a467361e56eb2d1cd49c321c99965855af3e0a976605b0fe494b7b1a963a1ff122baee02c01573c0665b5025e990258105816e75f3e0ccef764d630e", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "reply" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ] + ] + }, + { + "content": "Hum.. I am not sure what you mean. The domain is clickable right now. There is no expectation from clicking the user name (there is no email or web address we can point to) ", + "created_at": 1690079870, + "id": "bde7b3599fde0d7a80a96b20393563382c5d1c622afb062940b0770ce1bd7e7e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "201da0302b41f0b1ad45c723870c6b82a2352eea67ee890cedbeef394e8c32536a7fa3117e4057972002891e7f89bb3bec60214b14f3bb02a0e94295a87ffac6", + "tags": [ + [ + "e", + "f2845de7cb2d774af657ce57b2886debd9b5b9bec9660664bd40910ade0bc769", + "", + "root" + ], + [ + "e", + "f90bf3162e5430fa9398709f39c9fee0b81b175ab4fdc26f648b1c90fc5caa17" + ], + [ + "e", + "d5a7523bfba9a9d6144fe3f6de462555b6b4ee3aeb6e581ab308003bfb9d4660", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c" + ] + ] + }, + { + "content": "", + "created_at": 1690063233, + "id": "a0b29a3283c10b5bc63632e9d4e6554e46580b6320513a62da68c8298f464cc1", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "44aa72f7f5644f6f3f07e7ab5c76f00c2bf7621e6ff172743591672bf70a27e0deb5961b9e043c4bd3e95007320d75c470cd283b799b9cf8f9022e987465d86f", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_581d924e2f04b41cf2bb258623ec6800a7420f83bd81ff44.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "9180a00d8de2209c1d2623af0b5a492471f967d080e6620ec28e6f003ffb49bd" + ], + [ + "size", + "2371930" + ] + ] + }, + { + "content": "Spam :) ", + "created_at": 1690062330, + "id": "9edeca1c1c29db5cbee94c2f38637eb46774b84dd52c9d2a6d5910c8352d3898", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "071c1ff194a234f1009628994191b3e77a22874a68dc4d82e799d2f35f6c6f9edbe83eb79ad07fda3b8fd096fa83c938d340c49fc271bd197ecd640978c4ee74", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "93fec67f101706b4ab832466153df265c75593fbea8e3ef302410710fe833a56" + ], + [ + "e", + "bb953fe6a64516caa9a0c2285a29e5ded168572be6cae1f3daa63bd8321965f9" + ], + [ + "e", + "aa6f251e957f4ae00f0596359f3185b9579c3602e351bfdb0dd10da1ca3c2c44" + ], + [ + "e", + "be4b56d812d4ae5f1c31b147b18be3b60bbf3cbdf636a4f02e61fd850cfecc9c", + "", + "reply" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690062314, + "id": "f2e1535fb76026dc640c6a9ee171637553fa45a7beac7be1d61c59727f421a94", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94953d6006f0d76f29f5b4b097ffdd3ef3becccad9020b1ef4620a3d567c085c5eda72fa139ebe845219e4be1b33ee54b78735df4ae9eafb563746d717de46ce", + "tags": [ + [ + "e", + "d84e9c154142dfb6327e6003358822b808d1e13f64d6be4182b05b97e4cd56bb" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ] + ] + }, + { + "content": "That shows that you are following this person", + "created_at": 1690061566, + "id": "f98bf74282c9719d53a89764c270b1338e9d3ee9bc81e48022e7d825bd48a52f", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0af6b4e3005dec7a59105e3c7d038f29c0006930ef934841864e241cb2cac4445e88c2786b7314e6ea4cefa2e172fdc1e8cf2ab2a75831bfd5dffe5e618457cd", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "370eb5809959295eae9693ee07e1183501363a587f482d8e2fee7044994d97a4" + ], + [ + "p", + "4081035ac927281b3a086d12b6a6aed9f11872a2d82b8f5f1d0706244e84d535" + ] + ] + }, + { + "content": "Those are all from strangers. The spam filter can be disabled in the Security Filters option from the side menu. ", + "created_at": 1690061413, + "id": "8dba54845e5bcee2c3391a34843ac9faefa452961f9cb89c9e70f0233e5c03a0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "01881e8ad06f625f446c48fdd7ca3ceff54f07c4c52d47c0b0e8c25b896b75879222e9481fe84024d73524d02bcb83eaf67850918202f7ab1e95580034f7d32b", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "f19ab878cd9aed173d5ca6f12fb0036a2bd992091e2f9e13f20887f7442b3413" + ], + [ + "e", + "4daa7e033c8cc3fc6e1e98fce4569c876ffa56897016a92e5e78586bc15a5d59", + "", + "reply" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "Yeah, that screen is up for a massive restructuring :) ", + "created_at": 1690060494, + "id": "f19ab878cd9aed173d5ca6f12fb0036a2bd992091e2f9e13f20887f7442b3413", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "db85ee76fb66a3eeee6f8036e283c5fd8974323225a47535014658e08eacdf220d3197997909c80f180b15adb71d4cc0842f5e4fe5280618d0e3d4199f3a8493", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "06ba84837bcda8cc94d9a11f45e2cf0c0998972c4686a17da8b63500be31191e", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "Yep, but on the private side of those lists. Only the author can see it", + "created_at": 1690056576, + "id": "25e3140b1d838154f06ee1f7dafd52d16b52d38bbf679245f65e3ab889758e7c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "adfca8fb093f442e66d51bec2a063f22e7a293157063a8637fb191aa7e41a4f0f1a06e126116dae11027406e958e7dc0d98eacc830281e8a5d3298880e51915c", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "aa6f251e957f4ae00f0596359f3185b9579c3602e351bfdb0dd10da1ca3c2c44" + ], + [ + "e", + "acba23d2aae16f161b6489515746febbf3b3d5a581ab581d464e7b93cbcf5c42", + "", + "reply" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "👀", + "created_at": 1690055271, + "id": "3be55d0c79a7803d31aa9aabbc72ee943d253fc4bb9b69655d54163b05a2b9df", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a5f26620c693bbe87a824c60f829a8d9a20afe982224c0e811bcb10c2ad7cea824761da226796d8f53041b25c60e8a7883fda40a8d68770ae826d19d7f8e7c61", + "tags": [ + [ + "e", + "011d95918a2c4fe8c6666784d8229984999126bb24520ef2f3ff2bb799c725fa" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "Block just hides the person from your view. It doesn't affect the person at all. Reporting does. Block is just for you. Reporting is when you are warning everyone else ", + "created_at": 1690055267, + "id": "aa6f251e957f4ae00f0596359f3185b9579c3602e351bfdb0dd10da1ca3c2c44", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ac3e68c9002292121c89b7ada44df8f69b54c9f3562c300d6d40b5a0979d293c907ceae39b266db368eb473064772e21f205152445e43c9141132125a649223f", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "93fec67f101706b4ab832466153df265c75593fbea8e3ef302410710fe833a56" + ], + [ + "e", + "bb953fe6a64516caa9a0c2285a29e5ded168572be6cae1f3daa63bd8321965f9", + "", + "reply" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690048291, + "id": "6ebe2878b5bca21ab869d71278c4ee25f1249a960b5f2c873d97b0a2d5f154c5", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8446c68226f4c486ecb40ca82817ae12eaad2ab31dba5985e16adb1e7a6b39830df81a371367e5ba7a10be2627b5b47f4e8fb02d68726b814a74667213bda6a4", + "tags": [ + [ + "e", + "927c65129de519fafecef2bd2cd75785f6843b2da0ba0697d574131f9953782c" + ], + [ + "p", + "1f297398e7abbfade212d7bf1e98bfbf682064a9a5cea4a5f3de49df63330302" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690048258, + "id": "84ec8afc369b68af39a44177b8a7e7a6146642bb3ebae85485eacf2a1195f4c2", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8be5d9851ee5fc0d0589ef960d1b7f211c6cbed005d06b8693d18f5fcfc1b8949e5353842fa3887de2875b25a528032fc55fcffa43af3df89509a4e28d987bbd", + "tags": [ + [ + "e", + "1de83a4e1cccd7a424ce97f67cc45d7cd2b41766dcca3fd59a59138aa982b9a7" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690048141, + "id": "8f3ba22a76f92c56e0670f8c47d47d190aef401bfb4699d896d0b575f52ef455", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bbc4655749a64f7cd973364e9fdd77a94b8900fbe874e140bdc1b7221e7e6a38441aa893ce9c1cdfcbfef91f307233ee6e39aa7e7bff40f5bb782aebef268f38", + "tags": [ + [ + "e", + "661435f046df5f20399924de66c34672eaeaa04f6b5b3c3918a2f74d2c768694" + ], + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "Each client does it's thing ", + "created_at": 1690044425, + "id": "ec0f4b6f3d7f08c2183cc397527b48f47973deba9eec2c77a6cedbf2652cc1bc", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "04e00c840222bbf02da8c15aac1906d2861a123bb4324b84f14e663b3975930d932eb7f85a4d16335a1afcf745577d177b4e81adcd2aa20c14f890eb5e321d89", + "tags": [ + [ + "e", + "e1574ee2757c6443502ad1de2e9bffabe146f6ed0edc15be6cee7fa9725af430", + "", + "root" + ], + [ + "e", + "8ff6ab31d730b8934e6a788be0a686c71ca41985ad87f6d15f3cf2d6ba99ea55" + ], + [ + "e", + "ccc770aa2e496461dd8c328e889e8f4ac3a02e4e2e0496bb083bd280e6bc2266" + ], + [ + "e", + "d4b98b5cdd30cf226fed081467dcd16caf6948b1cd389b2f6d7a1469d36ae864" + ], + [ + "e", + "e1bd0e07369d4fbc9141f767f8d643ef20db243bfbb5f171e1799dbeb33d27de", + "", + "reply" + ], + [ + "p", + "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177" + ] + ] + }, + { + "content": "Only if you create a list of nsfw authors right now. ", + "created_at": 1690039711, + "id": "04ffa93f7c9c765191f6ff7c33b89870a5521226193b46739d283166e0457493", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8cfb4c7d3ea59ff087f801e6aac44bdb0f82dbb17241caf80f1896df5237d8398b446729456d71c296c47538661c0ee99403e5ce71c2c1d323e6279097720fbb", + "tags": [ + [ + "e", + "21db8800e3654871004b7653e549d5dc9c03028d05abb1c9417bed4405a47d77", + "", + "root" + ], + [ + "e", + "163d779823264345dc473001dba0987423c4c61438ec2b887695ef3f4d18e636" + ], + [ + "e", + "d9a46441a3df57e1549ad2d2d458fa4e95b73a9dc5034a6212e3a61112d58e92", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "People can just flip the always/never see sensitive content on Amethyst settings. The relay option won't matter. ", + "created_at": 1690038798, + "id": "163d779823264345dc473001dba0987423c4c61438ec2b887695ef3f4d18e636", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0e835e8b5e6e938dfd5ae6f42a7ae6d593725a42bc57985c729e12f709f5128ea83665b051edaa49aedbc50a4c2a91998481a796b3d75bc3509c068d9b534301", + "tags": [ + [ + "e", + "21db8800e3654871004b7653e549d5dc9c03028d05abb1c9417bed4405a47d77", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "Nip 50 is supposed to be that. But only very few relays implement it. ", + "created_at": 1690038557, + "id": "d4b98b5cdd30cf226fed081467dcd16caf6948b1cd389b2f6d7a1469d36ae864", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2a6152d146f5bdb7bc10e6e86fceee6ec2010c5ecff107b89f51d2dd577738b727f8fcfe6ede555b6a64799454e9f73c68baca41b9d0c18b55c44ac1949e9470", + "tags": [ + [ + "e", + "e1574ee2757c6443502ad1de2e9bffabe146f6ed0edc15be6cee7fa9725af430", + "", + "root" + ], + [ + "e", + "8ff6ab31d730b8934e6a788be0a686c71ca41985ad87f6d15f3cf2d6ba99ea55" + ], + [ + "e", + "ccc770aa2e496461dd8c328e889e8f4ac3a02e4e2e0496bb083bd280e6bc2266", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ], + [ + "p", + "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690034791, + "id": "5814e68e1a5f5cae768c0661065449b7bf981162b9e31553740a11749df1a668", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1bf9fae5ef84654c3b79598a72396072eb59f588151a132deac8948712774ea4c1f790e47075d7bcdeb09a993011896fa0532e69e5efeca9bee32c7f105d559b", + "tags": [ + [ + "e", + "84179e0bcd619efdd068c9a604f4fb3f1ed158c7798a2f7c8af6b4e1c9f1ab5b" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690034686, + "id": "297d8d5ef9a77a74348fe0e490e515e6912e8bda2173a20ce31f6ceca1dc3c90", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "19e249457e3e695dab747a373dced6fbbf625c39d5212e9f020d73f30e39187b66a232d89cf070e1a5e147f012165fe1d5bde4c45ba4314dea4be43259469852", + "tags": [ + [ + "e", + "47e702b7a6c69e43d5113a0eeb49c2bf934611f3cf7eff98e215d798f53f4492" + ], + [ + "p", + "1c6b3be353041dd9e09bb568a4a92344e240b39ef5eb390f5e9e821273f0ae6f" + ] + ] + }, + { + "content": "Did you try specialized search engines like Apache Lucene and so on? Some of them seem to run very well on Android. It might be true for iOS as well. ", + "created_at": 1690034653, + "id": "8ff6ab31d730b8934e6a788be0a686c71ca41985ad87f6d15f3cf2d6ba99ea55", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d989836b6e209dfec45244ee467ae7a92ad9b6d35b5b31b81884191e81e75a726bc1064901874a463a3f5f63a2c454d00837f5ee668a3eecc4784b923bb3556b", + "tags": [ + [ + "e", + "e1574ee2757c6443502ad1de2e9bffabe146f6ed0edc15be6cee7fa9725af430", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "Unless you are going to do a massive bounty, bounty hunters will be either professionals with 3-4 hours to devote to the solution or high schoolers with around 10-15 hours of focus. If things feel that they will take longer, they are not going to do it. Neither group will have the time to learn any WoT concepts. \n\nThis means that the more especific you get, the easier the chances of somebody completing it. \n\nHere's a template: get code of server X, add a listener for event kind Y, which runs through the data Z, organizes in this way, calculates a score using this equation, sorts it by score and writes the result on a event kind W and sends it back to the network. You have to give them every detail. ", + "created_at": 1690034428, + "id": "79531a6456980a95d5177bb0904a116b771eef37a374c30621e3fcf6b57edb56", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1fc94a332ae92f993ef384e0613c2a62101776cb910e69c1a818f0fec165c8cd2af1471f514af41164793ca40345c7fbc2c6fbc260de6838040da9f9efd4f7ae", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "622cf2369280653f92b3b47b802feb93038a9db3d1253cbb1af23303fde84c72" + ], + [ + "e", + "084cb0d0d8e7cb6ee7cc3b4e0c14e1746c6192a329e5e644cf2aa42b0702dc87", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ] + ] + }, + { + "content": "Too many options. Keep it simple. Focus in one VERY simple use case. Fix ins and outs and let them fill the middle. Remove all the jargon. Otherwise it will just be too much for anyone to be interested. ", + "created_at": 1690030365, + "id": "622cf2369280653f92b3b47b802feb93038a9db3d1253cbb1af23303fde84c72", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a67e5db83686746310d721b2896cd14bd36343010cdd4b1e295e38e9b3f7bdd461904912e07f352cfe0bcf34b44f242edfd28cd14e7dc4b1e4bcf6fd1001e7a1", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "311df23c765f2c44322acd39b0838474cd6c911bdbbe5133561a63d7b81c8245" + ], + [ + "e", + "0fc743840d438b7ba2472e9a4c922a72f38ad9822f05e885e48dd5a70c9de141", + "", + "reply" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ] + ] + }, + { + "content": "List of posts or other people? If it is people, you can create/update nip 51 lists and Amethyst would offer then as top bar feed options automatically.", + "created_at": 1690029264, + "id": "311df23c765f2c44322acd39b0838474cd6c911bdbbe5133561a63d7b81c8245", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a4fbeb9f8b2f90e3bf578cb6ee7d3e144dee810ed20210a9ac862db0ad501829088fbccf5893a4d9052a6ecc6fb7577e6614d3472e29a742958e3b7fc1b66106", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "c936aea32b162c762c3b26ad7ff303869cd018a55804c28f5486c4545c37b55d" + ], + [ + "e", + "fc9a175bbd853076754d9ea1a3272e089875ded5c03b1d3b7d11e4c567ad1364", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ] + ] + }, + { + "content": "", + "created_at": 1690028553, + "id": "f4c3fa3b4e18028b986728b70fbd4f24747b28840ac2dc880fe128552332c37f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3b7d8c66c8c7c8db6c12e274e455e072b9964c92915bf51421e421a61fdd0eb7822705a251f6a1faee7c02462bff739d18825f4c06cf23feb231336e0c3417e8", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_77519b031bb3bfe7956b501a01197a9508f376697f378577.webp" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "667c8409013988802a168043034a06d4501719aa27ab6ebbc4b825db417f281c" + ], + [ + "size", + "26866" + ], + [ + "dim", + "510x640" + ], + [ + "blurhash", + "_BRfkB%M~qay-;xuofxuM{ayRjj[j[t7_3RjM{xuM{ayRj-;j[RjWBWBj[Rj4nt7%MWBxuRjxuWBt7ofRjRjj[fQ?bM{RjofRjofWBt7%MM{WBt7RjM{-;ayWBRjayWBt7" + ] + ] + }, + { + "content": "", + "created_at": 1690028054, + "id": "ab3df16710d9ccc53c08e9b54232ef0b378e3eaadf0acd0f7649800fd4787857", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3cb3fc0059720310a950543e3f14d84830b3b8ab748e7f29b6d4d8e4ee4abb60858056bcc1d438a2a549da236f83e78cf52ef8488389f4cdb3371c4e1e63c78c", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_4b1b9cad209ed9ce79cec1bea6c76836bef273d9f26ea42c.webp" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "d9af8e835f0fe1024bebea6efd45348c6abecc2cd6cb12a5c217061f03cccb44" + ], + [ + "size", + "71462" + ], + [ + "dim", + "700x804" + ], + [ + "blurhash", + "{nK_5qWBogay-;t7%MWB_Naxjsa}RjWBRjof?cWBt7oeM{fQayoL%MofRjWBofj[ayayWAjtofj[ayjZWVayIUoeayWBofWVj[ofM{WBofoLayj[jtayWBayj[j]ayf6j[j[t7oeayayazj[j?WC" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1690027967, + "id": "b69a97ffa74a84b2cd1d7ba53f6b02d4b84024cf32de1c3df6eb7be819ee90f5", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f50ba3ebf9dc452d8ea8862b7602c2837f96b23515caa05222353f82f2c30228f74d942121ab4a1acca3ee2304c501f5d92e1fedf77d0122614f59f40f8f68cb", + "tags": [ + [ + "e", + "2583ec8975b63ddd5c373d2c8bc7bba419362b8c9483945a78214edea52227c7" + ], + [ + "p", + "4796eb254645a3b0db24e77dd7bae72eadfe18fc4c24f2bc6c4e658c4fec4ed4" + ] + ] + }, + { + "content": "", + "created_at": 1690027967, + "id": "08c3212fa15443ee5b0b7c71e0192ec47e79deababfdd5637f7eac30d9d1b387", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e48a9824005948ca96d4b16696a0ea75e255ba1f44a7ba9abf823b14992faab2b57d58b8198b36a3598ea075f2c5d1b38188dc3c4f90ccb8f84a636c1c87ce7d", + "tags": [ + [ + "e", + "2583ec8975b63ddd5c373d2c8bc7bba419362b8c9483945a78214edea52227c7", + "spam" + ], + [ + "p", + "4796eb254645a3b0db24e77dd7bae72eadfe18fc4c24f2bc6c4e658c4fec4ed4", + "spam" + ] + ] + }, + { + "content": "Have you built a data vending machine for that yet? https://github.com/nostr-protocol/nips/blob/67e950a2009e81df1b8c91b0a2ade0596e83f168/vending-machine.md", + "created_at": 1690027938, + "id": "c936aea32b162c762c3b26ad7ff303869cd018a55804c28f5486c4545c37b55d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0a25b1c1855391a31be333daeaea31b0654cf804f0c620b3c44651ab7d59d48816fbd26bd519e0a68ac558d4b9233931f0f20624e91e1291220cda94d67f8c28", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "467b16aa0975ae962b9322438448a5b94d28c9677aa0bf783f0d5a97c85238bb" + ], + [ + "e", + "8ac9aea5873049675b1340276158bd332f955230c7eb3c606a160bb15dad91df", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/blob/67e950a2009e81df1b8c91b0a2ade0596e83f168/vending-machine.md" + ] + ] + }, + { + "content": "Yep, that makes a lot more sense :) ", + "created_at": 1690027857, + "id": "d8c51aa8ff8e880bfc229b556b762a8322d25a0273bf138f55a3bb5a4e6f3683", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "52fdfead0a8c473b7a467a820cfc19f0214f7dcce763223e0c74b35e627c97ae0f86875348592b551f006a8b0ef386d9c5f51c26330a6bd1f708ad7c14244fb4", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "9a88b1aa5e514e33a721a8e036e1e49654b31133f8922d27e9bc71d0f463063e" + ], + [ + "e", + "1326e784c89aae0d495b933710689757ffd307e4193e7de396b04e1b49c194a4", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "", + "created_at": 1690027511, + "id": "e97c4db1ec0cf8701dae216a0883239fb870ef8e477f0f83c9935c8225d0186b", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "70803168e9f3a53c4f05ddb26ed8d68d716680ae5e77b1c41efc732abce1541adfc5d01b263d868169a0ee5791d69db982944db8ecc48c4f1ffbc8f75b2ff31b", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_fe2f3b78ab59248cd0e65015d39ab827b60f4bfbc1e91162.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "7e9ed8613fb4d077afded5b131cc9238c3e5e9737e85ea697028ee0acf9cd3c5" + ], + [ + "size", + "393081" + ] + ] + }, + { + "content": "No, the kinds to classify nip89s apps by. Right now it's only by event kind. We need to do “I use DVM npub1XX for kind:68001:job-req-feed-generation\"\n\nSo, you can filter nip89 apps not only by kind, but by each individual type job being offered. ", + "created_at": 1690027384, + "id": "9a88b1aa5e514e33a721a8e036e1e49654b31133f8922d27e9bc71d0f463063e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "95dd34d7ff940ad5e92d0fe11ceac901494d5f62eb6c14f3cd53ed3872a46978a4e92859ee09645bee1c2bf5381ad039b437345c93bbdf18bc12412a3a929ef4", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "619c69ba1e8343d87ccf36260954a45bb52b8210c5facfd87861a06fcf5561b8" + ], + [ + "e", + "71bd71e53f0e857bf5915a181ffae08b26557158690170f32c3976d20727b2b3", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "Sorry. We haven't optimized for old phones yet :( ", + "created_at": 1690027143, + "id": "f469473308fa5e222d3e5358bdd2eaf6b1c4c135f4ab4bf60dd6cd1c8ebfa7e2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7dbc9e95c3ccd98ad00dc84fb50bb78fd3a88e1d280c6fda72f4cc7155755aea71b377445144af8017f1c03d98908d67e3d46c2800368354c67c95ddc2519f5c", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "3e646fa6294a2a87e6ccd619c39a50974d6c70216eae79d700bca6c46179a389", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "43d140f61e46792199dea8a8ab4634dd21f1aedaaa4fbd29add506f8029f1265" + ] + ] + }, + { + "content": "Eventually, yes. But specialized apps like those are always going to be better, simpler, faster in their specialization than Amethyst can be. ", + "created_at": 1690027096, + "id": "ae01161cfb1508092b9b4d9ac9a16809b354b2753490c870585da6b001e979f2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d25cd417e0cb9917d472a3d5707ff031c70654486ee79457496103af389f3434a530a2f2f3979ae36501ef81d160d3babb221f9aa98056b0bbe8d612be98d1c6", + "tags": [ + [ + "e", + "3d995935ead0e9a10af53b465a2c2c0d1e309d5b9cc563e8c0d6f0c569bb8c97", + "", + "root" + ], + [ + "e", + "540605b2e4ab1cadca0abe9806e96c06b643de78e34e1275133cf1dfb3817cbc" + ], + [ + "e", + "e79e452b991dc561d99eb33f278dcc26e0cb105eaf40eb9a3aae87ae62db315e" + ], + [ + "e", + "50719f470bc924b46617433b06dd67aeba0f9b19bcc810e89e5428a7118f81ee" + ], + [ + "e", + "3b992f41a24e66a78cb3c5779edd18c300a6a8a04afc722f8a9e6efbddac8f23" + ], + [ + "e", + "5d4e39e25d2453b8d42b2093d82ca99da9c65f6e8d56f3b97fee4e3b271299b3", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5131f3572c960cfa0b5d262687565fe53a98c8d35bbfaa2daf029bc3f6eaec88" + ] + ] + }, + { + "content": "As long as there is a way to break further down from event kind, it should work. ", + "created_at": 1690026998, + "id": "619c69ba1e8343d87ccf36260954a45bb52b8210c5facfd87861a06fcf5561b8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f9a77aa366699537f4e52d284bf4608c3959115b0ae2804adc47f69b39298385857f94c077c01d3a0e646d0f003c9104516f451d71af3a90ee3bb72ad6f5994e", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "1fc961d0a4f87cd225eef96695507f5a42aa446620090c4841faa4a84c22751b" + ], + [ + "e", + "d2c109bbcdb0ac2b0f6166b367d164a9d96e72605bd4f5c86dc31fca0be7ff1d", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690026846, + "id": "e799c69de8fa38f99997e48dbc2b5d203a841bcf1a20de848456c38d1199d29e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f705dc055fa87d21e15579f6691d12cbe9270b1682edb81f0861780fbb09c4fddb92cc20659d2214db6b7436830149eefad724defbdb56a55abc24d143714326", + "tags": [ + [ + "e", + "3365db2747c4f5ee69b30cccf7c20eac703d2c61f2f4e0b1bdb6973ba211a037" + ], + [ + "p", + "4ea28e364020611166ad3f826fd476c05b2c7852fe3d691a2c43381ebe704d0a" + ] + ] + }, + { + "content": "Yep, DVMs the user can subscribe to are way better. But we need to figure out how to log down which DVMs the use wants to use for each task. ", + "created_at": 1690026543, + "id": "1fc961d0a4f87cd225eef96695507f5a42aa446620090c4841faa4a84c22751b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7fbc9ab6433ebe48f29bf9c4cc3e6e48ad1fc057cfa8a292600a12ca4e95c14022770b7d679ebd248663b949b7cb1408862715e395082601232c11f462175b60", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "16ccafb23b3ea34d29a5ed546e30e21c29457458b389e59010d3f577e83937e5" + ], + [ + "e", + "9ed61ee9d569308861842675798444bd50f133a63bdb07deb6345be9efefe73d", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "Create lists of people on Listr.lol or highlighter.com \n\nThose appear in your amethyst's top bar as options. ", + "created_at": 1690026476, + "id": "3b992f41a24e66a78cb3c5779edd18c300a6a8a04afc722f8a9e6efbddac8f23", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fbbf7043f61a63c1bee4f3d986fe6e6b9a58a9225cce97fc45acba1e6ddebf3310daeab1cd121e020f35d015830c843e02d8d0006abcf63d785d347cf4f1e923", + "tags": [ + [ + "e", + "3d995935ead0e9a10af53b465a2c2c0d1e309d5b9cc563e8c0d6f0c569bb8c97", + "", + "root" + ], + [ + "e", + "540605b2e4ab1cadca0abe9806e96c06b643de78e34e1275133cf1dfb3817cbc" + ], + [ + "e", + "e79e452b991dc561d99eb33f278dcc26e0cb105eaf40eb9a3aae87ae62db315e" + ], + [ + "e", + "50719f470bc924b46617433b06dd67aeba0f9b19bcc810e89e5428a7118f81ee", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5131f3572c960cfa0b5d262687565fe53a98c8d35bbfaa2daf029bc3f6eaec88" + ], + [ + "r", + "Listr.lol" + ], + [ + "r", + "highlighter.com" + ] + ] + }, + { + "content": "Yep, I don't think it's good. It just reinforces large accounts. We are not here to centralize everything again. :) ", + "created_at": 1690026412, + "id": "467b16aa0975ae962b9322438448a5b94d28c9677aa0bf783f0d5a97c85238bb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "356c955f44848aae2f188efa7a26c2f6f0fcb3784c22ded65b992d0a4b26dc967a818b890cd8e7c8228136c25589e9ecf4f5fcac766c53e339b96eda42b805bd", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "088c6a88368f2b7c0bcac5be688b8a493161add325c32742ac2e37439dcae5cf" + ], + [ + "e", + "d4bb741326defb5f62196196c4fdc6c899cf9f2be20de1de1a6c6dc9cf131e03" + ], + [ + "e", + "16ccafb23b3ea34d29a5ed546e30e21c29457458b389e59010d3f577e83937e5" + ], + [ + "e", + "670401ddf5959ddb5bbc72451d31e36d526d1879280dd3bb89321dd5fb46164d", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ] + ] + }, + { + "content": "We haven't yet figure out a way to make trending actually work. Right now all implementations look like Netflix movie algorithms that just bring a bunch of stuff you don't care and the things you care are harder to find :( ", + "created_at": 1690025979, + "id": "16ccafb23b3ea34d29a5ed546e30e21c29457458b389e59010d3f577e83937e5", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "644876edca3832f919ba921368d97a48ff95c63881825ee7c8bba53ea8d7016395b57c83a41f097b095e378ccb0bf2880c3af4c2b7ce3abfeb10b75f03743f7c", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "088c6a88368f2b7c0bcac5be688b8a493161add325c32742ac2e37439dcae5cf" + ], + [ + "e", + "d4bb741326defb5f62196196c4fdc6c899cf9f2be20de1de1a6c6dc9cf131e03", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ] + ] + }, + { + "content": "🤔", + "created_at": 1690025791, + "id": "4e750cceca0a0377fd97bc3fbe02329afac6753be8273d4051091d8b2cea2b87", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1e9091ee1f8df139d36d948c50f3a28df024f52fb4736eaa013304c17a92a915a214a7f8e5e0ae1ee05491510be63ea037f7bf31776a3e18d94c4bf2bb89e28e", + "tags": [ + [ + "e", + "ac3798231044db275288061a933064011544192f3b867644a1952ea76bbfbf23" + ], + [ + "p", + "c4f5e7a75a8ce3683d529cff06368439c529e5243c6b125ba68789198856cac7" + ] + ] + }, + { + "content": "Put it on a list and access it only when you want :) ", + "created_at": 1690025656, + "id": "e79e452b991dc561d99eb33f278dcc26e0cb105eaf40eb9a3aae87ae62db315e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "768d7cf56d649dffba187a7d8e04f8f2f7aec4850277189d4025da4c37d741d4e05007393b775ced3e335747ada23ecce57d43a0d124c0462aeda070c274a2b5", + "tags": [ + [ + "e", + "3d995935ead0e9a10af53b465a2c2c0d1e309d5b9cc563e8c0d6f0c569bb8c97", + "", + "root" + ], + [ + "e", + "540605b2e4ab1cadca0abe9806e96c06b643de78e34e1275133cf1dfb3817cbc", + "", + "reply" + ], + [ + "p", + "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5131f3572c960cfa0b5d262687565fe53a98c8d35bbfaa2daf029bc3f6eaec88" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690024223, + "id": "817f132a4daf21a41f30096ffdc97fddfd4681f9e66e9decc1548a7e5ad41d85", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d5b6fe39efad7254f37894035bc52edc508bd97911e53dbae69ad058644fc0f35100ac36598c2118fd2288178f3b1ac55e4881cd7ab016a5907d40b6bd296fa9", + "tags": [ + [ + "e", + "3e0242a1e3afac94f6ec5f72da83429287d8b726c04223342fc738e151a616a6" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690024192, + "id": "1c467891446567ff979de8c0aa3900c1f6d125131f119df522feff8929afcd86", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8e75d7e513399ddaf8800926b1c63554b96539ca7df4fcbb3e53240e1a4f8d359f1597d5af157626d3c777d4b95bd2a5fe9fe107d0adec8fcec0dcfbeeee166b", + "tags": [ + [ + "e", + "c581efd5a4d700022e040db861eecc0f5167539cb19f57b7bd1417b2360aa165" + ], + [ + "p", + "7c1521d55a53580a05990c6cc4bae09c9822169b6e544b1ee47005a6c3e9e9be" + ] + ] + }, + { + "content": "Amethyst doesn't have a local db yet :) it's all in memory. But search is only used to find new profiles when you click the search button. ", + "created_at": 1689999260, + "id": "c1309e7769630260fdfa5ff25858f3416eab72e78c8a80b9ecf33244b62b3848", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "904e705f75a25b9840589fbe86455b5929d6e1755a43b35e24ff339bdc7edd578e8c4e9580c6134ed86bb8af78fab6503ce4085342dc0e6244993d5e157bbc8a", + "tags": [ + [ + "e", + "dca4527bd76908cd8071fa80d9241b552c3c2348719bfa42c6bd8137a568e9d3", + "", + "root" + ], + [ + "e", + "a1c52b61bd6c9096a98ff51ff63e1cf54e868f481eea2a503f6fe3e806946d43" + ], + [ + "e", + "a1461580c93fc038bfad8c9a457b6e4156d4217e0d9f3b7f95e51ba82ce95c2b", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e" + ] + ] + }, + { + "content": "nostr:nevent1qqst4hmuwdt88ru05r99mqlf9e0a5c76hus738ns6p90d8s865mgqccpr4mhxue69uhkummnw3ezucnfw33k76twv4ezuum0vd5kzmp0qgszp55czrt2t7ftq3dduqhthtwfqdkhg8xxs6cqg9wy9dprdlj26tcrqsqqqpp8z68rfc", + "created_at": 1689998418, + "id": "2529de5d4177178cd1bc495ab3c9fd47728f1a8d3f2e33615918d57a33d88e77", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6545f19adb258d6fa5cd2ad10b648c15f0eb5c2ef12f19c0aff96e4afcf04f6a2c1cdb9f822a5ced89a62acc54c755b1356c6ef55b5b131c98f15628a327b3fd", + "tags": [ + [ + "e", + "745b854705717741bafb74d36e968e6dc120aa8a6abae7d6934abbbc4b0c0c11", + "", + "reply" + ], + [ + "e", + "badf7c7356738f8fa0ca5d83e92e5fda63dabf21e89e70d04af69e07d5368063", + "", + "mention" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f", + "", + "mention" + ] + ] + }, + { + "content": "Yep. Let's hope more relays enable search. Right now just nostr.band and nostr.wine do. Without them nothing works :( ", + "created_at": 1689998365, + "id": "a1c52b61bd6c9096a98ff51ff63e1cf54e868f481eea2a503f6fe3e806946d43", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6dfead703845bf105b7908a6007de6cf0ffb0ccd62d371930e2022dfe636e7c8d12c82c8370073b592c96ee4c08b89dffc1e310778cebe4d867bb5422aba4b24", + "tags": [ + [ + "e", + "dca4527bd76908cd8071fa80d9241b552c3c2348719bfa42c6bd8137a568e9d3", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e" + ], + [ + "r", + "nostr.band" + ], + [ + "r", + "nostr.wine" + ] + ] + }, + { + "content": "", + "created_at": 1689997153, + "id": "cb3c8ab48e449fd9f312bcd3dfc1706dd4dfb997853b8b6f8006c06603654c56", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1905563b8b682aea269c4cfb2a247cfc8ca4b2162f24a68ffa20b94d83d8ed54fea47d8cd94fde821d84b10ae6dd5ac1eeff31f062b4e12631b316571c82f3c1", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_e64adb648e5ff82dbcb1f4df76d3378c58b7a60a24738a3d.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "592e1b0c982a4dae025e35e6d08bb4df41aa330c542fefd1fc942a6cc605ded4" + ], + [ + "size", + "974483" + ] + ] + }, + { + "content": "", + "created_at": 1689997049, + "id": "07846fa8245c0ddb620debcb3fd735cc3a8df6b4a010e97e211eee20d58c6bbc", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "61baf4dc3e84de957caf11deb63076de6db3bc28ea9dc1472b4e6ebfcf69be9d53a5c0533040585a255418706fd113e31e1ec847de56ebbae231fc04e172f42c", + "tags": [ + [ + "e", + "782621cb24a3a8ab85f31c15774bf1a78b4c8446430515a702976bd7a416d5af" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "ad80abee20daa3a220763272a7e3090242188f71833edf970c42121a4e6d5504" + ], + [ + "size", + "36172" + ], + [ + "dim", + "554x554" + ], + [ + "blurhash", + "UUOOIzkC}@WU9]W:ELoM$+bIj]jE=}jZs,bI" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689996772, + "id": "c8df2b4bed40245191d8703bcacfdff60d18b79f9571a0fb4a83f3d7a206d019", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e83d039b5624f2a17914037b46727697ea63c64b54de84b66a14e996784f2d818349ef213368ef2b6c8d7e1202dd977235fb19bba376e0ccc173d89ed1cf80d8", + "tags": [ + [ + "e", + "badf7c7356738f8fa0ca5d83e92e5fda63dabf21e89e70d04af69e07d5368063" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f" + ] + ] + }, + { + "content": "", + "created_at": 1689996403, + "id": "e9f0ea803d47d2a4088a85160ec5e3b460d4d7ce80d8005706b43a670c9ec8ff", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "874d491cca43ac519c2fdc2b28203609b0d66f07a950bba18a72b51577237131ea131c22049753960f20a28cad8dd8bceca7da5f16d398f0469850c06b031c7b", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_c69a040d82b8085ee667b5c439f0ede384f3f9fcf758860c.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "b9af7ba10a32bd5ba80b375fe461724991f0c36216f175a2c68a8a9645d89e1e" + ], + [ + "size", + "1912315" + ] + ] + }, + { + "content": "", + "created_at": 1689996260, + "id": "83218f8e706f092398a99a458a64b847e1cd39d0eaf1813f20fcae34663f72e3", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "188e44752870fcb55594f0f5465fb17a6ff248aed5717b9b66c89ce69cb82f44ee55853c6798e828ffa3163ca11579395ad84374e71217b0ee0a481efcdf3b9a", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d130b03e7f6a25183e930a37f1b35f5763896067d60b79ca.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "f3e8510dba57c20c8f3e00910cfa27bf660994d04d82f255f7822abb0e023cba" + ], + [ + "size", + "854488" + ] + ] + }, + { + "content": "", + "created_at": 1689994614, + "id": "5d9e383311d5e2da43c252e5029a311b43ad63f5a97a79b47f96b3654e419981", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fbebdff8e5077364ecaa8c6e9e71a743990b0ba12db0d346f0abdecded0abf65c3ab8d2be65da0ac6ab67788f93fd35e2ed3dbc950d07b6518212cca06e1ad6c", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_b6b4f0f16fc4cde884220091e8f43cbfa1ca1c9b7038fcf3.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "baa579aa8bd19a0c2bdc6d091012b67db89526eadf454cb5e5ee3863fc25bb51" + ], + [ + "size", + "14207658" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689992751, + "id": "6227574060cd0e32ec7f0e6c4c1bd59a049db49b88eb4a7fb72675c76fd3314e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ab33e1e1b8399b9e0030c578c2a79f2468bc7bc8d0b5279abbf80c7fa23b05b89dae373f68a98fdbd2de081e8b27cdedf07bba8195b2e366f0c2f032446055ea", + "tags": [ + [ + "e", + "5fffbe60a6107d0492b0ad0a18586957472ec472762b931e3fdd2b5f5dd38ec9" + ], + [ + "p", + "7f14df43e1782029254e2b6aed560440b0d1d202ab4855fc70f25584d8d4d732" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689992748, + "id": "bcd6ffd307fb1497422afe873c12d021df5f825039411aaaafe9c43700db972c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c43c4182dc8c06306f094c83e18f08ff54b0fe3964bddbca35ac66a1b73d2d4ff16891c37fc08f5e0528e4a157ea246de17028b6a638383b9e87a7cd38c33474", + "tags": [ + [ + "e", + "411f4bcd1a717a1b8266a47e646f27838f349c773b55298fb264a749dfd945d5" + ], + [ + "p", + "7f14df43e1782029254e2b6aed560440b0d1d202ab4855fc70f25584d8d4d732" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689985609, + "id": "3e8962e2fe9a240531d3971d70f40f87e3a3fd6ded501631ab95aee313ca6eeb", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1e1ce8acb6e9ec11bea55e6ca8a2cbcff2674d28a6336eda6d1c3b2e761670b10114c6b5f6d86cb54f2cb294b9ec6b92c2dfc024f183e72cf23d644437d2149a", + "tags": [ + [ + "e", + "fc2b936584da862e4926d85437accb5412cfe9c983edd517b88cddfdbf618954" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689985596, + "id": "ae40b833935fa4844c9fc7e62622e653a67beff93d02091f0a4dd529e75e03a0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e2a520fe8e0424d0c2310d1e9d1aa827f338348105d85d295b6e3165324da8ab2db219fdaf4c6851302839658ae077b654e9be02da34a8e6c0fd499adc482660", + "tags": [ + [ + "e", + "e91e975b8da19b4c092ac7581734e2168d45fe73e4b8f2049aab3788fa35ae54" + ], + [ + "p", + "4ea28e364020611166ad3f826fd476c05b2c7852fe3d691a2c43381ebe704d0a" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689984810, + "id": "f4bcad31c092a0042bb445f413c593e320b6a6c26941fbe106ca111ab52d7166", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "04b17cedec6de4e85b759eef2049da4492e1961106573f1e61bb4b79accf7b183ca20744e039c40939a3f7c8a3fd4e003783b68cad554f0f1795dafb283841e6", + "tags": [ + [ + "e", + "d269571c1d2ee39df436b3890d75373fededa88f48155df92f0469f80e69fd93" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "I am very happy with that idea. I think we should do this. ", + "created_at": 1689983150, + "id": "8bbfbb3a847fc22fd3446b68a7428fd1071d4e3b17a85502f2c9d7dd21471925", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c0e8227f5f211a1bd932742635c919f2f2d7cf749c15f069d7977c312f5bc952765cd5b5076a454b5f0acaa1f48f31c5c22a1cd5693c1258fa24d9b8b9780efb", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "7523b31c7ebf3c7230943b33076e7afcfafd69c10aa7deca5c455cbd41bcfa67", + "", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "I have missed it myself. I am not sure how to make it more clear 🤔 \n\nI am just happy it's not a bug. ", + "created_at": 1689983110, + "id": "766c8ad5a213902deb33c7177b3a83559e4f140425ed9e5c96a4bb10305fb634", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ca0841bd17b42b5303e0923bc81f2065b8e44e1880480a6b8340f3e6b1bcbc25685eb6f1dff7101a11ea3417be632eafd761d8c3fd3104157f9c8cdadbd9c6c1", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca" + ], + [ + "e", + "7ed23adc9508bf4bc57fc76af8874c09c9af9d6a4afa0e4b04db199a04461d62" + ], + [ + "e", + "40900f8a39b67e8688c2cad1f2ccc73f13cd0bb4af8ef8d88fa86f0bfc958b4f" + ], + [ + "e", + "56f22ef8b1c40cb9721b208f4231c88ee9fdd0372d7affbe426edeb8bd2803f0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "And this is marked as Global? \n\nhttps://cdn.nostr.build/i/cdf3decc75007b18174c3ece49f94c51f930e28a01c126bac1045b4c3b67dec2.jpg", + "created_at": 1689982919, + "id": "fd4e2a1222c0c7b24d48c7dc9359a7d8311246c4f076914b8e72b94e1ceefce3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e2bbf02d867d2b5d971e11131449805d7025abe446846c0400c8d0cdf3b61066d42c30dc23990d00f0f7a8454ae132d4f6b16553f9fbaff0bb708a4f976e3794", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca" + ], + [ + "e", + "08297c8b2ce0bd53a336a3328d9b9a9970e2a81a28ef70a7b5734bf4bc26bdd1", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ], + [ + "r", + "https://cdn.nostr.build/i/cdf3decc75007b18174c3ece49f94c51f930e28a01c126bac1045b4c3b67dec2.jpg" + ] + ] + }, + { + "content": "We got a name! \nnostr:nevent1qqsdqxyjenhfw85v5gx7kxqen8055s7cw2puclm94js4gtddflvagmqppemhxue69uhkummn9ekx7mp0qgs0e0trlf8y0g8mel484etgx9qyma39x83nzlmhrzuazss0lh08d2crqsqqqqqp44zurc", + "created_at": 1689982779, + "id": "1abfbd6067af774e88a049b44885148337fb870595595c31c2510abf9159f86f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8ddd006e5ce070f94588bf136490682e585d506c377e7c86822ba7bdc704d88538a69da09e9ad9e037065db0985c9a147ae60b69148f791d848cfeed67e36e63", + "tags": [ + [ + "e", + "d01892ccee971e8ca20deb181999df4a43d87283cc7f65aca1542dad4fd9d46c", + "", + "mention" + ], + [ + "p", + "fcbd63fa4e47a0fbcfea7ae56831404df62531e3317f7718b9d1420ffdde76ab", + "", + "mention" + ] + ] + }, + { + "content": "Also, check if you haven't chosen the All Follow list in the notification feed. That will filter your notifications only to your follow list. ", + "created_at": 1689982726, + "id": "40900f8a39b67e8688c2cad1f2ccc73f13cd0bb4af8ef8d88fa86f0bfc958b4f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a816b3002595152cb878bbe3c74d6602f38c4ce12679229bddbb85efbad8aea025825c0c070ac79ea2ac29ebc225969aa99b163a9a3d5b783d61239710a55b0c", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca" + ], + [ + "e", + "7ed23adc9508bf4bc57fc76af8874c09c9af9d6a4afa0e4b04db199a04461d62", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "Of course, we also easily allow the author of a reply to remove your user from the post. In those cases, you should not get notified anywhere. ", + "created_at": 1689982674, + "id": "7ed23adc9508bf4bc57fc76af8874c09c9af9d6a4afa0e4b04db199a04461d62", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "10a36e75f8fcd7a27d73eb9ea967487552eb3351acaee2e950e4301d713a7695cd053e8d5a94843469092fbd15104a4056a497c680bc7f2cd5a94c4bcf717e38", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "Amethyst only shows notifications when you are an author of one of the e tags (if you are the root, or the last reply, for instance, you always get it) or if you are directly cited in the text. This was a response to the hell threads that keep happening. In those cases, cited amethyst users receive the first citation but none of the replies to the post you were cited. ", + "created_at": 1689982605, + "id": "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c540d90d6126d14ffe15d082050bf07c50a551c34d515857d6017086697af1713f003e1280d7074f85914d7b8175df722e912f0cf3a3c6da60aaa510c8211667", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "BHz4QOLgtnkLFMkWLeT7vYEog9hMj00OCdIOFn5tC0I=?iv=B7sbT7bbjVS23Vsa3XdClA==", + "created_at": 1689981957, + "id": "7fac13ad33c8cc8805b50d1cd944e50383af754597dcb24be3bcb0e7d73fa34a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f3f0d2d07998b8b164a4f8a463422ae1d0711d08e66d2956871926aafdafe9eb1530027766f1b8edbbb9b7078e154b9cd51cd73d1fab8c4fc6d60888a040b4d", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "zvK4PV/zfIScLBttRMRYRvJBOKjoxxy5++CuSB2un7eWdV5lfuXbdBMBn/e+jSu4?iv=ic1MXHOOCfk7LNg6+qfUFw==", + "created_at": 1689979685, + "id": "cab93844a5464ea9c3a9101f2818bc72e20531c49c061dba55177659f7ec6e3f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2c685d86c9cc220c0cb0436fe26ae92625d5df71575f6bfb9630258120a7309c9c3e440b02ef45129c2cda4299efd4c230b34f00dc52789bfd32708a05b47c2", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Y1YSzKaLNfyzk/rAeHjbnpqCwdNZVhz2Ogk4LaVOew0QLUQux/MkYL1POQb+icsD?iv=GuVyD9MvvB8prX7SuwzEvQ==", + "created_at": 1689979669, + "id": "a8899213890ab028d13961a1f4b7962140ca083ca0d834f537ecfe52a50f1b1d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4cb381c6cb5287bd8df253d540947c84f441c7a73dea56f32584ee1f860179ea04fa7500f3ad58f76630a02a09f127f93bd39052fb0c993238149d4024d939d0", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "9873Ylo0oUHthBI5gHDkuQ==?iv=NAf6J1gwgBYCX9aX19WXUA==", + "created_at": 1689979646, + "id": "b26f9295f51ea0498ef9008c208b27adbed68833c570c2d6ca9451b6ac8eec49", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3a96d2ebf9a2695a031daa48afcd4f5f21ce0f7406bb495d8c7513ac162e37986165d989dd1b6d302a753babb5e4d87358783dbe66fd18067ec3e5c89b5f2027", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Makes sense ", + "created_at": 1689979221, + "id": "840c1578c4e6c49f0a623dd0d50d9aacfafb3728e986f0bb529863987caaeb75", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "15e6d9161334d3abfebaf2ca2b9f292498d35d7199f6cb0b9974682f0339e95be8c972982e4aee0cfe95855db3a3a8cfb244f813c3236697ce9f4212a96715f2", + "tags": [ + [ + "e", + "bf7ae8649a76c977606a13ddd453942a3ba501783b23c121d1bd685a218cd002", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "84eaf37660225a312d27dbf72acf588106664d444bd432b1c8004c18fa109d63" + ] + ] + }, + { + "content": "OUQDJlyIyVPZEaJsh0Zh8wjpdPNqri3ua3KzNdVERNk=?iv=LMJ9yIbIEj0va6BSF4lOGg==", + "created_at": 1689978881, + "id": "cff285496df7108a40a3d78ad158848b4e6a0c85e34bfa45a2e5cda587ff6575", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6a701ec9868cec9a12926443d3f5d4ae09b1d114fefc3529bb59f09ce4ab712eb722ed94be7e0952251e474f4c5cdcd4bfd3cce7c80a2fdda73df7d7c9a21396", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "AJcI/HvqWp4WtZwC/jSwUavXG46/NPMV1gzkxyaMgYYNRoSZIvq6bR0+b7H+ImfbJvzq1HI81eCvVsyt/eM4JA==?iv=7jXjtbkrppHdmcQT3Huerg==", + "created_at": 1689978874, + "id": "b74800c167733cb13dd0241e212cfbd5e3b729d245912b810f51caf844059693", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c66d201aa976d4c0d37c9d435633e5b8d693d0c6a9e258f3618523899c46984772c3d58fc097ac2489ddbb2a3bcd4c6d4a48ea2ff6cc78b513bee81c0032578d", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "e0wITUuIQONaGOLT8tzD1liObF9LHsVScktSpgkgrPlpUo/YjzbyugjGZ3h5D+t073G2ZK0X7B4idNPZNhDrAA8HFcRqP2Vc+ADn3RUHD0Gx6BoQ/jwIy3gOnEV9oRttkawDv4HputQaeWYroIX19A==?iv=ea1Zp+6AM6Hs7Grc3EdzmQ==", + "created_at": 1689978795, + "id": "966dba764185afd72a596a74c91e706f5a28618f16962ddd3ba41ac19904b9d0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dc43d1cd5b000d94a453c9dcd3caf5d400ea2d23b200996be1367852b92f9575fc2894ed63758f24bf1d130dd3c6a044016ea70717aaf1772921544e9332fe35", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "AfKccxXWtZjL0ZV0U0gZzXjxmtomauxe9f0sQTIVN9+2cYL8njKAxEsWSQZbrgIZ?iv=lxtITUPcoSK15HeDT0S3TQ==", + "created_at": 1689978633, + "id": "fa2f35b2f2841f92d64a3a006792a3f361c2ad60507b55081bac5c4f0f3f464d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "49b0d07eb6eaaa38e73c11f24fe4037f11e2dcd224168ea681a04283e1f3149368dd819eebe51957a5b5705d8a48c9b2b6ef575140f3cd842bcbe2851abed92b", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "8YAcpa1MZfvtAwVJ5/w16YQ7sipjBjbBRj1Fe2nnHJkT7O3GdIlk0V5TpREHDL5a?iv=AtGcsKmCEh9MTuofv/Xkzw==", + "created_at": 1689978554, + "id": "0219e6f056c0cebc919dc9e1a6043b5a5302f4a0fdb327c87d3216a2a4b8aa2b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "97c587f226bf35d2aea3c4ab1b36c02008e792630cbf329af28a59dd06a4ecb7339260fd4e186d8e15eed6ac8a47549956c7873d783e5507cad3b57ce9dce041", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "TColF2a6KOL0tPR2Cq0oUUJ53oRsJVJ83q/rSEYCDcbpihSgLh2sPk43OSJ4hujoNTvkyy/TmIqq9x7U8R15gFb0WIw1lVvZ2owykU+2iDtnVJkcicXROLQVtj+x5nu0rP4NYJxsp275G0YzfwsY2ngigSX4a5aT3FXwloP649rYCuG+C1Uv2nqLbYl0lbkt1PkoubEc7MsqboDAZ5a02QFpS1QQ3kyscrPWlF8/MG97dVVnKZKmXgpt5e/uyZKkvCHQCR910qJTFt2el5t1Bb8uII9k6VMtXJWMO/KLHywH/kgbM6GC5l0enohwvnqK?iv=chSXqVgngEDxdbcjTXDNzw==", + "created_at": 1689978540, + "id": "e30b18b795404ca81ebaf2a5bac9b82301de22f598786e267b4dfb93335656ae", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5ac4871503ab5e78e5949e1301ea9a6286d44e18124c6f193447c96783a809eb942b1d571ab64c9afcb8016ae5ca41c66f67d8c1e71a56af6f9f3d198f08d5d6", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "yM5XtJP7JyKfg+F1bx/3Gu2FpOPx3dzjbvrM8O7HSok=?iv=yWgjpXyDICJWVFvWoSEdnQ==", + "created_at": 1689978135, + "id": "fb7b2ff803042667144416fada94d9e919ae1a6442ec644d609365380ca34c3d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f0dfbf44bc54410fe14567236711175f5b842579bc942fa18d5979d2b58880fd6c879b8d4ae2e17fada6555ba08284ab8d75a49413588759c200fa8c389c8225", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "UkCJkOqT153btoDiD+4+VlToAtl2UlfFsLH/8sKr2lce5J7oQWBcLdQ5OiDjYjzJ?iv=UiYcO7+BONYURJf6Ezap7A==", + "created_at": 1689978078, + "id": "3b00f93d6ee7b42832aa34575170a74f50618b1095e65e0d325ef1c498b2171e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0dd1469c4f6d8be190f828f9345477115e66176de9a4401f578b053b6b14f6c7c62896d7c74d88552e0dc2ec3494492b0cad807f1cbd0926cd1116176eea7380", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "G+wV6IdMdJ93uV0aa0lwQe1noHo7ctUiXuqmX/AbctZQrBkSGgpX3XUbr+0m1Zy08pALk0vRlBmve7NmdhLk1AT/Am23vbxxq5i9ox+UOMgw46VyjB5g01XrLhKDUHg8?iv=WxHcPBEYuLrFZOohJLlrlA==", + "created_at": 1689978068, + "id": "8a2da86643dbecb3272e72319a7a85c687648ed68e64b4530a163727a66b2fe1", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "30578d318a41da4e61aadcd8cc9ede9150f2adfecec92727a7d126c58dcab29ef86e923e458ff67b465cc31d42947ed90d2d5b9c21f8c6f56887cde860121b2f", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Qn7zesOytPT+WF1W0uRDFnEbltsxbbc2ie7+NBqSHyGlLJBAesuJ2Nay9ALOntBk?iv=fnhLxP/1sYXgjjqJKiTOBw==", + "created_at": 1689978037, + "id": "add78bec9b1669dfe4776b4029e9910f88af60e7cb4a3832feafd408b5093d04", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5cfcb4532dc1c18f9bdd3d1b695e58313a11fc780a6f6613f10524b55f47d7da6182b1c1043d9b9692549615264658e570894f8c853fabeec53e92892344845e", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "gU/iheSDEaeFKtJLVs+7IA==?iv=LlKtkWuSJfH4ErPRKWh2TQ==", + "created_at": 1689977622, + "id": "b1241ca4526477334d56cab686836dc3fdeaf6ab910c1fd7e474db135c87f48f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b2a67ce55251f5b0ef42ec5cfa8581e99fdd069ba592d1f288d70f5801935f52fa8bb45ee89d8dcbf73b4879d189a16d64b674b7b02dfccf6f1b91f0295b43d6", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Lc5utJWxQ1j5dT74uZmgldkNzwufFEKXIEi43a2V5JoSqaAjjZj4SYgYFo81UbyCsX8+WvoOpyUBS9pmZ26+kw==?iv=fNF9u5WfZg1lWTUQY4w3Ug==", + "created_at": 1689977617, + "id": "8e0e0de3cfbcbbb8c40c6643524a599c9b445f3f00579d88f6595fa19031448e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0a576c229f0959b34c47cd76d18c45f047f338c2280b9e435d04e6e94ea27cdc82223775942060741b2514a0a1f3fdc9a7a11ce646af79642cc038f0cff4cdf1", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "ptJxRhe3oiOuSJ84UqDxqg==?iv=DG1s8TtDwB89j3zBOnt2dA==", + "created_at": 1689977604, + "id": "98c010a3bf59659061d710791df4874d979f1131d8292a8cf423916b07196d49", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "41ad18e5cfef67760dc24859fd52b44b982ebd1ad10ffc50d8e5cc9cbc0d41e391dca0160aaf92d1ee3593720dfeca05f6bd13595e722ffc8e9c1e1d6779ae62", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "eIl+2IGO/rzItb9UkRntaMrt80VQvAMXu2p8o0G476Ed33CINCc7r2MrBS2OZCLMFIysREZmipm0FOwC4zMfwDdQ2VDuSvKx1Wr9yp0QeHaQuMO+fvXbAM8SAtmmmu8sJFIm1nHByF+IxvGHMZNyC0WdhJsLFcikwLREXX5vfn72PXNIt6CjOtoT3GfWYR9lXYZoikvcYoL7yMYEqXHqS9exQl3oJSa8aMQ11K197xUikSG8lnbMBrbPCTBljMdx74+0x5aEGXGtQwQKV25eGY+MImxAiMi2XBneI4JzEqUvXCgMZwYVz1QbtYY0ajOZGWKTV/P/IT/81ItgdMkpgEPC/+QaEegaZknxDCbNU8MeytI5uGEGKZSdKeJSk3vIsDKaz4DStlIFcLKtVi2tam8Op9JLpGYF8TThe1WlfWU0sCdlCut1LN+eDkt0LqaW8k8z9Fo2fD5lXvLNrdU2M8kco77dGAKcyvz1DB7uGTCXQJkrbfdZTW5BObF9EDALJPY1wGFt1IFkFYaXj5k27dO4q+NLMuDYOoREyVPedcyv9zzy/CIllOu0LlQN7JStufo2y4pNSdDKk53izyqQX2aoPH1X7ABN3MPaOZXZ3Frt9UYMrsZiZ1m3kPcGH+wrRocYl4uWwQUbfS2dvTfarOq1vi38m1ZdpLvaEVKWgrZ39gjBup5UHfv0LMiyYWEioBauxLBmyRnfsoU7wgszHhn5Wu0835Mv1wvgRvnlvSqj4NnaneV5d3U+DadNifgBReQyjNDLUGT/UlMdgso3gWbjPF4Q4M8dmCdlomwOa4KR/eFE2Sw1KPqO836/6K5xhkRcxuqEzIG7RKwEIMLGWg==?iv=/CzyaHppYO841qCLbDCo9w==", + "created_at": 1689977531, + "id": "7f86c6b8a04bea04f4b1097622aab3a7c1da0dbe402cac74b8119d3c7282b1c6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f842721f12e20f24fd6a332d32114caec583d4e229ec3240c13def3292e260ca093eb22bb82fc8d8deaad9cddc59458275e89e5f68e86908c63875f45d5acb0a", + "tags": [ + [ + "p", + "c63c5b4e21b9b1ec6b73ad0449a6a8589f6bd8542cabd9e5de6ae474b28fe806" + ] + ] + }, + { + "content": "F5A58GD77ZjLyVrHN+JifSYzbBG+npbhJw6vnVbGtbI=?iv=ll19k4Ar2luPdqXHcsj/Qw==", + "created_at": 1689977465, + "id": "7b54d2c1cbedbace05ff968de66e95b7969c1e83dd5069f65a6de6be2cd9ebd5", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fcf6705371dfeefc15281d9ca5b7df534682a93531d1cfb6e13e1df57408cb079a390de2a861a2f2b60300a9dadee57933f006e091f646ead558868a9cf43b1e", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "VHiN84CzKCUh8uPgqS0n3SvulaWO2Ztg0FAMxoB52GQFsKLwTMFafvp+UVu/9o++fSHaQlsnUuobtwfS5Jv7PFhQpFrApLrFjRdOO7ijkhWwvHBqAQ3umh63aNjhS1MQ?iv=bnjEyVyuyYl0N+IFn9uvyA==", + "created_at": 1689977293, + "id": "59640cdddf28828cb3f75c6aeb4212811137ec990e3bf418c2ba19d764e6b198", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1f40ff28659001a244af4d73e41962b7bfd52d263bac36296616da4d0cc93cfbd391e9589b3711938412981cbeec17809b31d90a78eef4d7f6e7506e9815d6e1", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "These release scripts look familiar :) ", + "created_at": 1689977034, + "id": "aece58f5f5c7b98f5494ce7db0165adc8df7ae0c6b9bab581441bd91b0df3ce3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c169b8e506011a263ff013bf56f3f52718e44a12e50f5a06b6679d20f12d28e8818aead329419667c41eef111e0b1927ee2ba2f689f7ce4883a46a240dd300cb", + "tags": [ + [ + "e", + "31c2e724b2c44fc395e24382ba8d80e8174160767bd02ec16f0a9ca3a6d220ff", + "", + "reply" + ], + [ + "p", + "df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708" + ] + ] + }, + { + "content": "", + "created_at": 1689975807, + "id": "f0afbf83bb2e8c64b989776a7e674d917a6196c24cc313bd0d9a90a8032e3874", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "547c76ef76f139814ab6ae33fa5efe03e5dd899003c182af97e11e0aea9739d68e5a95ed1c316533856f8b63674b9337b1ca6d73f4c9816fdabb563904ba2eeb", + "tags": [ + [ + "p", + "352b917a5d3d447b7078a9e6178b6514210564a927bb4003844df48d98419d11", + "spam" + ] + ] + }, + { + "content": "LITEQ50g9GvYEMlz/4HxI2O616p4Nizdsva6JPXL0TolilM+bAWGxSJ6R/hnInV7G3y+3HvCTxAwAQnV9UpS09q8Q2MEKg+eOptLCZdzd50=?iv=1fVnYnkBqLvHshEaqi00hg==", + "created_at": 1689975101, + "id": "6ceb478d0e6e01c4cbd06ef9957b85e37b57a3a04a3c63dd3e66a92b9a512c59", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f883a65cd1ad833cd24805eb3d1347aeff98e244f2c43d8956782ea1a6aaff71fa75a1e5a952a8da6309b02cc9add291c8e6332e279da44b2b0a494be745554b", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "4BwEVTjQtwPalZOqQLSS4wlZnOc6Ncf/7wnv6YGXxYKhplB9c81H0CtK9y1GYVWa9ld8hxNyM8QggB6xB3zmVLowrDqT1z3KEPohY0nRoRw=?iv=YrIYMh52kA3yj5g/akYRtA==", + "created_at": 1689975078, + "id": "bc79b93654de0d04a4abc0c881a9240ff4337da92f8062e69d69724d6df9ccd2", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "93d9a154ad18e29eabc67913cab8cb6d2ee20b6c55469afaed55a6c3942f2b100cdc7096b427f82313dbba5bebff2f2d724af4b587245ed36f611e9ec59a7e30", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Make a script to update every hour. :) ", + "created_at": 1689973771, + "id": "7faf1a1ab9f093bf3f5696c8e7c4ea3473fe4142079198eca7efbb722aecd480", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dc86d190b84b44db717f48eed16b29e5707bb336f59ebe1dd38b04525e6bd5d11977e6c2733d212ec1ee0c0fc6e3f76f2c6dfee398098408f26320daf0dcbef8", + "tags": [ + [ + "e", + "84b084e1d31a27a92f76cefd5e745a089e09f64e92e679c238b268af5406723d", + "", + "root" + ], + [ + "e", + "72efbd2fce58b9cc92ec861bc875b02a7252503192e7e3795e78ddc4a80af8fc", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ] + ] + }, + { + "content": "A DVM to find which DVMs to use. ", + "created_at": 1689973691, + "id": "f4e1b1f74ebb814338a1c452dbdbf3aa97a849ffcc3044833bb338eb4528374b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "17a447a47002507cc4f58b1985e9112551d51453d48d076be16824bb0361b3378657faf79e4868b64b1cb82f9ed58de16b0030afce0a09b1194aad1451d9a948", + "tags": [ + [ + "e", + "49f06defee1977eb5a9a2ab86fae6311c616b5455c3f63227753442f5175a0b3", + "", + "reply" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "Not on chat yet. But it should work on other screens, assuming that the profile is loaded in memory. ", + "created_at": 1689972970, + "id": "f8a2486f4b5e24e31a653d99358062c67e13d20d68efc940504048385b7b4639", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a9e75594a467ede55727aa5160e4241b8bcfcd741f42524b16c06819f749e3737d307d24ff278bafcfb8f3b6a98318f8c1cc13a4337a0e22c9cb1e46ec06c6e6", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "7d2ae83cfd768993ecdc105e6ecfb864f53a3a84945ac2cc7bef20a93eea9a48" + ], + [ + "p", + "aaaaaada01484648a650df7a59f4e6b0d48e226edd959aa237095f3b6a6faa79" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689972368, + "id": "6fc7fe8d7eb7b9d6e17b7ae1c8a42d5bd5c15de0f3d543b0899961d827bf2e12", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e8a9f9ccc5592892080e09ce9414da1ff75f9a4d7d4885fb9e011a0029d67aed8c305fb841b1b3fdcb0f839c851a59692aaef37a36a679c7be9328c67e904d8d", + "tags": [ + [ + "e", + "297b09d4c973412fefb9a47ad724e4f7ac62c7350eb32c45abdea99af98c2588" + ], + [ + "p", + "4ff36a4d67fa327ef1a686a575d0106b4781600900f68ccb5a40f7e455530baa" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689972158, + "id": "9c250df48d9899d18e634573297aade8e1e81175b272bc60a155e50274c130e4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c78c7b2d9cf42561aba618a11f94814aa6a6f86dd0791b89bf3b1d5db8d86782ffcd3bff3f5fd3f4e9e148d656f2b615128c7dd4217a002a8d91db96bf310e54", + "tags": [ + [ + "e", + "6a7a47dfef9bef875b58763dc6185903ffa4bbeb6f3d01db9999c2cc4bfb1800" + ], + [ + "p", + "21d98206ea80a4aed5a7efa97965b5ef08f8f1cf22ef295c1d14bcfcf4efc3aa" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689972155, + "id": "75f95f238fc8912757dc4d8720a1b76ad471bde1235e11f304c8b4bd8548826a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ce8d68a1d67d24366870915dabc044be2f39e714a9c40dff0b988007cec7db7592c66f4e79b1c01a11ec9a7c4bc1f9dc95bf3bd936522165f9af5b360a0b92c1", + "tags": [ + [ + "e", + "38b911cf18013676cdab7699818415c52c8f3f76c673bcb7bd93eb8946caeb3b" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689971326, + "id": "b90073d643d6632831d65d76098be4a5bb2ef1f1edb040dce63965a09166d3a0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "11352b96b205a1588c64b2c7941199370a80d13fcda0f507fc608fc86a53ffcb20641d365a1213729dc87676d359a5bf2594fb136ddd5c2e36c3b034c7225051", + "tags": [ + [ + "e", + "306f1ce3434139c9761742f13b3f30a91cc0287dfd187d19a0a314a93a1f5696" + ], + [ + "p", + "4149bd2ca7c08ab6321dc1f54176c78acd295daa2030dca04e8010ad992e714d" + ] + ] + }, + { + "content": "Yep, no full profile hiding yet. Though it could be possible to mark the entire profile as content sensitive. ", + "created_at": 1689971219, + "id": "5c2964fdd3a2c531ddb6dae4008f4c8daa26a12b1fdd1c5f23c93e8c2502977d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fb6230e03f1c7e2ad6ebba17e299593566f3f3e25e00e9f770e0a55d70e47a119b0692412dc3686891815c78cfc094ffbb323adc59684de8ecd398b949206145", + "tags": [ + [ + "e", + "f1b86fbbcdf3def82c539ea1cb47116eaeb81806cddf2b55986f79f73b9a1b97", + "", + "root" + ], + [ + "e", + "a2b6c4f5ce2f59e2b665623624935ab9a12ad899809de109eb62497a20674c39" + ], + [ + "e", + "825bbee3f59a086aea0f4ddbcea235e156ff10db4169af01f2b7b240eae42cb6" + ], + [ + "e", + "957ad7b333d2b068d42b3eccdcf1cf4aa2a7e3fd9b4fc3932fd4f43dc9c9d5b6" + ], + [ + "e", + "99ca15337411022cd6dd6ebd12970c678e039ce64b16dc801698a220d010707d", + "", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "1b1e7b22eb297be33b16dbc01d89cf2ee88704390710069feee7ab9ed91deb1d" + ], + [ + "p", + "dd840e433b978795bb35a408bc61ef7e99688ba0b166f8cb4c7cebcb5318ecb0" + ], + [ + "p", + "c6402125e90e82792f580003bbf81a130bab2540d553331c5035ff0864e5a54b" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "D9ZDPT+ym8SiZVDXLY5r+ccfMfJOLZr/Fb34eYAkSNhE4bfz6VeucqqRflgqiZtnpJXzxzVu+ccVMlmOQKKBA0BQD7juHaaaHAXMUXbhLjw=?iv=Fax6ymzr/o0Vj+8pJqHyiA==", + "created_at": 1689970514, + "id": "10bb476b7e35472b292f69e14aab8825ba2921eed14bcfb175e71f5bd84682ca", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fc82e33f3737423385466c4f825cbb8de6232a5d81eb6abf0d0e09405ee0ca5217e307994becdd6f32b23dac4ff625218ffbe930810f372247cdb0f786985df0", + "tags": [ + [ + "p", + "8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa" + ], + [ + "e", + "2721a5c224fdd7b786fb97d5c173444a735b42903ce8daa9d7ac636658360ec6" + ], + [ + "p", + "8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa" + ] + ] + }, + { + "content": "Top-right button. ", + "created_at": 1689969132, + "id": "87f3e8669d345ff58a7314b59966dac2091c6bcd9f78f8670d0f559acec70216", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b112cd109be777d321ff77d9b2a51bf81da709547c21456e29e2e6340399021f313cddfd9e9f170f85a82b6cdd54aa66cdf59e38b49478f3ff71f3ae5097f933", + "tags": [ + [ + "e", + "2ae6e0e1978e9514561bb989f5da7e8adbcf8f1630d7034a3a9ec7118888fe26", + "", + "root" + ], + [ + "e", + "9f3e00e694697dbdd682a83ba9328956fbdee15ee98c8247f609c619d5b1ba48", + "", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7bf7db83f73228f5df6ba34849f2af9fd54bf565b5ad698ac708249b310079a0" + ] + ] + }, + { + "content": "l0giUaY9CWhvb9TJDppm/TxERuBTIKu7EDV9iJn9pfAtoQAzhq6DOOZxspsFc54b?iv=0xlAHbPb/lU6u6M+mMrVgw==", + "created_at": 1689968865, + "id": "e79169091e1262551ce7d9720910ab4276e9ba3a90375f0eb6b82133d5033d37", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8b7559ccd3d527efad3e1f0de07fc73957d39d1261f665a7fe21c4aaccb9fce895eb7ce1371aadc06ef9ce2dd7eba6a2cc140ec05de6f1b7cbc53b6cf5612d1a", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "B0LQfQWsazGI7Z1U0YbsslTRgrikDPkURzlU/ZaA1dNb8xG/62ZJI6glJTmSTh7Nx0ZlnqviisPqU17cxWwjJe3vPM1xop/FDsha3ZdmFYY=?iv=jIcTUYXKc9pnAWlEvwNX8w==", + "created_at": 1689968856, + "id": "60b7f72e911d92f54f3db9d207a71fede0cf78692e110859c9e8915b7f745175", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0e227cf32752a175bb5be027064d6eed57b7f6913f092871533693124da6f6eac0ccb0491ec5fb88fc23995d0bed303c5f5e01a7a61adb6c76791767c84f62eb", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "GTncRNSHERzHjokQ4zWRCk8q+0Tj98EIWVfDlETRdnb34tUvZoK1NQ97BXzyj/aRJz3t6Qc91z46vLursOK2Sg==?iv=5v/i58l4GDhGx3BpXQciFA==", + "created_at": 1689968831, + "id": "c96f42508779710b8d09b8f3308e5e3ed8069983e574ae64fdcb23eea01877a6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9a32548210a39226d8eda5ae21bd4f0d7159e4bfc4b7bd11014deebe735e8ab7cb80a525dc73b67127c4bb0646f738b8f4ac0ceee687daad02c6a55461fc7678", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Hummmm.. looks like I need to fix tagging live activities. :( ", + "created_at": 1689968448, + "id": "860c9594fcdde6307bef4f6acb99f7f155e1061458b8c9894e3989b023c59d2f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6fbeae674d410eb00ee2a17a0735ad0c3e746bf6695d8239c1cd0094e45750752d8b68a4998724894448e887865cdca56c60d2a1e0cc07633b9e4d6f7bbde939", + "tags": [ + [ + "e", + "2ae6e0e1978e9514561bb989f5da7e8adbcf8f1630d7034a3a9ec7118888fe26", + "", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Turn on Amethyst, go to nostr:npub12hcytyr8fumy3axde8wgeced523gyp6v6zczqktwuqeaztfc2xzsz3rdp4's radio, pin it to the background and keep browsing. 🤩\n\nnostr:naddr1qq9rzd3c8qenzv34xgesygz47pzeqe60xey0fnwfmjxwxtdz52pqwnxskqs9jmhqx0gj6wz3s5psgqqqwenslj8h0y", + "created_at": 1689968401, + "id": "2ae6e0e1978e9514561bb989f5da7e8adbcf8f1630d7034a3a9ec7118888fe26", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c43f4af5b693201d6073e3034dccf272954812547470c6f9a0f73b5dc6afa8b129de124676da58dc52efffb3f73449484508306e9d55154f70af8dfcb3a76d68", + "tags": [ + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185", + "", + "mention" + ], + [ + "a", + "30311:55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185:1688312523", + "", + "mention" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689968322, + "id": "34f6196dd0ad0cdd727f936783cfd705271fe8504c5a114d18a03a16f0b2d825", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1b02863fcd487cbea1b8e6a45ffdcbfefd130e3560af1192398ad02349df452281b6b2ac5b2135791d8f80f64cf21957eba5951fa85a5eddbbdd9781a8de4629", + "tags": [ + [ + "e", + "c9d976864bcbe0a3be5b025c89e671874d16354dc34632f340bb4387124a80dc" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ], + [ + "a", + "30311:55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185:1688312523" + ] + ] + }, + { + "content": "", + "created_at": 1689968220, + "id": "e6e3cc971eaac101d1086cc6f8bdc140a631aba8cafbd12fd795b7c07b6ae333", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1c2bd83a4745bc62684d4a78c6a0b000466ab95b49daba18115a9e92bb57525294f62f3df31e4397d87224f0644e7ede7140636eae36b0094a9cada49949507c", + "tags": [ + [ + "e", + "0a7c4bea9affd6a5614a17889514c7a2aeba60e63dc001743739f7598e3fed70", + "spam" + ], + [ + "p", + "9b0d19ebfbddc17922a0cd8df1d97e73d8ba106fc80a4d43b3f815f3f1a08983", + "spam" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1689968220, + "id": "7878ced274809d1c8d0935de9f4ca67e12f61a6d3e8688e0f939e9616efd78b0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "86d8360ec24069f6736c46edc01dd713555e0a7f56da5c48b9b015878f422f5a36faaf68dee1f77360f56890d7d4b9081df08da370a02489d30777fc9994afb1", + "tags": [ + [ + "e", + "0a7c4bea9affd6a5614a17889514c7a2aeba60e63dc001743739f7598e3fed70" + ], + [ + "p", + "9b0d19ebfbddc17922a0cd8df1d97e73d8ba106fc80a4d43b3f815f3f1a08983" + ] + ] + }, + { + "content": "Keep updating that event. Amethyst marks it as ended if you don't update. ", + "created_at": 1689968204, + "id": "84b084e1d31a27a92f76cefd5e745a089e09f64e92e679c238b268af5406723d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d7f66d9f224580504fd39bd6a8caca7941ec40efd015aa114c8b0b6500b32c74ed96622e57a5b907f39df73b753b2f20e765ca0ed533d36ad5dfa15473825a7c", + "tags": [ + [ + "e", + "6557bea21d980ce1fdf671fcee3d3295a6beb78f63d29729f5d284815cb57751", + "", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ] + ] + }, + { + "content": "hDAdqWH/tj/dzqyXNL+7fzEZ83mJKsbHRXMdln4y+B2nnCWKkNTdrFxU+4L7DdQC?iv=Puix1vCTdriA+pLxfC/qnA==", + "created_at": 1689967568, + "id": "28cdc408713dcc255edcc8db05bc0e40eb486321d25c43b8d203de5e70941f4b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c797ef7c1b05d81c24c1aa60cf491b2df71386634175bb2ebffa9f8c78c7f956d9f56fe586366cca91c3f25a3f9b951688aa8965c690c4b70150baf096c24f61", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "HqXbmGO8PjLriW8nFNTMsQ==?iv=llg6zk1KYvXem7OaE9bl8A==", + "created_at": 1689967556, + "id": "f88c0965f79b79d2fb58f93e844f46d91776c2d366374f226ddb935be6d95ded", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7e1b3c32dec8c5dd3ceb310ae2bc881aa8ce838a1fc9acc33ca9ba9907665e1cc972d0d229294c56c34f4c490b621f1f9a0df0f081181ed0c9e06dee7bf2c476", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Sx4QIMpVV0QEq6oeXH5MylS1Y733BReuPr0otd423lI=?iv=LBuyRw8BsOpaBbU5xG+BAg==", + "created_at": 1689967552, + "id": "6af5ca2203c5b93249bd7fce7d6ff3fb426df38159927c85e0c263905ccd91b4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "68d0d30378769c1ae389df6ed0bf20af7d35311f8fc26ea3067929d24d7a5ae896e78f3d38ebb264344e8f066782b230f51337e0941bf2f4ef83e74829006557", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "xGwYwz9X3f0s3xYD8tjeG0eWoXaDzLwXGth7ID6o4YzWNZv9uj245SNdQvYQuqzg?iv=UnnU6U0xomni3FQFRqQmKQ==", + "created_at": 1689967546, + "id": "90060a1cd906ce9ea5c790839ab1232aaff691e8931c44f57ccd725953f5a40f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b6d27a3c34fc53bfc816693bdcbe77010b4c1f9d7e4817af043922746d7f200133243cf5e2f85837dae935c393e5c4c4b6cfb4ec22c245620facb2f2afceb246", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Video is so hard :( I will keep an eye on it. ", + "created_at": 1689964286, + "id": "49b412af98a3b519f18266a67bf10577a68a7cdb3df26d234e491a54980d87c7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2fb538efc3a33e80100a3dc78ddf779e15945f5b676afa5150f0b66382a409554087f2f9e8a1a983b41435db48911aba6b7a30664e12f7313ed4cd98a4356ac6", + "tags": [ + [ + "e", + "438c70e797d8bb3ddda9a1c15726a25f2034c1c9a96337d801a377448ca754c8", + "", + "root" + ], + [ + "e", + "885849c65696f340433c58a054df5ef27937ea5efe87405fcdcc60741748f7b5" + ], + [ + "e", + "dc6a448e730342262f978f1638137b3621e3166d1fa17efbe0ae972a24de0282" + ], + [ + "e", + "74d50ebd3d2104014265ddf72a81671e6d15cc6fdb68f4706eb5a6b3612d3c10", + "", + "reply" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc" + ] + ] + }, + { + "content": "", + "created_at": 1689964202, + "id": "b2a975890c554d6071d9dd1200cfd2020bc8be752d5e845e8d6ab6e5eb92f7ce", + "kind": 5, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6682fb0170db6a48da22983af8ee44aea60c80b42f303599c0119d96e395c74ecb4ce6a10efb8c6dbc810b427e72d29374cccccac5a34dfdd418caa656faec2d", + "tags": [ + [ + "e", + "8b22b43b5c93e5b84830446c7ec610fa88155dc62ecca4d32514e63d49081335" + ] + ] + }, + { + "content": "Last message: https://github.com/nostr-protocol/nips/pull/468", + "created_at": 1689964112, + "id": "5ee95e9c26d48954d6e842bbde5aa2f54d3cd5cdd1e42eef1625f3fbdcda7795", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "861af1f7cfce98b5d84c728e4a0d666d91fa42b5a1d58eea1cffabd39215b2441f39bf1a8bac41dcf9778ca4b01fd1d45b535a51c423f7f591b551b7ac901858", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "ea9ff60451079f154495ce532c537e0509e2f9add03b24b0d5a16166732e4ba8", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "It's a very effective way to avoid reports. ", + "created_at": 1689964080, + "id": "d2186814284909245032b96a0ca44af635d4a4c2cf77e57c2119704df80ac9cb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2f1032e162dfc502edce54e862eed06e944677134c7e831e2caced19f2a6614d78440deb6e7cbad3c72c6f6bc834308ebb19e4df248a8b9d93bfd9ebc8b4aa6", + "tags": [ + [ + "e", + "409233edfd6dfbe4c052e53552cea2b5d99427107e638c08a5f76412638e2997", + "", + "root" + ], + [ + "e", + "c0c727063a3ba2e9c21604df63444fb97b7c39dc788734762f2bccea31fe45a3", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ], + [ + "p", + "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6" + ] + ] + }, + { + "content": "get the video urls and send me over if you can. ", + "created_at": 1689964038, + "id": "dc6a448e730342262f978f1638137b3621e3166d1fa17efbe0ae972a24de0282", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "62f6f79c609297e102ac206ab6e5d46f6132c0c6db1cedc27afa96e1e1883159cf9c36793aa6bfaf943e05edc67b2e908df87a38b7769c2768eea615848cd5ba", + "tags": [ + [ + "e", + "438c70e797d8bb3ddda9a1c15726a25f2034c1c9a96337d801a377448ca754c8", + "", + "root" + ], + [ + "e", + "885849c65696f340433c58a054df5ef27937ea5efe87405fcdcc60741748f7b5", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ] + ] + }, + { + "content": "Bt0pzANqo6JDNMOVSZtRGduXq7HpQB4pXZxi+jE6ziUhQor/HhA/iDUO5BbtMD5AfpOpqisuLzf7HywST79t5TVbtM7UGBm9/iTfcZeg5Fkyt+3V9G1ef/lTMw8b3BvB?iv=6T5YVB0wcifzlVwyasCJjw==", + "created_at": 1689963534, + "id": "fd438f5148e6e847ff748d564f806cb15a4603be5e0c27f8fb192bd3c3f6ed67", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f4f1e999f951e672340d01a8c1891fb475fc54b4275597f2ffa4d92f5f0085502d5345741542239a3bb508411bc9670dc22125c46e78957e99d4df415322abd5", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Thank you for using content sensitivity ", + "created_at": 1689963327, + "id": "796d257f6e06d8145d0ed795f3695bc4c175d7a24890922be3852e4cd246e096", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7b2b3eec7f5c1cad7d655d59d521464c6ed429eda787ef31cb3d44b90e30d770419dc4034e0b887c7091a933f51fde3c7e92880a2bf2e17621f62617a1b5476a", + "tags": [ + [ + "e", + "409233edfd6dfbe4c052e53552cea2b5d99427107e638c08a5f76412638e2997", + "", + "reply" + ], + [ + "p", + "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6" + ] + ] + }, + { + "content": "8Ab731Z61Ct6sIERglBUgqRxjGrJHnyrq820HqMEywNhu1gIIPW+7dvM/XC0nv6BjXBLGuLHDswYbGhVMXxopQ==?iv=EKyBALmNq+k2B46m3v+mPw==", + "created_at": 1689963305, + "id": "0d7ea4e5dbcca7d6c7ce3e7013b2261fbdbd8b767821236c4c059b51da3bbe65", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "625170fff7fde4f0949ec574706cbf39b1eb8813bf676bf6ea22a5973cda0333467e0f748ddcddbdcfc357d6cddb8e4f49b4f8f0c7cdecbfcd80e40ec585f048", + "tags": [ + [ + "p", + "1c6b3be353041dd9e09bb568a4a92344e240b39ef5eb390f5e9e821273f0ae6f" + ] + ] + }, + { + "content": "0ONkFjclPM/XKdRfSCPfpobIMumwXkIEW2y6u13PG3M=?iv=sLeGSt50ih42yHN6V9xIvQ==", + "created_at": 1689963292, + "id": "df3e74b5af761d6f1ef71c0258e27d9894dffc346c9d4df3c3da29e0889975b2", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8c1f8338736612bb6f5de0d4f6deecba028a66e41c74d63f83cc85bb1d4e2b8106a82b7b58e1353a54fd8529363fac11514f67d5426a2aa06048c7c19d2d8a54", + "tags": [ + [ + "p", + "1c6b3be353041dd9e09bb568a4a92344e240b39ef5eb390f5e9e821273f0ae6f" + ] + ] + }, + { + "content": "Super strange.. all of them? ", + "created_at": 1689963074, + "id": "0ec5ff7ca4b1585cfe21db0e071f6d49c839ddbbeae9d322f1c30b016145276a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f2e3efdc3d54eff17c5ef2d2cc86985582fd8cddad7e2ec214b2f2bd52e0e40b3414fc43ad6578ceed602ca29084a0c7332a6c19ab5710b4901a79964b72078", + "tags": [ + [ + "e", + "438c70e797d8bb3ddda9a1c15726a25f2034c1c9a96337d801a377448ca754c8", + "", + "reply" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ] + ] + }, + { + "content": "Yep, just click on Auto and set it for German (make sure to have a German keyboard installed in the OS) ", + "created_at": 1689959447, + "id": "aab95307fedb358bc79fa51699cb627e31774e5305bc178857d1a38d624932a6", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2461b06ff9a8c141646312c5a59a08a44b842f43386f73d32c925c18a62ae0b993560c6480719f3e1252d5a0d521bca7d077efe614f864e82421a8d19403f249", + "tags": [ + [ + "e", + "0b6153cc21d36e51edbd2c30c7c45dd2dde66c6bbd0b0b5d508a613f277b1ea6", + "", + "root" + ], + [ + "e", + "81d53f4fa555d6aa29175ff2235e34cba40a949eb3be8104176255451ebfadcc" + ], + [ + "e", + "01186521fa18cecbe9bebcf6d3b6194cef6dd36ae43a3b1306601e2cdf0debec" + ], + [ + "e", + "f8ec20358eb2d8c6f3335aa0a1da2ae40b0e3163fada3a543fd2c7ff2a1085b8" + ], + [ + "e", + "c7bf286ba483b4bceaf758b372d3d35d58398962e5e31a6b76ce38a614e5e5f1", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "We should have both implemented. Market decides. ", + "created_at": 1689959301, + "id": "47652f48803fa5e109ea9ffe514feb183a613ada5ad23df64f6be09971ed06bd", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c402d5cd2a50e0d0f26bcb1c15ee09b419d2985898aa83fe89a353270a0900ed6750cb6354222afa316ed29e16c61492f75215580323c1aa5cf47e82e89846fb", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "ea9ff60451079f154495ce532c537e0509e2f9add03b24b0d5a16166732e4ba8", + "wss://relay.damus.io/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "", + "created_at": 1689959268, + "id": "92dc035f4ee6747cf9b12f5d3265c6f015f4e0e2ed89ef78fa46dfe8f4536b24", + "kind": 5, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "300b8bb33684e433b4d7908c51f80286d4d51b2d3b82967aff78355fda768e0cbdce22fa58b5630a68ea622248f0d9363ce1dba86f7f743b25e778ebd6901b1f", + "tags": [ + [ + "e", + "8b22b43b5c93e5b84830446c7ec610fa88155dc62ecca4d32514e63d49081335" + ] + ] + }, + { + "content": "Very interesting... Maybe the app is never deleting the cache automatically? How much space do you have in total? ", + "created_at": 1689959088, + "id": "bf1866daf7c88ee0272062b10c26fd4d77c37e29fead13820b55f4307c25df28", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26bd4ab68f0889678c6c8d17e1a2a612e2435cf3055bdbbbe81cb4795bf9897e930c6b82c5474e05946f4ff5cd31da6bad5256397a8bb0c9db0de411df3d930b", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "c87acc1325604410e041e4a2a363e1d9ddd0c6eb97c05d5cf79a41101729b281", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "0d1dd56ae3204328e45f78b1a64ac8f06d227129f775493ebe84cf28250d1ec6" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "You can do it in a new event kind that is similar to private messages. In that way, the only way to leak the message is to leak one's main private key. \n\nIt's possible to leak it but it has a cost. ", + "created_at": 1689958824, + "id": "19e4caf71bf318b48942caaeb8c10eda847ded4240fc51831ec8e73e52e09b39", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "49bad4e3a8e9511baac9c58d7de984dd754dd205f321c2dc4e92b757d043e1ad4f04fd9e605bcca5244134a87e1471377bbedb085f66c3630bce59f6698a4e60", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "3bddcbc6ca72750f7ed63e1468a632dae75ecce119a18a52c624c7c047fe2b42", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "Then just wait. ", + "created_at": 1689958465, + "id": "c7ea022e8d7826ecb7e92a7842e3cb6d29c18c9542b872aa56f08fecd3ec09c2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ee20a289e0d45068f04fb0c7fb6b98af8f020131b03246be70796f93227cf181fa4f637e55c4ca3271741de7efa67bc6f712ad5a6c53e1d2677b9f7c9184102c", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "d1aa5d57ae4fb0fa142acbd0554f92ecbd7b43eb95811e9874710d17ae1c3988", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "Once you sign and give that to somebody, you can never block its spread, independent of Nostr. We can make it harder, but somebody else can make it easier. ", + "created_at": 1689957408, + "id": "4145fe4ef02e0f19b43cbf5a360093b828ccf7eff205f6a92498354f10f3e584", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "583f5dbd61495a31b663e9eaa337d3d4e9f63a795bed5d578e85374c289ac75de32de47f7d08c41cb0352d1bfa3ff491484c14e01ebfe3eabed7833d0ea68a10", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1689956888, + "id": "fa03a5d6e5f2ea0b52bdb88c1656d90a18709cb1b9ab9ab184a22aaeeaddee85", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1d2ac2e49963532028beb37bec2ab69fa1ad09de2015fcb9d3fe54c0015c768b09096b0133f78e3d51c1e017bc3565ee10f615f227704cecec86645b969ac823", + "tags": [ + [ + "e", + "dd89366d32893dd2ee4f8b22a68c9d19673b031dbe7a780e1ac14a39354f1e58" + ], + [ + "p", + "c1a6940f84cb4d32564837d9ae055b036545c8e5f61cdd24f29786876436df8a" + ] + ] + }, + { + "content": "", + "created_at": 1689956888, + "id": "db4a85c524e8675494bab0cf0c1ef3a83809d1a954b159cd3a27b3fc0bac91ba", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ca9b7e59d54d109f789fa4ae6e67ecad738f5bd266f8d71ae5561d03aedbbe63dd70264a203662138b2e1addcdf1c4039b6a708d66a27555a213b22c247f8405", + "tags": [ + [ + "e", + "dd89366d32893dd2ee4f8b22a68c9d19673b031dbe7a780e1ac14a39354f1e58", + "spam" + ], + [ + "p", + "c1a6940f84cb4d32564837d9ae055b036545c8e5f61cdd24f29786876436df8a", + "spam" + ] + ] + }, + { + "content": "", + "created_at": 1689956881, + "id": "ec4dfdfecf7979e5a5e8240d8a522893d414012e961e8dbe653c278bb82701dd", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "84d0b6acda593495ea135c074c539c062826727568039ca0e4e575c4f30b63416a8501faba8088b5b4d19ba9275d3ad8dde05397c6d5d5d55cb868719af8153b", + "tags": [ + [ + "e", + "91d440406cbb8a6514e225723c42d4efc3d359da1ee57098a1d86cad3ab879cc", + "spam" + ], + [ + "p", + "ccec23ef54ebcfd3bcbf71c4e28d8c32087addda00710197e72275a04828648e", + "spam" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1689956881, + "id": "d2ba718ea610c9f2715fa8e0a65f5568439b31a604ec33383e51654767e03116", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4c0dfa3a5d1da1f14e5d1fd4e092d44e78d1dffb033b5d64af9ccb691bf0b9e23153867f71a42d768ba107988e752b800ff8b9f4972df5a3f420aa4a2200715f", + "tags": [ + [ + "e", + "91d440406cbb8a6514e225723c42d4efc3d359da1ee57098a1d86cad3ab879cc" + ], + [ + "p", + "ccec23ef54ebcfd3bcbf71c4e28d8c32087addda00710197e72275a04828648e" + ] + ] + }, + { + "content": "Actually, it's the second. It's just the first I advertised 😁", + "created_at": 1689956873, + "id": "72bfb72cd240f6fa6ad0f6aa502fb9f0f9d8d862bf61df28bac61c35ea2c531f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "21e168c8bad482131f6bf06b69540d11152911de296e657450c92214fe9073742d93d0063164c0516ee959c2f41610e61d0bc8c87a304695d05acd69c0cddfb5", + "tags": [ + [ + "e", + "fca1af2a576ce6f8455faa518475687e857c8dac514fad4148f56b68b25de64b", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "Thanks to nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5, Now you can choose which relays do you want to post to.\n\nhttps://cdn.nostr.build/i/d43712f8f8ec312dde3b376afe1339cab94b1750c1f89185cb69f5ba0c9a6687.jpg", + "created_at": 1689956778, + "id": "277fbd58c7f1ebcb09f7211361a540a38f07c717663b5494af1b9847236bb560", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2f60d9776f1453f24a70a471f73395660240931e541003154c48bb9e6f6358252ec678e3e5f6213ca209bf768ecd935fc1d266b1eb51b9852c62c0133121b335", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19", + "", + "mention" + ], + [ + "zap", + "greenart7c3@getalby.com" + ], + [ + "r", + "https://cdn.nostr.build/i/d43712f8f8ec312dde3b376afe1339cab94b1750c1f89185cb69f5ba0c9a6687.jpg" + ] + ] + }, + { + "content": "Did I forget that part ??? ", + "created_at": 1689956705, + "id": "60b045e3acdc14f003c241e78d6c802420375bd324e85660c43be3e84dbc9de2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4f6c79243266abba1e55952a95ccee0c33ae8c9d7be5503a9765407c545ebd2e80c4b65a6310f6538dc6d7b4b1ec0672afe6a2bef17b3f11882494e9771a1071", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "bd3a2dcf591c43e69bf1d54d8a5fd7a379ad19458f79b08df6b756059558569e", + "", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "### #Amethyst v0.70.3: Fixes and Improvements\n\n- Stops Video playback when switching in and out of Tor\n- Adds a space when rendering inline images and url previews for nostr:npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx\n- Updated se/de/cs translations by nostr:npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef\n- Moves the video caching service initialization foreground services.\n- Fix boosted notes from blocked users appearing as blank by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Fix KeepPlayingButton color in light theme by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Refactors ChatroomHeader compose\n- Moves the loading of an Accounts backup contacts to the IO Thread\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.3/amethyst-googleplay-universal-v0.70.3.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.3/amethyst-fdroid-universal-v0.70.3.apk)", + "created_at": 1689956180, + "id": "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f36eca3f55f6a575ea5cb44e73ed5f98f8fb0c44086d4dba1e8f7e57d837e8749c5bf14825898572bd8cf612abeec12d825e4ad5fbdbf911540c77f241428da4", + "tags": [ + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "UFNglwaQDiNRbqo4P7NHfA==?iv=gRBjwGJVDFtiGR/XEWGfgg==", + "created_at": 1689956136, + "id": "a42b28e1008356cccd329e3fc9b8f33d42bdfc02891d32bfa2218853f325344b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "159df581567f73f2eec769c8b785399eb20a42211a7c569592f7359406c915432d353d6af8011fc723393e3057bcd23f5b8b1ccf52a682544ed0e1f367e249e2", + "tags": [ + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ] + ] + }, + { + "content": "It's 25-50MB for each translation model (each language pair that you see in the feed) + raw images and videos (which are big because we don't have a central server to scale them down, etc). \n\nMay profile pictures for instance are 10-20MB in size. Some GIFs are 50MB each. \n\nSo... It fills up quickly. ", + "created_at": 1689954869, + "id": "f13fa631fbfd476b06f7027d2944dd5b0cdb67d0755148c95d6030af213178a6", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c78bbcf217fd0b528876dde5443e429edf45536d079d14e844e41e126225893735c4ac025525f5e437ce6546e7eb2a5d21f09dc83bb2677b7077212c34d5c6bf", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "5660cd2929f688d17a0fd713301c4d3be0f9c8c76f9b4fde611f23385576f327", + "wss://nostr.mom/", + "reply" + ], + [ + "p", + "8ad25d02882c05d6ac20fa7c230dfd1c3d7f6de3976d9c5bd5ecc7bbb63495ab" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ], + [ + "p", + "0d1dd56ae3204328e45f78b1a64ac8f06d227129f775493ebe84cf28250d1ec6" + ], + [ + "p", + "8ad25d02882c05d6ac20fa7c230dfd1c3d7f6de3976d9c5bd5ecc7bbb63495ab" + ] + ] + }, + { + "content": "You know.. you can create lists these days.. http://listr.lol ", + "created_at": 1689954628, + "id": "c233de5e1d9e9d342c24484d694ddaf1a8f5b874f10de2b26ca1a76102f3db18", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f9c10d14b065f1d0ad82418e38231a03bb3dcb728c54431e3944b67068cd5405ccbce3fa690bae8f485da2647d8d7c61543b52cde87187709eac0daddefd2d93", + "tags": [ + [ + "e", + "51c78616f2de35d6c0b3a15ae2d23dd3c084bb976d20162ae5accd81a136571f", + "", + "root" + ], + [ + "e", + "30555fb1b6bf99910d21c243c236dc3fbd5488a06bf7151f21fe2385316f7f5d", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "916b7aca250f43b9f842faccc831db4d155088632a8c27c0d140f2043331ba57" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "e034d654802d7cfaa2d41a952801054114e09ad6a352b28288e23075ca919814" + ], + [ + "p", + "ad9738030ab84c04ffaec64abadd6cc682cb3501d193a5ff1c94b90770915558" + ], + [ + "p", + "385eacfa42fc0831b4975983c485e0c7c55ed0f5e4f56d79fa7a7151fb0a06d7" + ], + [ + "p", + "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e" + ], + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ], + [ + "p", + "cfe3b4316d905335b6ce056ba0ec230b587a334381e82bf9a02a184f2d068f8d" + ], + [ + "p", + "c89cf36deea286da912d4145f7140c73495d77e2cfedfb652158daa7c771f2f8" + ], + [ + "p", + "52387c6b99cc42aac51916b08b7b51d2baddfc19f2ba08d82a48432849dbdfb2" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ], + [ + "p", + "958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c" + ], + [ + "p", + "d4338b7c3306491cfdf54914d1a52b80a965685f7361311eae5f3eaff1d23a5b" + ], + [ + "p", + "6f0ec447e0da5ad4b9a3a2aef3e56b24601ca2b46ad7b23381d1941002923274" + ], + [ + "p", + "ecfa3c5c82d589c867c044056f75d6cff794f1886d5ebcdd48ad851da47adae4" + ], + [ + "p", + "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450" + ], + [ + "p", + "a8171781fd9e90ede3ea44ddca5d3abf828fe8eedeb0f3abb0dd3e563562e1fc" + ], + [ + "p", + "8be2fd2cf7cce65a56f0820b022125e9ab4044c7dc5e444e2c0c0eab7501b0d7" + ], + [ + "p", + "50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e" + ], + [ + "p", + "9f807153876ccb1db650a96fb7ada5a1dbaaa0dc3f22fd3fcf545204aeef43f5" + ], + [ + "p", + "668ceee55475f595ec0e8ef44c64bf9da8d9dd6008ac54dcd24b2501930b960e" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "b5ba65fbb0221a32b6c14400f505cfdd3651d43938a248a9265a516ec0c54240" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "c82bf12d7eb3df7e3cc7766e54363133ac17024a5df2e179c603106cdf203e15" + ], + [ + "p", + "404211c6e9763f49c8c0d8622e6fbf216550b748674247400a2527e0f95e3a79" + ], + [ + "p", + "b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc" + ], + [ + "p", + "47d519cd3bfe5cc6705e32063ef39931946979eebb53425db5f2e4f19c3a3af5" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "f1b911af1c7a56073e3b83ba7eaa681467040e0fbbdd265445aa80e65c274c22" + ], + [ + "p", + "8867bed93e89c93d0d8ac98b2443c5554799edb9190346946b12e03f13664450" + ], + [ + "p", + "7560e065bdfe91872a336b4b15dacd2445257f429364c10efc38e6e7d8ffc1ff" + ], + [ + "p", + "f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8" + ], + [ + "p", + "803a613997a26e8714116f99aa1f98e8589cb6116e1aaa1fc9c389984fcd9bb8" + ], + [ + "p", + "a1c1984994512025327f52c7b6d3a1434a37fbb7a318380cab4832f8daacbb52" + ], + [ + "p", + "9984188a6578eb513fddcf658f389dbd532e54b82b628ad36666f7aa8f731b79" + ], + [ + "p", + "9168772564e66c07a776a3e2849b02d1a0ac88a7f8e621600c54493ca0de48ea" + ], + [ + "p", + "6fda5bce2882176bfbabfab503a1b5281329582d71c4be84bbb567e65c1a791f" + ], + [ + "p", + "a80455732d5bfa792f279011a8c871853182971994752b9cf1169611ff91a578" + ], + [ + "p", + "971615b70ad9ec896f8d5ba0f2d01652f1dfe5f9ced81ac9469ca7facefad68b" + ], + [ + "p", + "31da2214d943b6db29848bfe7e3cf8ec0380014414f06cddb0eeacc9af2508e2" + ], + [ + "p", + "26bd32c67232bdf16d05e763ec67d883015eb99fd1269025224c20c6cfdb0158" + ], + [ + "p", + "c43bbb58e2e6bc2f9455758257f6ba5329107bd4e8274068c2936c69d9980b7d" + ], + [ + "p", + "4ffc11bfa2f8516ae8bc7c6bf82275d358e47045446250be2d0e5612e2140828" + ], + [ + "p", + "50c59a1cb233d08d5a1fb493f520c6b5d7f77a2ba42e4666801a3e366b0a027e" + ], + [ + "p", + "a3c1a5ceda8b86b7cb64d5d6af58fc787ba400f2912b907969d27547f96545d0" + ], + [ + "p", + "1e9d809ea96f8d7227f06025f4ea2dd41e9426c4276d96a70770987c8013d21c" + ], + [ + "p", + "a12fb7fe051724a34ef3409ebcdb377b9d1157a79b7a2fcd8d008995f190d9fe" + ], + [ + "p", + "c02bedab495a8d73e23192fa161a0b8344821f48446811b1e810fac65b584f49" + ], + [ + "p", + "fe2d5cf62e95aab419b07b6f8a7b75d3cb3066fae25c6b44ace0f9f30c59303d" + ], + [ + "p", + "5133a0947abe801445c34e2f56151d8690f4789ab4c763275b14166e74c5830f" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "b43c91f2a7b9d21bba3ddb08cc321840df249364fb716213508bb17383e1d930" + ], + [ + "p", + "eeadea6cbb5018a190f0117857de513cc271d24c947d56cd82c54a6b64ae47a4" + ], + [ + "p", + "de90c5db36a4011f9d584dfc18de1a5724686867984793ef526331b51f8b43e9" + ], + [ + "p", + "6a02b7d5d5c1ceec3d0ad28dd71c4cfeebb6397b95fef5cd5032c9223a13d02a" + ], + [ + "p", + "cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031" + ], + [ + "p", + "df0aed92dff00d4ffeda2f4cd88d967dc576b5549400b92afb98a419aa06e6f5" + ], + [ + "p", + "72f9755501e1a4464f7277d86120f67e7f7ec3a84ef6813cc7606bf5e0870ff3" + ], + [ + "p", + "6e0a1f4852bc8fa42b8047d60a81930d88904ca3f2acdfb5b8413ab8c9f444e5" + ], + [ + "p", + "18905d0a5d623ab81a98ba98c582bd5f57f2506c6b808905fc599d5a0b229b08" + ], + [ + "p", + "90b9bec74789688e515125596ab6350bfe646176ac75742275063922c5fea010" + ], + [ + "p", + "ee603283febc4c31b09903392408a2fff1daf69ac2244a5e4ad07eca3bc79dec" + ], + [ + "p", + "f508d64aeb31bac052327841cc7dd59c9d37c182102a048a439098a8bb816dab" + ], + [ + "p", + "f07e0b1af066b4838386360a1a2cbb374429a9fbaab593027f3fcd3bd3b5c367" + ], + [ + "p", + "b82b98dad630f797edf2ee7e48224577ac93cfe899b3ca2a26d91b3edfaa2ae7" + ], + [ + "p", + "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0" + ], + [ + "p", + "7b2d73a3ac9714b06500005454b1b24ff255304eb488076822122101d0fcf7a2" + ], + [ + "p", + "4308ff20ced73871b69ed8bcada2a051bc3a05610d1183c54d67b47cd0f7035e" + ], + [ + "p", + "7f1b2d20466fd5ff836c6bca1a0849f9c77162cfc00b7e708a9142763c021673" + ] + ] + }, + { + "content": "Yes, because you can receive an order from the state to provide everything you have about a given IP. You can hash the requested IP to find somebody's past actions and deliver it. ", + "created_at": 1689952040, + "id": "b35f1b545a37833991adb956498d55fb652e3e19a9cfd1cb6cc658c0da547a6b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b46fce54c4e8e3136f73c769240149bf1fed84c293c6b19ee1909a305e92a943f889ebb9637bdf1f632058c1e80a4c1393112cbf18007b54a544ca2c8fb6b23d", + "tags": [ + [ + "e", + "d5fbba30ba547c6335e843400d698e5d9e87463fa7f1d03ca555dcc70fb17c12", + "", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "cxVTHgjC0TXaOTfTi0b/KJk+MLCff4HbxDT6zjwDG69KgMHIw/Yvn0NjHxW87PmHSDTlDaBGXKeDS/7BcY2nqg==?iv=f2tzSqTrlQoXwG8/46dOlw==", + "created_at": 1689951312, + "id": "ca2cd04e3cf4efc9534aed57b1899feae816e02c6a2e8671c2027d4d9f6893fa", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "af1214a7deb8c740b0e7ebc1f55d04e813fc69a4b1f225d7b4a47176230eda817a70f7730a21f518128abb415fb0107475d05a8a0c935a6f6864f26861810421", + "tags": [ + [ + "p", + "4b6147b45bbde75c2ce4cf93444675945c47f41ffd51e3446287bbd56ba668d2" + ] + ] + }, + { + "content": "https://github.com/nostr-protocol/nips/pull/673", + "created_at": 1689951049, + "id": "416181632a67703a81b42b9fea5ce5eae64e8d6c13e7abf46de4a1d66a2c509c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "70fb4cb049e4f4399327272847007fca1f7958c818053aa92679e7e3a665b64af019c0000f0004a13f2ba520d69e61529a8b7c35acfaf85b37cc9e1c7bab1c69", + "tags": [ + [ + "e", + "88c9c9b3fa3b378414e5225e78b7ec5c2a93ba5a508f43c380e70cafa3ade713", + "", + "root" + ], + [ + "e", + "cf98d38c498d3c21669d9e7fa9843589a0a481d17392c1d72848bdc0f9743e99", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "dd840e433b978795bb35a408bc61ef7e99688ba0b166f8cb4c7cebcb5318ecb0" + ], + [ + "p", + "c02bedab495a8d73e23192fa161a0b8344821f48446811b1e810fac65b584f49" + ], + [ + "p", + "77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7" + ] + ] + }, + { + "content": "We use 'e' with a 'mention' marker ", + "created_at": 1689942025, + "id": "05a786d700033851ba5bce7733e3259e9cfdde7c0cb0139406f5ab42744618b9", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "82707d97e8fbd4e5075bbfe86c377385113db6cb231160153ba9f21472466d7217cef48c0fc161a1d1aa83115dbfdea6abd568789808c3d2e5123d335130a716", + "tags": [ + [ + "e", + "acdd96b1297da7ac202c288e76bc7d85ebbacfb2339b1837cb35a0bfcc817a58", + "", + "reply" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ] + ] + }, + { + "content": "It's because our terms and conditions is hosted on GitHub, and F-froid doesn't like anything hosted in a non-free service. ", + "created_at": 1689941382, + "id": "664ef39828f333615e41daa08e975d69c4bbdf48eb94e4d78be94a2c8c53cba4", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7c767e3d95a36374ba2f79ad6eb3547309067e5f87d65dbd12cfe126f373eadb70ad20e3d5edd55f7eff165f0adb91503f8d8b5b52c501ae3cc580e508ddfe36", + "tags": [ + [ + "e", + "42416b010364982b8df9b9a696083b9df982b57fcf4dc907d3b978f42cdb36c3", + "", + "root" + ], + [ + "e", + "b648656bb2536f74e3ab7528e660d93c1400ca12ad6aa8fd06ba59e50643c905", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689941261, + "id": "0d71d842517f97ecd4b8df42a46e0dacae609e5db7d48540a25cad3808eaf346", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "34cb3ebc088c09bd4ddd9ae21a00efe862894bfe2140bfe99139b08aa8189a59456edf3dde9da08fb9870c2a366905cd9e96d41daa0d9e9353c332ea2be450dc", + "tags": [ + [ + "e", + "846479a923ab551c827422073e09b704249b5540e7a8728d4636bc27fb286647" + ], + [ + "p", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" + ] + ] + }, + { + "content": "It only does it on the Play version. F-droid doesn't allow the Google model we use to translate in device. ", + "created_at": 1689939108, + "id": "f8ec20358eb2d8c6f3335aa0a1da2ae40b0e3163fada3a543fd2c7ff2a1085b8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e622c5c274ab4ed8acd99d1ccc9918a47deb9e64aec0f4a5172740ae4e0118d2f3743ee178f77bac79c73f3f7b14b9cc07fa1a314e6d0a756b659ce09895482a", + "tags": [ + [ + "e", + "0b6153cc21d36e51edbd2c30c7c45dd2dde66c6bbd0b0b5d508a613f277b1ea6", + "", + "root" + ], + [ + "e", + "81d53f4fa555d6aa29175ff2235e34cba40a949eb3be8104176255451ebfadcc" + ], + [ + "e", + "01186521fa18cecbe9bebcf6d3b6194cef6dd36ae43a3b1306601e2cdf0debec", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "Not in the plan, right now. But since Kotlin runs on desktop as well, somebody out there could make a desktop app using our code base. Put a bounty. Let's see if we can attract more devs. ", + "created_at": 1689939061, + "id": "5646ba53503e6a10c538f5f9789b47b4cc55a6a0d51f9cd00fe026e8d0223b9c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "25a749d25096b1e8e1fc8ec0c078bfc24b7ce7c9b453449a97017f95636d797dbe001f7f9b1af2ca5ef770de5492739ecaceb3ec06396ab774ce36e4dd4d2615", + "tags": [ + [ + "e", + "42416b010364982b8df9b9a696083b9df982b57fcf4dc907d3b978f42cdb36c3", + "", + "root" + ], + [ + "e", + "f6ebe924faf115031927d851b4d09bc981370d38bba28895078f0f5908021afa", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689937268, + "id": "fe40d759d27e61ffe6be02d68c45c65bc9545b90247eefa3f668f4bb7dab6566", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "35002734dd66eb2c3088f053673c647e8ba7d6e4e5fc6bf73a6460d8b5a1a4691c828a0ff51ab4764bf4515b3597654b499bc8b1c54d65df472e94303d8d44d0", + "tags": [ + [ + "e", + "b43c9bb1520dbb4a30405c27b1dfe442232e53ace78168bbfb13258c6a7ae569" + ], + [ + "p", + "84eaf37660225a312d27dbf72acf588106664d444bd432b1c8004c18fa109d63" + ] + ] + }, + { + "content": "https://nostr.build/av/7b9b8019fecf3ae30a25f82ddd22c9fef7adb07cf444d87e078c93fc9ca74845.mp4", + "created_at": 1689907768, + "id": "e509d1900262f4ff82816e5f3250641cc29e7b07f4be966629b193718c59979f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e8f1a409c356a736019eb4a0d210fded04653bc1a1c869d245fd6a0b150e11c455c64bb838c868c488c9104e55cec2a0b187f8ca24786402f7e83a0d8d018257", + "tags": [ + [ + "e", + "50dafc0709a63aec5a1ccb97e979b4e6ab41faed89ef15590ed498b1bd654a7f", + "", + "reply" + ], + [ + "p", + "c4776f31e10298e4fe5b6a4b9d5ae240042f2ca17b5a37208f31166f2e88f05a" + ], + [ + "r", + "https://nostr.build/av/7b9b8019fecf3ae30a25f82ddd22c9fef7adb07cf444d87e078c93fc9ca74845.mp4" + ] + ] + }, + { + "content": "Imagine when you see the live streaming :) ", + "created_at": 1689907532, + "id": "019568d32c6507fd8677006e47afca2cc78bca9c3806158eb3e84838a23eddae", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "da67f7739fb06bc8faf7a8f5bc0b3672fa190b896ed4c8ba40f55ec1b0d7d2cf4f9a98652baea76dcca5409f5649ec2cb467852c3ee0ab3d015f0aad4708b91b", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "4a5c12f52b19dd1f9c62a90e6a05d2dbfe4a0c04bba189ac2f7991732ed0a20e" + ], + [ + "p", + "f3ff8e7774e3b3a85c54041bfd73b60fa2b32ee1c5dc316a1d564234552116d9" + ] + ] + }, + { + "content": "XbR03T+g66nPBK8Qzo9y3YKZJVnusRAkq6WJDgZr6HaoTre+IMvp4t1XyXc2nft4rDy8hY9ll4dkdD7Rz1TWwykgaPTY+Y/SjmUDfiPzoG7gEFaFUUPTXQLrOuTfZUmXy85NwHIsHRC/O6P/YFpfVAzkaSZZ2HWWEn5xDCQ2wodYJNxR1o/so9RAyzRg5PUOvS/5JigbohCQea/yjedxjmFdA5YqNJEKi6QoOvUtEhA=?iv=OQVBz1rnHxytQ+E7rQv4MQ==", + "created_at": 1689901355, + "id": "0d778eeaf231b9c5cbf775446e520e265465ed4e1db864c0415e55eeef45d862", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ce58c8d40edad01244fadd38b5819f6fd11062e023f3244f21bc176abad1e42b9c286fd6f963869c1fc96c860b746fcebecd915992762c843c4ec38e5c7c5aba", + "tags": [ + [ + "p", + "7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf" + ] + ] + }, + { + "content": "Usually 1GB. But with the way many profile images go for 10-50MB, it can easily go up. ", + "created_at": 1689900854, + "id": "05a9d71cfe8ec6b931d980c2993b696cd7a4fd5043a3f5c35ea05b301a5411e3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c46a68c8d2918707061b283c20308c397570c086953d54442b9fac8179a97ef5e2cb6f9e84be0d4db25cff63f13f5ed3316edd20ae0847be93ea10d87aa5f1cf", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "49b67dd8f173d56b99758522087aca6ab2a757e9ef1a17aec988558a3c0f0ce5" + ], + [ + "e", + "ec2980452b7025ac26449110486794c76fee7d48444d5435e983c72385457654", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "Ouch, 2GB? I recommend buying a new phone :( ", + "created_at": 1689899880, + "id": "49b67dd8f173d56b99758522087aca6ab2a757e9ef1a17aec988558a3c0f0ce5", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b8c2b7a564b932f2e582e4d53b063877a48b7414ace4c72a6289df20740e4eb06997045ee37c575673fdf4bfe25fedb8478dc80728a5378df49a1277307b0a30", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "2e94de4d2a46d61aa549f9e83545f5dfe4bcc404d49bdaae9a42dd202b24dc16" + ], + [ + "e", + "9fbc8f192e8e6e3006aba7567cb29c6f60ad7c583b050a8765ea91af95faab37", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689898662, + "id": "8ddae69f32ccb558e49222bdbbe9ec4137a0fbf4ae9614990fe34860f8ba173c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d7ef27a7a3ee6a1cdb1fcaf131f045e9fa98d8a7f18bd507e051bae262c5ab51643b4321fea5bcf733e22a48ee61f612792f5003c97b15f87b40a1bf32cf90a8", + "tags": [ + [ + "e", + "a3f71ef8b8fe3542df4c8a4bc88539a53db31c23f3a780543ea2c3ee599f9a3b" + ], + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "### #Amethyst v0.70.1: Quick Fix for F-Droid users\n\n- Restructures the spacing in the first and second rows of a post\n- Fixes Wake-Lock permission line that crashes on the F-droid version\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.1/amethyst-googleplay-universal-v0.70.1.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.1/amethyst-fdroid-universal-v0.70.1.apk)", + "created_at": 1689898519, + "id": "42416b010364982b8df9b9a696083b9df982b57fcf4dc907d3b978f42cdb36c3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a4f06bffed259dfb747c001c4e58ad9547e1ba5eda174a4d3c6f63903efc70742027d12443fb845b6a86e9de6a6d397b2cd2f520ab8274213c8f5b50c1c0293b", + "tags": [ + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689898498, + "id": "effe7e685b36a6cbd3dadbc74ff6d59c44c103f784ebf3f9faeb58ad2a8afae8", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "af8d2f662d454ed7b221118af7a1324675dfbd5dd90a3582c4259b848917df01b1a6373e2c5233035508e578bfb3b8f14cae18306c0073d2f7ffbbd93b4c5847", + "tags": [ + [ + "e", + "99d97b784f54e315731e04e2fd3ff9166b2ea2093eb494844b10ad37b86c36ee" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ] + ] + }, + { + "content": "Well, I only make this particular app for Android. People can of course install others. I'd just would like to avoid the extra work if Android solves it for us :) ", + "created_at": 1689898471, + "id": "06d414ca9e36bdfa02eac2f27b62298dad203dbf784e69d5c339e138ceeaa476", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "812cdda0e068cd30b21b4f700b1e738dc55e85bdaa1f03073479958d345bce6ed5a1e6c735cd9eed0ee773ccb26f1306b06708b5b70054f40d584f16db0e23b8", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "bfe8b13fa0a8a63ecfb3b7ad3bc26216efba5ae6aae7b95ec5749db8124d33e9", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ] + ] + }, + { + "content": "PlGM8/MtS+vQnbR5HHbc9YXs+DaKNeKeP/JQwTHL7Gw=?iv=+CyNjnad2EJ/djDiMYeJKQ==", + "created_at": 1689898356, + "id": "901df0b16ee3ff8e864d278876093827f15dd9a39dff49b059775dbb319831c9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "126f482832059ead0ea012dc7a5508517d4e08e7000e8681d2ba637993d49dc3b36735cd1a7b5d673c04245e8e02f2a27844a2b28fd2da6c02418fb5bdb11536", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "See if this version fixes it: https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.1 ", + "created_at": 1689898240, + "id": "2ea9c229e5cf6d8e6cc8c2c018ef08d1366ab70bd9d7dbb499e2e388666fdbeb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "52c8716c3d7afb35c58c1b7412db2cd44a08e57366349e8aae1c6015c5b078d3fd1b0c8f4ffb718cc81c0c462cecdf97f816c949621587cd635caefe56457cf1", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "107ea3c6074b2be4096c5d0233553db7ac326ede4678ba551498c13c80f0794e", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ] + ] + }, + { + "content": "They should store everything on Nostr. We have better infrastructure. 😃 ", + "created_at": 1689898211, + "id": "0b4cea57de5eadcf57efc6d1b44511c944085086410f3d400f13554d5c3eb132", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "732cf27c63e6fbcc4e7af2d7fbe2525af2aafe35f6effa270231f38393b13ae161cd9f58acf42ab3e7bbaebe15cab90b8fa4946484480d434e50d8dd36f6169d", + "tags": [ + [ + "e", + "3c4a16878ca8285dc65803a239b9ef7c6b01badeaed9b41b6e5af88a580c76a6", + "", + "reply" + ], + [ + "p", + "ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49" + ] + ] + }, + { + "content": "g5U/iOElAmZg1czy4FWXjbRuy3QG8LsGmW7QbwEYHjUEslUXqGNvG1P67+kt2IB6UnXUfUy0CCVTTHZ3UhXqJsfYfYNsME3JqRV3WOqDU/7Hb4zShj5YgbQHkai8tIhe?iv=aaWZ7SvNudW3QrZR+w7Ifg==", + "created_at": 1689898075, + "id": "1b0562bceb74fe5a051c0b23a1c76682268cf9857bafe3c7df15ad386623f797", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fa4fb247f84456b731e82fe511d806909df64c4c0a1f407f78dd97c5c65152b970ca4c204adaa1304dd2985ef7bb59da8b7c684d1528a214fc973783cb99fd1c", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "Just go back to the previous version. \n\nI am not sure what's crashing for you, but there is a bug fix coming up in a few minutes as well. ", + "created_at": 1689898027, + "id": "48cb09f199294e25bc884fad3253c92dcbbe5b3b03ade1e9c4f079c97d0803a1", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3b09c5b8d314d35b6302203557bc7e01fdd4c94e2264e9cd895a16a31b14a0f7c106444ad84b137c3f34079b19aeeb05dc281b9bb4128a95c31c4877feee2c52", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "10443395bebea840ff883d98379f814eb694486a0f616efa8e53e07ef3b1dc92", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ] + ] + }, + { + "content": "rAjdaIbE8aQNV4wl26DYoZtpy41jbPK60zFJ3eysPzo=?iv=Ef4GQxwtqFbfJ7iA10Halg==", + "created_at": 1689897433, + "id": "87702fa85d8d3b792be82275ba1c0763467b9c51c1b01970c238dbe601dc8e3e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f8f4d672666d2856c6ab2a6b323610da9e13b1839413f4258ba6d479dd8d6266ba25df019eef35ac84e5160f5b66edc828deb6f26e9205a01f02b16d82780f25", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "WTFQjqBoYqgRFXnqnGwfd0pNaeoL3pkbZxmCjuP6DSU=?iv=HrCg46zasY2X8fcVsqsMgQ==", + "created_at": 1689896526, + "id": "cadccbd35fa4e3719612a5441ae5165cc6d3ee083ed940184bf392e5fe3142d9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "72004a76b723ef7bd5fbb3093279a77546764840665bd5ac92849f284b5a7f12f489346ce251385c9af39da3b8e6bfdd15672b2dd940aaf33ae473b5a58d897a", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "QLzMfgQsc7ODjSrJrLxP6DUl+yCHFlLGmMINyYmp6xoi42+qLO7Ucssh4GqWCyY9?iv=niAXV5K/KMasL+eNCr2/DQ==", + "created_at": 1689896516, + "id": "fcdf61a01298305920b318c693f71b5f9ac322225a065fd4fd70d97bd69cdcce", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4606e2fd335552dffbf33bd6f619f2b8253f07d25ed38c2a26b3fb490800bc23c551d435da5839f1d7541c8bd4867fc5c6228cbe4355bda95f83e6c485d787de", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "Which android phone do you use again? Is it a memory issue ? If so it gets slow before crashing. ", + "created_at": 1689895792, + "id": "2e94de4d2a46d61aa549f9e83545f5dfe4bcc404d49bdaae9a42dd202b24dc16", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8ecc5eb799f021c715be69345b8209211203e65318248dbf071cd2ed24b1479a217354e192ac19fc50ac9ba20c9431d10f11a140377b4521d196fd240ecbcdbb", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "a0db5f1affbe065a92b0909ac480d17ea517d9ae03cc28f268468e7c82e7fe41", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "Isn't that automated for Android APKs? The phone doesn't install the app if it doesn't match the previous version's signing keys. ", + "created_at": 1689895740, + "id": "83a06ca57bfdab4f635221e67f05a5827ddd0e91f75756b05692138b5c055e12", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a74648c78086553bf3572e86c2bea55421fa510f1a7804da1952b4d095fd2c1280890e54643bf78b5402c3565e8ffaecfd5cfad509498f208a7550471c264dcf", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "3eb596232de49ba67b1f47873d388cb389f93eb6b3a0449889588b3099402ff0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ] + ] + }, + { + "content": "### #Amethyst v0.70.0: Background Video Playback (alpha)\n\nNow you can \"pin\" streams and videos to the background and keep using the app/the phone while watching/listening to it. \n\nhttps://cdn.nostr.build/i/04b00315f81c574afa45ae9c5632952e9bd7bd472bebb45b6e83aa97a692546b.jpg\n\nKeep an eye out for bugs, this is a massive restructuring of how the video playback system used to work\n\n- Moves Video/Audio player to a foreground service.\n- Migrates Feed, Stories, and Live Stream screens to use that service\n- Blocks screen from going to sleep if a video is playing.\n- Blocks WIFI from going to sleep if an online video is playing.\n- Allows the app to pause while listening to media and continue playing\n- Manages cache for up to 30 videos in parallel for each of the 3 categories: local, streaming, progressive content\n- Activates the use of popups with artwork that points to the screen with the video\n- Creates a button to allow any video to play while browsing the app/phone\n- Moves app to SingleTop mode.\n- Keeps viewed position cached for up to 100 videos.\n- Restructures the starting screen from App Navigation\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-googleplay-universal-v0.70.0.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-fdroid-universal-v0.70.0.apk)", + "created_at": 1689894483, + "id": "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d1a1bcad4fcae1592e27ddc086d8e9f656e790536038a82a7560705348c964093c89c6058e4180a67f423a34e91157dfd48168a3db3c05e5d33aa261d5ebdf29", + "tags": [ + [ + "t", + "Amethyst" + ], + [ + "t", + "amethyst" + ], + [ + "r", + "https://cdn.nostr.build/i/04b00315f81c574afa45ae9c5632952e9bd7bd472bebb45b6e83aa97a692546b.jpg" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-googleplay-universal-v0.70.0.apk)" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-fdroid-universal-v0.70.0.apk)" + ] + ] + }, + { + "content": "kyZ2J/PX5HjuuY6Fnw4Wwbb7aigSPVVL5nEieJ2rxeKOtIxKkdk/rMuo03JVuKOFgV3+TmyUQS8KEEI0TCv1zu8mBvrQ66gk8SFcVFRlDHINWO4gqMV3hQub1+kbgO/YZv/ez4aM90+TAH+gLqb1Is4gAOOJ+aLCpeT5mAkmpwx4WPA99Q1jgqUix6Hhq9vAZWfbB+YnoCkkePlMYdM6iqhdMAPrPwTj6sauatmQCkBFTiPD5kCro750tKS63cvkx3AyYGDJWxkC8yURnqp8j9MPzXsGoNRTdVyimoJXFZyY/v/iRzeWQwFL2sHa1PIqKrqwEE/9mZY8YAqtrMx+5WB8tco0Aj5QnCJpocJ5XEB9M23Hl4/fMDrRGUrf0KCSmiSd5oljW5ruasb5VEEMgwO6RudG28Qafk/Z2cZ2s+nx9fjkOHkOaorOhCotPq9d2bd6VpqXc27BnKU/LyxRzFqXI2bVl7Gzl15nTobELgfjSYFIeUWYhbzr6n9MhCe2dqgHIt4FR5NUnHbLMHylo5zVBBQTkOQ5TX4BiE/55BbDqmudqNuQN1ZaFmKvRIaoIutFhopNwYRVe+Lj8NGcZ6jvR1sw+AcngJBdYYsAPkRisMELbeUkVNkmUcWbQIRNPsuTSKM8M/HSDAA9SBH2V5Rzd+8G9SrNoCGJoueABfJqjz0JHg9fc5X5qfa9/Ly5S0bgHhWU2wueMX85+UiD5G8yJF26Gd/wEr4YQrMnXT9xmTJQ/YusKmygOxcZ4yuC15stNV6hPnx1Dwr4KhISbw5+i76f/GFgbY7iX6nfKLsl+A6UxNGHkVUt1QJWRP/BxcXjVY/1ynQMmUlyIDu8E/ZbHCGvZuOnYow7+BDfTmKzgwKRoO8HcbijxsPG/nKF4QUIP6177l+wL/46E0EZGxroyT+w7a4QcD9lhqD8Bm4+6qFvBt1Ihl3RVlVzN6WzaKHVzs/WUhRYXNm4qzyYwjtCPR8jJ7MnfgFqFqDYye8Ac7CdIZvU/j2B3Xqk0ROLpcigFw/53sHJzfPz64QeE+Iq0Ikm52Vd9NqBCzQWJpMpi2mDPEL39P6AqE17sEFrEiEz5Ul6sl9gI0UywPSgf7grY3Thl/LmmRVl7LtDqWJxQ6/0BI05pUtFo9PF6ZfbA2gqt21gB6kRx3FXpxiHN+1KB+eBkyTUa/GwyAFwO+1noID2c17HlBYwrxikxCH8WD0qCjXmy2rzAUopDTbLmtrREfDTDSqm3Wy/3bDLYcB6rKcVVZUGQWp7heAbhk1i2LLouBQ4aCBE+Q8NoyFct7WWNZ788s2gBwA9xYx6haCnWu8x/72Fq8EayH8pakEWqNsAb9Ig7oxUZUbUFVlURp/4hFA6ypVEsVxPSikJ/iS9g1f9hO3vQ451jKWmVboq9ViRNSvIwI4xzB8/iYi1/R1NVanEZLovOP591TcDs+rCRL6xBpeZPM8NFWiqMo0K2Hr5/SLpYR2SFTcVG5x5OGCvAQO1CsrK6YxXEkRI7uy1oo7bYZm/LgzPzGcbESRhZrA2VmwtdE6LC/vTt7PlW8zxPAzja8ZeLkuKSZPlo9ahYHNtuHAVTHh5JojspOMJlGIjOrnZaSPZF4kjtOfhevF8b15YuE/EDLnv2EAyGR7U0zGm1ZSUgecOW6D9dBX1DrMLXbnSydaDxq76bmMIXzynqlQNkTHLPrj+leiB95gNaAc8NnMJoLeK7E9FiFxD?iv=0wUJgJRH2qM6M22nQWTtzg==", + "created_at": 1689894398, + "id": "ad2118124d93a34e78de1868a03b5db77269de9bd847008aa56d234ac435d911", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a32e5d31c055b090fe60ba719c8691c8500eaa9f0239e9b3ca823c8af7ef0251c470facd766d0915bf495d8414037a16692da9c487bc260cdb43f272a4aa2c22", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "gXTNhA9dmULS6RWEeUnGpJeK8jO62z9IXGTKEeZkFyLPyzR5cwhIyVDCPZra7fDs?iv=S/+MHIRTOC3YvN5SRg544A==", + "created_at": 1689894312, + "id": "9fddd6ed3006a4b02a4f61106f93422a875d9104e18ba17d44b41eb0d29118ba", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3332078b36bcff14ed9b4079c4615bf2a73d07d5982dbc0c3c225ad768d400f1d152de5912119a122c7366465a193bfe47817c2a03c575664074fb47dc5a56de", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "SVapbPIcn2mKwCD8+0X/ssrakEARyfHdunFpBqKHJXCwahK9G4A5+wT8Tn6HcmK+qALX/eCIEeotD9jQfT/yFw==?iv=V3qBEP8xd7627UWztQb/1Q==", + "created_at": 1689894286, + "id": "ad0a2b77c0575b49e3f05412a09abbd7575e76547e9565fde0315a89c16a5c26", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8fa4798a22452bffe5c56eeb0fabfc3cbb9b9ebe1ddf861fae41321befa3d67354fc588f3e664c004b5d5450df27dcb6687dc5261bcb24c986fae40d81114f0c", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "We could use another update from the user to identify that the stream is still live so that zap.stream doesn't need to keep updating the event to sustain its status. ", + "created_at": 1689893549, + "id": "6ce9b93a4fb5967d2f70225d0d8c7777c1f86d3fefa135229f8efcae338d783f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6126b61b95f23e5364f13a7f75899e582641398ad66aa77277182c839243f503e03fb36f5445366336ec6532dd03a3aa90951aa41ad2a153ffb114be9438d096", + "tags": [ + [ + "e", + "173eb29e7402ef1750db662d06b88a187ea9c0b31d4ceff1df5d048fe79be3fa", + "", + "root" + ], + [ + "e", + "4bee2e762c7d296bd66dd5b4a0e84dc7e0a98355ad5f18277ac65c06da9ab25d", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "This could help in the updates to the LiveStream as well. ", + "created_at": 1689893064, + "id": "bf3bb95525800c504cb74c0c60a5e532fd1bc9d774613af6b6cf8125900a33b7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dcc36fde64e0b483114cec6ddf5f72b9f53a7deb9bf06475a4f36aed81b2d41d0b2982e77dee6d1a015c219db7bbe8672c5c43abd49646956d7a1ac669a6ee04", + "tags": [ + [ + "e", + "173eb29e7402ef1750db662d06b88a187ea9c0b31d4ceff1df5d048fe79be3fa", + "", + "root" + ], + [ + "e", + "fb6f8c9055d34b3fd2eca32f96a5193f9a491a623a57819b8c904e0504477190", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "To the best of my understanding, that is only true if you use a different IP for each contact of yours. Otherwise, the server can easily bundle your queues and map out who you are. They call re-using IP an \"opt-it\" security issue. Even though most people are reusing the same IP for everything. ", + "created_at": 1689891763, + "id": "48735c6e1caa54c82015c1a11d86c9c3ff8e7e6102992bc32b78f6c5c4294f43", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b5e3b53de8cb327f7da73d2c9a09bf409d484e3dbca8ec7c844baf9b11ae9e3cef535b82d6c716260eff36bb672c1ab2a5ec85f445a539dba4cc919af0e8e0e1", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "0bf10092a54fee81dbd3ba2100eb80766276e93a30bf752961ae890be2993617", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "9110fe860a5e40a41e03d52d306a7b337ecf32d1090812ceaf423ec9b94954c8" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ], + [ + "p", + "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171" + ] + ] + }, + { + "content": "+", + "created_at": 1689875139, + "id": "7ae6e200034e48f561c22648e41f677cfb71a7713bcba6bec683c74727ef9619", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7d73b8fe475b99b11e8041728e32f55b505fa57f141fe9a301d7112336fc2cc09829734d27100168ce3a20894b8321965c3479f544346ec4e2828ad313e36178", + "tags": [ + [ + "e", + "39d9dae2bbd253e85c78c55ab89588a245a9c40ad03373fc355153d35119c80a" + ], + [ + "p", + "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce" + ] + ] + }, + { + "content": "Same issue. The metadata is always somewhere. On Signal, the servers at the company know the destination user and the time of the message. They claim to not be saving it, but who knows... For SimpleX, it's similar, but on their relays instead. ", + "created_at": 1689874092, + "id": "06756e30fb3a5a5adcf0ce8953e428dedb6511b03cd5d219cb3f394e58dc1657", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6a26f8fd80c2ffef7ab5c0e505e2223ab57f4b4b049e7a5135afcf03c2fae44b71eca7a457f406c4fdbf3cf74c52a832eb354d303fa236c431ee0638680fc219", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "fc7fd52ed1a79e8f7a1dd6a84e38a04a73a38b92a7a43e4c76df9708adf70946", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "Clients generally filter by date, so you might not see some messages if you do this today. ", + "created_at": 1689873381, + "id": "cf1b4e3048fd829ba241bc84b850937cf8ad256208309fff0ffb92c6ad20cc00", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8463639628afe7493e61931e6613da239d489d2c9c3212e0c58ac622a166c6664cb13cb33b80531f4037da99d2bf53548ad4553104ce55e161aa5cfe11e53584", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "d8d90496d54ebf856ecb5624e4b9e07950dd5afb1c57ff2b1a25fddbef98185c" + ], + [ + "e", + "adc0937780518b6a35c3ac44e36b2c48d0e6643d3609847652c8a4c245bca2c3" + ], + [ + "e", + "c98d7ec7858ff362a45b60c8a6dcd03c9b66f30ad65e89755b66d0c38320c858", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "🤔", + "created_at": 1689873007, + "id": "0900a6bf9d2ef4b4765074b26e277bd8ae573e8dd697fd2a2a2626f2e6deffc3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d0a69e056d477f73ad70ad00ffab45cf33d685bae46a674552871eaf47f131ce2e063fe2064a4710f0393869a96712d0ae85e626b895e1f979e4478bdef8e56a", + "tags": [ + [ + "e", + "1a3cf67ecc40a0861fd39830417671a62c98ad35de0ee0ad9a78ff2b0170e1f7" + ], + [ + "p", + "21b419102da8fc0ba90484aec934bf55b7abcf75eedb39124e8d75e491f41a5e" + ] + ] + }, + { + "content": "🤔", + "created_at": 1689873006, + "id": "330f32a6c42c2f555cc1b2def25e521ea35823a81bb9fe36d08cab65bc5bfaba", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c1dd416e6ba387ce6b74a5e8e7040e2f3e01b0a3ccf95660ffa8383da9f0e7cfbed1e9294416c3f386a9cd071a30b9767801bb2cd47dc3026e6d3116e7c8f6d5", + "tags": [ + [ + "e", + "7f4c1bd91549cef6c1e960bb3beda056f559097cee1fc957c131b4029595d6f7" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ] + ] + }, + { + "content": "Nah, governments can ask operators for their data", + "created_at": 1689872829, + "id": "deac3db2b74976056d8f5a4543aaffd9ddabe2209323861605bbbacbb1a2309d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f827a824cc259fdd1b222d29ca59c63afc5d5926039457cad968d9461787f4d6757dccfebf9c60cbf2d47c9cdd3388d2aca713326530bccc3bd8c33066e4db7d", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "60de51c3eedf47496b23b5bb16c9b0513d776d9eab4326255adfc9df791f5eb1", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "2ef0fccfd5a55e36bc8be3a525c1ce97f20eabaa94e69d82febfd641d9480c35" + ] + ] + }, + { + "content": "Governments establishing a stronger evidence based on a time collusion of separate evidences. ", + "created_at": 1689872769, + "id": "adc0937780518b6a35c3ac44e36b2c48d0e6643d3609847652c8a4c245bca2c3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b46c04c7a396461959a0da797d2efb9ce1cf59fd00bb48bcb3f5b8985b7eae1168636119fdc982914acf5f5419b852d78a1a78f3453e6b1b0c561cb5bb57a441", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "d8d90496d54ebf856ecb5624e4b9e07950dd5afb1c57ff2b1a25fddbef98185c", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "How do we hide the date/time of a DM message? ", + "created_at": 1689872583, + "id": "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "69870943756995ebd822c0d56cd2ffe451fd8713bcddf7e8828dbe3f833823005f3e4a34170eaf4020fef416c66af03ddf00755eda42452c0b53c8079771a403", + "tags": [] + }, + { + "content": "O2u7Hl+o63AeQNONtyIXjDI/2Xi0WckHVWZ8aGJnO8hrT1XILWJ49X11Tz8eM25sW5vxceKR2gnYUh+paux3ZMkfhTYdcm63f9AYsyQBS8k=?iv=/iMP4rhqmyQXGU8+7llTuQ==", + "created_at": 1689872451, + "id": "175fc151ab6ba3d8c567e8bb372827ec94b875290068d4601251401042027edf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4a3e683e8a5073f191de3d49cbd5dfd4688382ceff1050addb5f5f6495bc7780d9d225ce346c23551141cb36442fedc2848f09db68d27f25fe2150200d1614ff", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "e2dv5uyGynDKSN73wEsul1qw9v6Lns/jEYGcZud+u9Yh5gO6cuHHjVcwlZvIysfiEEL/UkcM1LSkpsrzrk55z5Ruy4f3CJsacb4DTFX7eI0=?iv=rCyLaRVvDh5PyMR4rZrIUQ==", + "created_at": 1689872401, + "id": "b3fece9b46c5cd3a5243d2bb4d23db4ab8648ad7b0fa6348a519c3da895c3ede", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1ebeb7474c7a58a87d9738119fd692a7aa19ab65e231f43deb46c2d51814fa217194224871a7a8570e93eaeade1b7517e9cd13c54166d57c18aaf3a2fd0ce778", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "It's sad, but true. However, we have a bunch of new non-twitter apps to balance it off. \n\nnostr:nevent1qqspadh2506xtrpjtld0mu0e5ly29cdseu2u7vrx5thsxp88t4uy6dqppemhxue69uhkummn9ekx7mp0qgsph3c2q9yt8uckmgelu0yf7glruudvfluesqn7cuftjpwdynm2gygrqsqqqqqpz67xy5", + "created_at": 1689869163, + "id": "e35dd1efdb8d688b2dd82d8e87c10891c2c15e0bfa85d75302366397abffff6f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "16fa751c379b5107f0a8f7c975daacc95806f7d50a0b9f1fc58fbb8008c32278581f0937e3aae646e01d6bdb34a4f9eed8b783fe756378c8c20463e875b9dbc0", + "tags": [ + [ + "e", + "1eb6eaa3f4658c325fdafdf1f9a7c8a2e1b0cf15cf3066a2ef0304e75d784d34", + "", + "mention" + ], + [ + "p", + "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411", + "", + "mention" + ] + ] + }, + { + "content": "+", + "created_at": 1689868629, + "id": "7f13a40f500a511d643ee6115877753a788f00c2b00d342a5c3cfbc99190b16b", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "22d6b5deedb017d291ae94d1e638a479397ae76c8eca229b193555bc436afc30e8f795a27224417b68a2b0a41e96414a14d203193facdc9ea2d0760ea7a4bf5c", + "tags": [ + [ + "e", + "9c27822da347c0083c2908d51f2429917e079e0fd1d623e334cc2946395dd123" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "I can see that you like good UX. I just wished you just asked for... say... to increase the space between the text and the image directly... instead of this snarky post: \n\nnostr:nevent1qqspxnxmq8gvh2zf64ktvqt6ahhme7qy86cpvzqgnmszj5dc2stsrpgprdmhxue69uhhyetvv9ujummjv9hxwetsd9kxctnyv4mz7qg3waehxw309ahx7um5wgh8w6twv5hszxnhwden5te0dehhxarj9ecxcetzvd5xz6tw9ehhyee0mrzlhx\n\nAnd since you like good UX, you should know that this is not how you ask for improvements from anyone, anywhere. You ask for empathy, but you didn't offer any of it to those building the product. \n\nThat post could have simply been: \"Hey Vitor, can we increase the distance here between the text and the image?\". That would have created a more productive conversation. But no, your wording wanted to shame me and the \"product\". Basically a \"look how bad this is!\". Maybe I deserve it, maybe I don't. But that language goes against what you are preaching all along. \n\nOn a last note, Amethyst is here it push for everything, all at once, and move forward as fast as we can. That comes at some UX expense, as you realized, and that's ok. With more help, we can probably offer more UX improvements, but at this time, the majority of the development is still on new features. We are still on version 0.x for a reason. We are not even half the way of an actual product yet. ", + "created_at": 1689868224, + "id": "209fbae56844f5d16e64fdb4026ffd966864de649062c30146f8e588bb5eb452", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fc3e11ca74034193f5c7044e80f6c5a4b035b7daa82147584c91fb8b931638daa43c5e7d576c4ca7bb295d4ca955a7e7f63658dce6e9ebe489b3a0473efaf3d8", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "7aeb2f3ccca028aa33a3ffc287e6538e492d61c0ccae70627275730fcc354ac6", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Dude... I am here listening to you. I am not bothered at all. Why are you so angry at me all the time? ", + "created_at": 1689865738, + "id": "7edef713c3d24ea0271368c68e844ec1360c026f21c045c7b517a872de094259", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7ef93ac8f1bb45c1b8b11a8c7f168c82c7deeb3d3b31221c60fd0ec21a0f7a11ed4443f194bed7cfabdaa90e912d6cf25bb7e2ca85dfd813e9d74611ac469b36", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "cebe888ef52d02e679c32b0259e441095576bf9ca5a4508f69deb2a924c9f926", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Yep, that's the thing I was trying to avoid. I don't like disregarding what authors do, even in these small details. If people add a line, there should be a line there. But I will do some research to see how to improve it. ", + "created_at": 1689865669, + "id": "4b771bbff33a16eb80f6568a5473c1415d8db077884b8135b105c661c64c0af7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9517d5805d660b34e2574d7d95b3bcb25537dca821df8188d7367ce2b2189aeaf1eb53da84010e0d248054a3f9c8d6e616f0ef5eaa0e50d306eaeaef5bbd7474", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "9a8ddc77b81891173936d4d3c44501ec5873c1ac6a246e34f89425e4bc4d33bc", + "wss://relay.nostrati.com/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "I was just asking because you seem to care about this detail more than everyone else. But if you are offended by it, I am sorry. I won't ask you anything anymore. ", + "created_at": 1689865536, + "id": "72af445176b583364787978c772c929a550202cfb588da2e2a97bdef3601c93e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ede069883773114b2303a9981db6d6f72125aa423ff1a58acf0aa01af0ba498240bf60cd2a6a8350f0a58c972754ffde1a267fdcaa967fb7200d6a5605249193", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "f49066620597bf34152ec79fdd44f4647016f9f519610bd2b470127915593a13", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "If there is a new line before the url, do they show an empty space, then? Or do they just disregard how the author wrote? ", + "created_at": 1689864705, + "id": "c469379a1f15f7bbcc2a5da7a8b5eb3cbaf1a90c9c1e28c2b424ba88114fa496", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "914cb11c0f23568631c72b41ebe14b5d12f665a7785e5cc28c2e77b8d959c1e62d9af1b4d5b056783f7534f605d4e462750ea34c9cdc44a938764fe1b35eb1a0", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "a4947c5d9a71d2d944e0cd1edcafaf25375ef1d4eefdc495d8ef58c38cfe6d48", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "+", + "created_at": 1689862624, + "id": "79d66d3f15bea48b6d0ac94f1a940ea7a0ec936527f56b7296df12f127560999", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5e1749add3f9f02d3044c2d2919313f8e616a7ff4bbd7dd0735ce475425dcf1d65f388f4705e4a18224ffe86e69a194037ebe21f749a515d18e537e75eab2080", + "tags": [ + [ + "e", + "7c3b8478c7fae8943356cbc72fffb91d0972402226da9d84a3f63d3a77bb44c9" + ], + [ + "p", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" + ] + ] + }, + { + "content": "+", + "created_at": 1689862150, + "id": "dd53ef21c2183dd451d8282291c54ffe30aa4a737b2111621f1a02beef772af4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fec7169c25bd4c995794b079f1acbf4212ce27555d4e20a57b5ed10be85e0a53c950db078c9c4460a2590baec3f0c88762e212411799b76da6ce529e886568ed", + "tags": [ + [ + "e", + "c0012d024315e94af4a3b78d88b5764b3db502689cc40e59f82051e57d69cffa" + ], + [ + "p", + "50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e" + ] + ] + }, + { + "content": "Do you count just anyone in any list you follow or do you want to break it down per list? ", + "created_at": 1689862073, + "id": "5836b1b6d60d819f7cdb9db7b723734ed46a43ee3905c712c379fe8430bea635", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a5e34cb72dec3b80098a9d092c122628e192020c9d96437c63729f9c2fa892d5cb73b43bc08f7b18ea9048db5938abf0e3cf5688d615dfc879f956154cdb2467", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "3a16231d3ae0dbd75a99c7d6038acef7ea2c510b123c566436965f813a638bf5", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "+", + "created_at": 1689861399, + "id": "72c71b828d4ba31fd341a35bc2ca90e24dae437cfe8d1b4aea48354154222d09", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1d78e9b3459e05b3fe269ec8ef4351bf86d54783d14585bbb2c0a370b3e9e47f81998c0b47168e16f568208ab7e55c4db33e1375b5f1b840105926a0efa5cb0f", + "tags": [ + [ + "e", + "0c1560b408c5fd5fba808d72506faf0de36b684beb39ae130b625ff48ac78e76" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "+", + "created_at": 1689861396, + "id": "43a89e40238a6eb9cbf43bd5df47879e6952b4bb7f4cadf93ce57de2a16087f3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "07801393346d8264c7833d4926c6daa456630be169f61b504e73c86e5976cf1dcc283bf9334238457523ab0f6feddd9ca4f9d4c005e077851654c8070b10b27d", + "tags": [ + [ + "e", + "f4bc628a982288fae1e8e49b4858a64943e6cf9bbf25cf04faba489ec26355aa" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "Ohhh... Community tabs for different kinds. Very smart. 🚀\n\nnostr:nevent1qqs2auwg7glxpsqg5nvwqyuvpnzx6vgjvw40v2kyg8ptrr5dj6rg6zcprdmhxue69uhhyetvv9ujumn0wd68y6trdpjhxtn0wfnj7q3qalpha9l6f7kk08jxfdaxrpqqnd7vwcz6e6cvtattgexjhxr2vrcqxpqqqqqqz4hge7g", + "created_at": 1689861199, + "id": "998372074cee04fc8b89bb385dd6eb0ceba8cf5012446222ebef7fcc33662f04", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "39ac7eda70f0fe0711db4bc00bc2bc1cec8e7a45fd27daff370660fcc074a75b1057956a2186a4ce5dd8617048b79e0770e09b80a57fa187e3025a169ac3deae", + "tags": [ + [ + "e", + "aef1c8f23e60c008a4d8e0138c0cc46d311263aaf62ac441c2b18e8d96868d0b", + "", + "mention" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0", + "", + "mention" + ] + ] + }, + { + "content": "I think the issue is on how to link the list choice and the relay choice. Does each list have their relays? Or maybe each list have relay list options? Or maybe the relay list is global and works for all lists? \nMaking that make sense in the UI is a challenge. ", + "created_at": 1689860877, + "id": "81911e85a3c7de2db65564853d4914a244ead918c2a9d2a17ab9a4f707bc63ec", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9a2caf4c047ef61a84e5c1d54e5297707eeac4b6e839cd696336e3d55101fb77924689fbbe167e9d60460f48cb755e27805c0b478b6d98a96fd05e2264e9554b", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "4f26cec8a0e10ebd1be60ab8debaefd2de1a3f4e2f6c05910cbaa0d825c9439b", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "oJ8doMpCQ6ZfFxbyPiaH1Omq+QlT00sigyEEdmF0CqE93YpfSkTaawk69Y4G3RKet0JBbRZgwPlhtdhU2KBiRpQ8Ggf/W3cHayb5NeeaYB466R4GPeGfeAP2yV18ASnoUURqKH9j7kCazec6s38BKofrrlr/O2uARqTAZz+aff4MqMRqkjBwJXkUqwGQNgjZ7KXcSSi4R8g6eUfChTZaBEryxQh00zK+skAGhZFbvilgCSZ+PjDAohC/4Nz9kxK9jlRJZ48cjbZJlhSpcNv8Z4q5x7l4CWjZhomBjFgrbyXOXsdMUXb2Zy0ZaBkd27uk/O4SlWdT7oui+LC+sYd8CkfFln3Ho5kgFp7nnzi8emFowPKVVv3sU0J2Sc9Rw1nNnZT0ZKeOhqZq5MaRBPX2gDMhJf61n/JMImAKN7ZITgA1QJaw1mDplV+CdZ8kXHfn2gtOz+u+rGa9mJR/fsfgz30klfcixBUlKrQu43jorkU=?iv=wlPb/Dk80+TDdCjEVj1zxA==", + "created_at": 1689860762, + "id": "3082d8546d083e4c02e513e31fc7e8fa86d86d760958619a62fa9328df0592cf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26f8d38baa4b62826b067cea227fc2c169c1055f632becc9c6dc2dc26e4807c296c9d97a41f83a75d63297dc67a2918d27c0f2d42e5a815075603ddfc9a7524b", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "NmGECBQokRbj25yC1GLM+0EQp4uDQnbCUMAGr6BkfNGTPyGRV9Wp+nSMxU+5QWa6tfPPuT53FhSairq9lGgFxA==?iv=LWNGgfl2ZxKJaOulvEt+nw==", + "created_at": 1689860574, + "id": "786ecdd73e40b5a8df20fcd26a792b49fef7ee599002afce4c1286984316db13", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "27ff060daa027c8945b0908c25ef216e5442ccf92fab897e604a3dadcc7348c6a9b7e027c26e6777f5fd17f9b73cf1f1b5220c8bf3036c00c8215e772870138c", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "EmytAFZloV1YnquNnF4dS/SMoenFynEDbYcWjQF95sQ=?iv=uADsN7t5Gac2HA+6X87Ung==", + "created_at": 1689860432, + "id": "23e2d47970dd18fc49cf39e0e1db5ee94d03f7370809cd4de9748fc77bff4a0b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "282e43b702199690d8d62543a09d3d469ba8544350b022c24cd2bc6838a6169a471c7a1839a58bb6b6592e0bcf9687561ca91d118d90d01d729cfd383407e611", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "zpV5c64Gr25S63G3P21ulXKzd2pItJXtq8sE8P72wtY=?iv=Zq+BthTWRbtlYb8mKvdmhg==", + "created_at": 1689860314, + "id": "3633e70aaccfc32800d2d435aeed6f447c55e646a3e94f2251e5deb2fff926a1", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c358edc8614575bf56b207452274de2800befa7d8905f306c66bbf4f8e44f5b1b2674fe5bfe1153ad3298a0f9281ec1135b2c1f57a05b006003c085eda755a24", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "For posting is ready to go: https://github.com/vitorpamplona/amethyst/pull/511\n\nFor viewing, I am not really sure where to put it yet in the UI. ", + "created_at": 1689860166, + "id": "b6fea7cc4087d399fb7b8b9e74cdca10c27d9756816665271737630db01d6326", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "361abef7aa4ece11a8cfa6371a3e2f5b5315ae0d31c3a10ac3d548940f62ba8982d1643d6c6228d84dda1aa9442872ca01fdc2830ba855d8494826a10f012f2b", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "b6aa079b9b7c174a42441857fcac737a1924229a7895e3cbfa74466256403fdb", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/pull/511" + ] + ] + }, + { + "content": "Amethyst is the Swiss Army Knife of Nostr clients. \n\nGM.", + "created_at": 1689860006, + "id": "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f6a03ab1f6715956b000e40ac4e524ecd8112f6a985d387b671a5c8c96f74b5edaa75ad7364b2e0efe6e10cd61f28b76b6cfc16f31e6d0a98132c22852153660", + "tags": [] + }, + { + "content": "The off-place heart has been fixed, but I am in the middle of a massive refactoring to ship background media playback. I hope I can finish it today. ", + "created_at": 1689859895, + "id": "f0d79ad5e47832399d6e758c63e7ae6b3c4041abd60a6c3f2baf7bd3b5bec847", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "efc9be562ec6b9827408ab1343443226ef638b8b70ff7bd875ae4b12a9c6dc19c5eb10537dc09b8b19a321230e8441b0e88519d70a0fc464a1a22855570b6357", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "d29521c859bce7262b1e1a988c9288d88466851c2baffc6f7578b0d9f2937347", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Sorry, I just tried to provide feedback on why things are the way they are. ", + "created_at": 1689859533, + "id": "23aad9e03bbd73536e22e830f819868447211e8ef66953830df4b1797c50cf97", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f33c542e002f2d788b76cf55ee3fe144aaddf993f5a5c7e786fdd9ada1d3f180ac3686f0b504444abe8cf050d503052f16cf07b169da0128a5612f9ca378add3", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "c5d0cdba3717383bfdeafe50c1275c95de28b18d036df0a60699b9ca019522e2", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "You can already do that with lists. Even if you have not created lists yet, you can pick between Global and Follow lists to see notifications from. If you want the feature right now, you can create a list of the people you care most and that list will appear in the Notification filter. \n\nThe only issue is that you need to make a list in a list client, like highlighter.com or listr.lol", + "created_at": 1689859394, + "id": "b4b1f87e23e0287022def5727e902c550fddab066777ea788e85202613a01876", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "161b8ff1c1bcf4b20d88d17a41dda3f28d937a5228c1db4d1922557c76cd16a710d836a8cec1639777882e676069df86225500bbb631096ed4c21112385b426d", + "tags": [ + [ + "e", + "2f93fdd2f638c4932deff5b2dfdeb8e0f3c464d837e579defc2f95e428574112", + "", + "reply" + ], + [ + "p", + "01ddee289b1a2e90874ca3428a7a414764a6cad1abfaa985c201e7aada16d38c" + ] + ] + }, + { + "content": "Yep, it's similar to how GitHub and Reddit do it. But in Nostr, reaction counts are less interesting because anyone can create an account, inflate numbers and change a reader's perception of how the post was received. \n\nIn Nostr, it is more important to know which person provided those reactions than in apps that centralize identity.", + "created_at": 1689859264, + "id": "98e44e39877beaae41a73c2ea0016087e1a933cff20978715b77abcbd15057ad", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0b5ac8ac7a02c0afb50e4b1f65d1d72e5c69cb6b019ed61fb788f476ff35767c219e324ecf81298cb91dab3afd8a16c10f56beaa87b3b9b5ba1f28963f7ef94f", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "ffdd0c0aa3242dfcde3a4dd2b40a8d7b72b1a35c59199b130936c311a6524fc1", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "> I feel like the reactions take up a lot of unnecessary space \n\nThey are designed to be a little party about your post. In other apps, reactions are relegated as useless. And because of their UI choice, they definitely are useless. We are trying to bring meaningful reactions back. This means there should be an easy way to immediately extract meaning from the reaction set. The current interface is not perfect by any means (especially because this is mostly an old design updated constantly over the weeks), but that's what we are going for. \n\n> should maybe be collapsed into a consolidated view that can be expanded to see them all if the user wants to. \n\nThat already happens in the posts in the regular feed. When users come to notifications, they want to see everything that has nappened. Hiding things that happened because we couldn't find a nicer UI yet is not a good idea. \n\n> The stats would feel more natural below the post than above it.\n\nI agree. But this is \"new\". We changed some of the reaction design in the feed and also added inline replies at the top which should have inverted the reaction set look in notifications. We will get there some day. \n\n> there is no space between the pfp images so it just looks very busy and cluttered. \n\nI tested adding some spaces, but it made the experience in low-end phones (which already don't have much space) a lot worse. So, we reverted until we get a better idea. \n\n> The badge on the pfp has a lot going on and could be simplified. \n\nOn this image, it's just the profile picture and the badge uptop so you know which ones are the ones you follow. I am not sure why it is a \"lot\"\n\n> And there is also not enough space between the text and the image in the actual post.\n\nThat is a problem between clients. In your post, there is no new line between \"I’ve been missing you. \" and the image url. So, it should be as close to the text as possible to correctly represent what the author wanted. Adding space is not what the author wanted to do. ", + "created_at": 1689858675, + "id": "e8d93fb9fe8aad86af190d0c9780ca4c4e5019381914b8cb65fc521eba6036d0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b2836ab980831edbf8b7ebe4948f8e65e49a172a8c0bc583264069fb2253787533a64ce294d60b876324d4646788ba8588b8ddcf33f58266f83b6a87e812de35", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "d300a620a29f9e09cca2f8c81270742729ce2593d1cbdcd2578a870c73e1355e", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "It doesn't matter. Download counts are largely inflated, double counted, faked, etc. I can pay to get 1m download in my app. And Web apps are never downloaded. Since lots of people do use PWAs right now, there is no way to know, not even a ballpark number.", + "created_at": 1689854101, + "id": "1a930a4908d732e5b6956aba473cd6dc10344bdc0726ca384daf2c5621b4bd11", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94ae3dfc434fa223c050fde595cd8f0e18943ad51b4c165eda129215adb250d1fbeff28a47b3969f4e04ea6213dfa3cc0d71eb6ba23e5ea4fe23ad7abdafba11", + "tags": [ + [ + "e", + "763c8078c7dd051a4a690001664b8dfe984070dd3efacd78846c4cdd2da3d068", + "", + "root" + ], + [ + "e", + "9dcca85455abf03edd6812c740d708370077922204a74933548b8c49a5a39f47" + ], + [ + "e", + "aa7fc08e620a6c44c409783356d866910df2247e5b0a4e8493f6d1611eb8ed1b", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "8dfbea712e6263402297a88613240948231dec9172bee9afe2c82b5af27a72cb" + ], + [ + "p", + "09ed4d0ef81d77ee634617e36c505bdd9a8a3b3030686dfac5f5e12a08becdab" + ] + ] + }, + { + "content": "No.. not at all. Any small relay operator and even clients are at the merci of the state. ", + "created_at": 1689853928, + "id": "c3675151e21f23455a36ec7611a45fc69e49da3ce6f7fbbb3aee7d696eee4533", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fac6ed996c0b989fc7983340e8a683ee2b61f058bdc75cd88b5fd993af257072a7f6bb39e2ae8faa095f70f7c7ccbe27b40805e322b8dbb85b0d5d3f35e7e213", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "3212c3bb3d6607241aa71eaf28ecc838873fdd83a051497cfae39d2e34fa7d2d", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "09ed4d0ef81d77ee634617e36c505bdd9a8a3b3030686dfac5f5e12a08becdab" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689851999, + "id": "2a493246e80b3763dec1f04042607a3c53ddbaec602de94bcb61bb201a0f6994", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b3bfa5173003ae8b9217f7cd88226738545b22cbed21fe83adb1e28162525e5a162991bfd09bf72c87d317d1fcf98deb0f71159d12bd20b3a0c86233d6999d47", + "tags": [ + [ + "e", + "40e4da7d99af6b74270f6a41403ad4ef6fc221c4d1311c2afb878468b2a31ba1" + ], + [ + "p", + "1b9d72d38b422a09cefed126e88f82361d4b1cc11cd19a2fc04e17b00b0c7d15" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689851984, + "id": "98fa2f068d0993daace0d34916318fc08ba24e0c8d9b61474666104af24b008f", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2a13e46d8488437d06000d1eae343a3cd36725badd4f6028b019f6ed8a32e285f7cb0abab8f04574b54f5bc73743cb5033b08e5815a2216d2973a5fe44091937", + "tags": [ + [ + "e", + "94f3156cb584696e9ed2559b7aebaa57465518b0493408dbf93c8a6b27d6ca04" + ], + [ + "p", + "49e3ca8a4e680a8914b8736f72470c9e91f609905b01650c75d3e189a49f2172" + ] + ] + }, + { + "content": "XZNuUUWuMuNLom0yTq38jzxZaR9WkSCsdQRjt4q7FAU=?iv=F1s/jYGrdc2i+SMPTqBFgQ==", + "created_at": 1689851679, + "id": "ef72e739b14aebfa57b6c5d42bbc65f4a413247809c592f3b52b4f4b16cd2963", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2b3bf56fc86a2d6f4274dc0d2fb46d47935e31bd952ae7753863dcdd0101d35978a027fd4f6da316bcfbf6aebbef323c460b6dcfc2e1e2cf35c8e9c52859dc8", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "E5diFaZYD9BL2VnxsPbFhszv7gPnULv+CqfY1mo7kNo=?iv=XFp9d80vSOYbIrs9e6H8rg==", + "created_at": 1689851672, + "id": "061c473461f3a4c067d8438630874451c43082695756c774b8f2bdfbbc8a94c4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ad8d277e27dde2f1ff3389a47e744625f1e672413e33b8f6fe0b1e7a597e8627d6db6883c1cb23643ebcb40c281e9a57f25296b4455aca24283cc0cf342494e1", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "", + "created_at": 1689821830, + "id": "a2a0b38cca7cd77861264b47e27439180d9e8ec0c07257c824e29b7ae6cd7807", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "30f4a24336bf1f0e422770d4ac83b6fc9a14d8e5ccb925c25802ce8fe50e9684f6d32324d0a5702d0fceec85f1f38e8c96b62d405a2e150d4da572900a86ebc2", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_0e75717e2422fcae74a5728783dcab922aa26dced8dc16ac.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "efd7a96c5095ea679d4019a3eda955c430b021a917a350b022bb3c169e67d499" + ], + [ + "size", + "2720953" + ] + ] + }, + { + "content": "", + "created_at": 1689820944, + "id": "7d4d4011107a1728880da56b0f470a0304cbb05e3ea0cdb810ed3eb861e410f5", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0af6a6f8b8e0933691c1644345a63fc1f91e2696faf592790b295e4b903b4c5e4f54a08a6a2911d6c06960df9a3d8cd36f21998161af3f24a37bc9b214996b89", + "tags": [ + [ + "e", + "2d4d14bb122911d7843a934355475528571f34d478d940db3079d9af58d3c448" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "6e225189e0ca6deaac822e3f48fcc44d6132067bab26e59b1dd8f96e55e30969" + ], + [ + "size", + "45775" + ], + [ + "dim", + "495x669" + ], + [ + "blurhash", + "_7G*WnqG}Z9GO@IUEg.9Os9ZaftQ9tni^:%jM^KQRPNbV@03n3s?$~xCWBR*RfTKob,Xo$r;R+0QwaD$-;ogspW=%yR#Ef%3V@tRe.4oNgkDRijZo2Rj-iS5WaS0obV@oz" + ] + ] + }, + { + "content": "9m1+vEUF9wuhiBexLna0SA5wtRJOE5nP1iu5SpzO7II=?iv=Stum2j5gFWdgSUkATftsMw==", + "created_at": 1689818614, + "id": "e3848ec5ab89b4b4a90f1578e695fd5551f8396291ea4b11dbdf6b6a4c065d42", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "57a0bec436faa420590a50fa870e1de583e2615c6878d882c5bf117e4110e4aea9aa70c0b948110fe4d27123fd40d9ef9e4902b0dbbffcc76fc986c59bdb95de", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "IgoBo0FWxSXhIfE4fpowHM7HKTcC8H+O+xTVsIeLYpA=?iv=7mzzIDMSU4G+9pi4cNUnXg==", + "created_at": 1689818605, + "id": "b08b843073ef035a485162714c3df63f049d831cdf9925683507eb6b4c040ed4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "51db96a77a2a85ea0c6b4d50defa60c569614640c83a4aff933e02d103a88b7b59ad67aed9f23129f4f4c85c3c1fc522172d4ed7842945b739343abb4a3879ef", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817063, + "id": "d7fbbcef0a1ba76404891938c72589557b10f032cf3338e0a518b2285b5bc956", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e91283c33eb2525038cb88bb749cc58de7f7c527a63e336b8236a0ae198c812b69ea81e27f9ba4df4399fa75213b3b07a1b4518dbf1f5bc724c9b03081760b73", + "tags": [ + [ + "e", + "d4960970b0fcce6a8e19e9eaf88519d54df616e3dadcd260fa584cc3181711f9" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817021, + "id": "fb97468b31105ca6af4937048cae0eeaffb01576c79f6b06aed84131b0bdf188", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "485e37b048849165ac76e177d546da3ef5606cb4e4641260ad0660f20e52d0bdd53f7d8b4a87cd9584e1a63ffd0b183557c85258f41f3ca8c28d5ded697b8779", + "tags": [ + [ + "e", + "c87e225890d3d6d429663f4cab9057221088c484805b9ef2da0573aa558b9e91" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817012, + "id": "fa72f4d3ca47d559b0b5e627347aad6630c202fdd4000b0cdd70085f051f820e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9c6a4e063706ec03bbf99b1f839ad95b4b5e98b527bb3102d0b5d83f629ccd89294bda01f8d50c238d2e0e96c5967ce41f649e8a73a4686e349256ca8bc41193", + "tags": [ + [ + "e", + "0190e4e11059dcf124eea8387529429c4fc24b7151b0c098edefd364ffd80274" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817011, + "id": "b083690af84d9c7f1b7eb4da844e9132d0e9e20f94dc17dcf43e17ec1c285470", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e2adf0fc81bc1601da5b28835c946762479a3fda09d7bf3b0888b0e7df8cd3f5d7b731ec4254b588426b9376e67caa777c8e8df5d7cfeac5699af33e57977662", + "tags": [ + [ + "e", + "ceb3b15d4564ba20c8ffc2d7753059028ceb5c3679d2229081c62eee84782a3a" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817009, + "id": "76ba23b7a75d204849ed6104a1c7b35b81b54aefdc90448ae97285d2908bf6b1", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8d8d5a2d851199943a1d3a87e93954ce6d90acd55d294ad76015e2583fabb95d39288953697a9eb184a74447d9bdd38e8a4435c736defd3237578ec8aa522291", + "tags": [ + [ + "e", + "9c289fe421d434b5f8800fdb6a5f064412b809218d11571beec7fc7fa5bb8877" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "Let's go! Nostr Calendars are up. 🚀\n\nnostr:nevent1qqsgyhxkj9etqared6eg2c9d8zjndnpr8qyc37l9cuelk02zyccn2sgppemhxue69uhkummn9ekx7mp0qgsf03c2gsmx5ef4c9zmxvlew04gdh7u94afnknp33qvv3c94kvwxgsrqsqqqqqpp5cakn", + "created_at": 1689816781, + "id": "ac64c67c3dc8b6d43a7c15b94c0d5c382e235f65cae783ed13abe4bff6084163", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6d2c7e78d751c1f175ca559bea9da61b9c8b3dbdb916c2a15754b2d510642a2a27c176aea9e94f985c9a10d7e47b49443a164365fe9757a7ac2456ecd308587f", + "tags": [ + [ + "e", + "825cd69172b074796eb28560ad38a536cc23380988fbe5c733fb3d4226313541", + "", + "mention" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", + "", + "mention" + ] + ] + }, + { + "content": "How do we get subtitles in multiple languages for Nostr videos? ", + "created_at": 1689801767, + "id": "276064d45865bfb223b1c7156ce555ee291c51b82e3096711c7fc9048bef014e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "93dbfa2a47499ed5d9567fce061399a636af7ec8fe2b3d9e02d6db67dd2024fd0db7ea0c696f925633c9b929ca0f1f83b1617372af182f7cf2a9d26ccc4c8e2f", + "tags": [] + }, + { + "content": "AxnOIG1qFS+PKCRIDl/vOfhNlZpMWWxgFCfMAmXR5VR0WyuHgq51owAycbQMDTneEr1fdCQvUvij0VzAhtsWMQ==?iv=fkuPtAZC0sB3f5z95Q5OOA==", + "created_at": 1689796668, + "id": "fe3050a24ef6f71bc7c6f300161e963bd93d8e35b46d6896a679a24bc234def6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "63e0e21357ccd969d00d8b8e3391212a21b99d37eaee66086bce4a75a580f1edb403010a9554e93735eb496199fc1d9dad8a5fb8c6a562ce15b081d8c8400283", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "seyXteTXE7xcuLVoJFxOxrioedOcMZZA2kHO7D5czLW7qIG9djYZmotp8/4AKUvW0ML9/Zg+cMB/l98T1+mFgS+j0LG0v5MYBH1EzQDZpyLlPIZSu+U6FjVX+mQ5xP/1VYbw6bYeN724EJE8A5uR/CeR5ba+7mU/lESmMSQbcG+81nVbauESmwN0/CtfameLcvWQksSclKUVUoK1T8xPdg==?iv=OhXj3Pdt/MWXB2vLQGKQ1A==", + "created_at": 1689796648, + "id": "bd4a822b08a4c60fff4e9643bb524bc10a15ef413f05b54e6d2c06c8f93d175d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "330156d1bf8dcbbfde9a9690b9a2c470476599275a949d1a57b07904e1cb34d6832a4b0f48eae64bf9d990d2a75ec371057ac4e213780874288319173b9c021a", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "dOTrkPCovcomFiLrZaT5PHkdhIjUBA19y1/AI7i05h30rg3KFHNcAWAoKGIDXdVYd9zwp4614oaB5OwU8v50Vg==?iv=zYLVydZOtyn4esxHM2POUw==", + "created_at": 1689796524, + "id": "8a72bd5d8d93e511bc4b90f9c06db9a835533a41244b7ab8dc603dd8204c4848", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2e8930dcdacd7932c447652f8da5d2638fa64bcff4e4c04d0806472d7ac3b378ce8ed41dc7b5c32000d4117d217e6837438557964c24d55659fb9c415b0c6119", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "afy+lH9UsXeMDOXOIDcGWIbqGluAAOC9+Ou+hfz8YtJozeATNVXONKW9vywhW4uf/ug0daIa14ua+Htjru3k/4ZsSt4Lf5s3HL89JVvY8dNM4RQWY3ujCojCibipkHT3?iv=5ZpYFy/C0/lkBA0jUDitWw==", + "created_at": 1689796215, + "id": "1d4b12c76b6b59161c8167e4de7ffb9e6f8bda61abcc24f1352e37acfac5302a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6be01d493cb564657b75b4a3f46a2e8c2f66c08e3c16266b4a39e2e377632f007e2c878971f01ffa1755b744fc71054a5ca7285993a3d6d5b9117f47d5fe18c1", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "hTpYWaI0Y2FGBRnAPWXblTqdiCrzqrlFlLfqCmDwjBYpa63jiCE2pS7bVcqSWMitjWVIyKhCN9fgVBYBFPvefamqJT9z0IZzKebvvs4BqB7Nd+Iz2nokOo4jOPeJqIu+?iv=OqPz5bYdh0+b4haP2XQKdA==", + "created_at": 1689796190, + "id": "3e2dbe64f489fb36298b6d9bebfcc6aeb0ed813a9743d64393eeaeede12d3bbf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3cff6a0cfcac3221b028cff5dac1c5ff6db6237680c0c6b30b73ef1196300709671f1865c50e0f7146d69e9a7fedd320959ed777d66fb04776ca4be32c5faea8", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "fK7K+JZQjd+pGDqqY7iITMHhXNeqtbYM4yoRniviW3YAgOH3KW5htMwL4iE7BP22kbRCGD+KAugKCQS82eaADG0H4UzcQwRzDkcWfKkOBJZN4hyc+k2h/O73SCT2hjj3?iv=41MU3ZirxPhr7+5FRKko3g==", + "created_at": 1689796155, + "id": "a9cb0ef636892cf0a4c22d29da2b418d2668b94c75966550f8bca4be4b54f4f0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "40da153bc8d11be451edb175ec9e5d896fd6415f1b288ddc83f84775bc990c930faf6061ed01152a8b69f0858a7f10025155e707e426bab08a1e57d13fef100d", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "6XrKnpAX88V6Htj3dRFhYJIrpvoQygXbFRHQ7U6Lvulf2w+Wi8ObX0duVSPhbTdx?iv=1pKalTwNvr/773dNsYxrsQ==", + "created_at": 1689795660, + "id": "c7423761b1b250f98795e26c52aaff8fe85bb28c23ed30bd206bfc4d84ce9580", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26bab236f84777a5f7478744aa689795f1dcdff523f2f4e3f801d6a73f6b221f5d1f55e664ee4b9880566eedc5acdc3ff8fd39e85aae27b04a2f4022c0a71a65", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "d+fEU20cosND2HbmwFIJauroNtSmj+0vjS7JFTuSpVRZVlqsBYAudVHjIoBUoVhoZ6nRf1mYcf/DxCVwn08ZMvJAXqVgT5WPhylmKYm+6Y0=?iv=eJW6o5wA77YxIOLVyr08ow==", + "created_at": 1689795615, + "id": "f0d66292c0478c5876b12b330d5f8bb87ccce54249203d87440110c1e1e3f358", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f5e9328dd7f581af984fb02f41b62bfd0b14961586b612f668a8f9a1a237e1d47a5f7e7ecef4cfe6988c18b8ac7c58701e858c27d747510e3dccaa16a8235e99", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "jYW8dxVeSx76co1IsQTpqw==?iv=9TpMslZaeGk/Qc0kn6d0Lg==", + "created_at": 1689795614, + "id": "f4232c1aef99cf09ddf794392f4c37c4eca0d9d85bb2d1ae3ddf72da7632bc0b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bf5d8458087408d3a652d26c162fc405d31743a5f3de2c12fbca593ba3b3662e9052c2b3d7c9086dd79e4387a36762b55873544c4c762f104e3727d734365c83", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "wJqpUSs9Tqc+LJMiwF2Tl1d11sl7y85iM4qutodWeBNn3UMTpyA731WmTMxtwo59HwgDx7Y2wi/B5oLY7oBZUA==?iv=0stWUqHs9xD34aJcRP0Z9A==", + "created_at": 1689795571, + "id": "df846e0e2628a0102dc5b5839e151dbd483cf5e6ac29b7c5212a20a058a9aa33", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b65d1f339c88793dbfc0a0e46b91e53cd506732a6ece9d585041d8d1e0288b9cfd8b81d02a0efe4e6835d2b51258b2a2291af42f311dd1571977bbb22d1188e7", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "ol3R5HKU2HN5/6TlsnqPEw==?iv=0y+mRP8iHhHiIHlIb/Njqg==", + "created_at": 1689795555, + "id": "de8f70191affee06d10e83abf8a8000d5af3b6726f636dcb204631672551d206", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c98a7d07063f1f1f69a93838348123d6a6dd321cb8f720d876958e002e413e0dc95b9716f36ce1dfcfb51240c8ce7e0d8e835fe2e3fb209f3940a4fde582e0f3", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "+", + "created_at": 1689793683, + "id": "ba35efe39f4d25ef3f79f578c618456a04d27115333a8ad4b5558a70b6ba20d0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "193233755ae8283a7e0bbafcea1656d21a68f90912a4e58afa491a11590d262ee9f7cc57ad625f8b7a5969d6266968783037282c7171413cfdb3308d1c739da4", + "tags": [ + [ + "e", + "ca1d6d0979a9892cdc6bb6c9f6f0ad752abf91a59e2ab047c8dc40ba864d94fa" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ] + ] + }, + { + "content": "WW1FqQbpCQpDxsJeX/w3qSMNW6OMRN1FoWU6pE0fxVl2dsisbuts+pZplL8S/0AcQ+WrBcGIxd5O8w5ky8Wz6zWyge0VQ63fr07NWuitHr4=?iv=ZehOo9LmLkLSt42WdXYnFg==", + "created_at": 1689793047, + "id": "fde523a39bf03736ae928db7d709e6d50193e7a6c20747cc529656db012a6f6b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4a72e902dede3d8ebbcf1f662f8194573d0bd2cc26a66d66ff11a67d11471d24444329c4a3f75dadd7ffc51740394ba83f54ff6337882b5b7ee332e65f468d41", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "11d5n+hT39BBDvhG6kwgs3QtITRCilwvu2t1EyfnbW9zQDvhGImaVwcNSBJogPUWND81PxxSevsnRunX9IlpkE+NlxFvoRJETnjB7K7SYJgEm1AJS0gvOOZoJay6YgV+?iv=5O4lPYjvIlFJhZtbcOqBNg==", + "created_at": 1689793029, + "id": "617562fd855d45c2f4ee08f87c4f8cce815b4d2e94a51cf6c6d25b50789c6cb9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "504ac9d67669273fbcc6d92535c7a01cd50915393baa190cac7d913b50fcd3606e2945b67370e37366327b70ed24fe2f45bd9be29c26342fd5fd9f5ad1db5c40", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "+", + "created_at": 1689792227, + "id": "4afd3e5db2ec5e356e2b734978f0c142d46dd547e2a74bc23d3a0a01c15273f4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1439d24fa6b4b40ad66ef01799d7196fdf70f9fd7f8cdf8d114e99235af0a0d994c28bde2f665dceda0cc31694ded3534e1e8d55e316dd6605c84eddae875eed", + "tags": [ + [ + "e", + "794fb8ee8d06f5961c40a11f6cbda46819ef87d6907c0d9fb05038ca13aa7a4e" + ], + [ + "p", + "876e724d762be346b89f47070758da679cdb969a2a17d3c5df93da832bb71acd" + ] + ] + }, + { + "content": "juGxq6AXk3XCL4JEp5A+BJ4A7hwL1n5AZjD55zC7+EI3PjXiYZcGDta1c0CUH4qVcxmTLSqUFOcs/Dwtd5Qea/hemj9/nIu3GJYMh6Ch0+SPDo0sfXaVecSCAaAmcObl?iv=l+1TL3C3hwFh+Izzz9MVVA==", + "created_at": 1689792194, + "id": "5425f9eeab69bedf79ca59cd730aae076b04b33cabf709e78560929680164294", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ba56b1630ad6dc18ff9d3169a5947011623281fd73dac87272bb6cb0b2cf316b0108171689728d49680f1b6da47169ff0cef2aa420d86326da2ed11ea7ec0394", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "For sure.", + "created_at": 1689792082, + "id": "ad71d5164ffd119c21f1386f5ebaa23faf93b0860bb2a8af6d2af63b5143a43f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e466cd8f8c06b0acbc66bd4f59cb0852ab4df16b8d70e63de280b39750c44a58f49972b06563fe9b4f74760a3a8be835111a12e874015b8b32016fa78d22ff90", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "4c37c971eb747bb8952650778094269d48047e470b392129967e412ac80fb604", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "876e724d762be346b89f47070758da679cdb969a2a17d3c5df93da832bb71acd" + ] + ] + }, + { + "content": "peJPIP0HJSqAp5QnclIiewPTW6D8ABE5XFjsRLNExq1+m0ofqGlK+p6eUYTtXEkVY708OLjsn4n1BwzZ3VvBrRhAFTW1qiBgot9Mh3g14bwzwe0qTVIMFJX0Zh/Xt0eu?iv=nMfwVItwt7/FJ8wPd/Eqhw==", + "created_at": 1689792036, + "id": "2a129c01330a59c5e517af6fe0fc3e5c2c2ca1514a19df5351d07502efc220f8", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "23a492d737219cacf65b085a17a2db72ddfc3410616b297b59eaaee9bd10522754832cbbf35c7e422434300b50909d2ce7ad2e3a95017686621d2126634b199a", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "7yKCNmFIkil1iodXHUNPGg==?iv=QhwQSNtrZXzi4jw/06XOaQ==", + "created_at": 1689791999, + "id": "f7108bc890bdd5bd37ae38f7d11a02a8da5f02042b0b77423a74658fcddc9934", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e20c98f26702482353a7982cf48665a7fe9f6bacfe05af338339a70dea82d2575235ddb1b6ddcd752579d6a83171c47e0df5fa282992c4aac099730900b91d64", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "40VWtBtcLP3YFFX1QTw709kpQB0pTfY0UleW7S2iOJU=?iv=m7BNPpW5hyYeV/BuNR19AQ==", + "created_at": 1689791992, + "id": "19d8ee5db99a5631f71d4f4711bae78b5cc93c0c3552ab78fdff0df1c674979a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f416f0a88ea4de2b2487fb5b07b353516ad5ca4c17bcacf2315d7ef612b312281f1e087a14ebade82e91f6cd4c27395bcc88af61f51cbfeac4c2ff2c6d7e6378", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "5p2yM2M5i3h7H0nuTsP5RrsvWmKbUpucyHzxMZJ1wsf//jObdREH5wLvqPQ2w6i2WKD/T/808UTk+rb8ZPzlqLNUgYBMlD8bjz1IEVY7Hn8=?iv=3749TEgAq+mmP68ICxcakA==", + "created_at": 1689791923, + "id": "341bc18bbcb90e8dd6736376d179a137e42ab2d33b9a9d9af60f82b2069be88c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c89bfc23afd50a4b669c5000a244bff10b114991983627c3da64b03de38f220792d14c75528cfd89b2ecefdb8093086d2ffa3b2b109ddb2a4be0b6b4dfe53bcc", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "n/Zh7u6uaozj+2mCIYhXuT2Oqn7uyYwYkqaBWPBOwsgO1dSqxHPLWwR5ZoiCPij9EWhtSI2BHEEy5OeYxqlLr51Cli4gzNpq03zxbEb+vulcrC0mXRBJXd3SIo/Cu/um?iv=putWwaSfX+fajySLJXgBeA==", + "created_at": 1689791780, + "id": "18d4f7440a02c83680b2a5744c0359fc088a80b6ca0dfe922c3e13103746926c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8dcad60b4faa5727302a3e3f2fdca3ecbddb85d3e87724d0e8d7053f647960d5174482a22957776ef8f84a33b9af5d19c45971b5cbb43428ed754c3839c25459", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "T7+/09ia5+pzY0WDwzhGhQ==?iv=iAV94k1Vl1X1OYpekGbgLg==", + "created_at": 1689791735, + "id": "a044df6a86bb24b2468609f74a3fb639ad599d6bf14293efd6b257148d1df8c0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ecfcb8838d80dfb943ee0d8fee094402484057ce6a85d35754a818707bf04becd299d41cc89519c378e545a461263f44d3e6adb6c201c2fdbae5cb7cb6c627a8", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "What's wrong with the image? ", + "created_at": 1689791634, + "id": "1a32a1345dab56de57b1d0e5e3e0f82197e782541cabd4d4ecd1b1ce55d495c8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0e893b5c1c6adadc6d61868ea542d8320924234f8e6a8df75eb845e06abbbd386f2cc8717692bf18927bd52b58f936ea5bf0fcaf66c03b1bc7219c33f1508620", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "We haven't unlocked the power of communities yet. Amethyst will have a few UI changes to make the feeling of joining a community a little bit better. ", + "created_at": 1689791536, + "id": "5f74955acd83df8b9ef6031f19956685172beca93a4b03dfb56954a66782e9e8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9d277d454cefe5e6313d8c10820321ef2a6d91ff2ff305f3ed7639869321ca78d3d7aff5205f5d02b2884499e16171d29d1959fca185e35f73f34de79f6966b7", + "tags": [ + [ + "e", + "eb612971888141cd5abeaaab8b7472028574f26155074f57598ca9eb237080af", + "", + "root" + ], + [ + "e", + "39c1e289861226f800d915a60484b498a531d4cade8cb3caed3dafcdb4ee891a", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0" + ], + [ + "p", + "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "D2c4aPXsJCcwJ9z6NGsQALQ/i1+bbhIHgOsdloA7mMyLqxWkCXNtiSa0wvJN8Bi9fYhjlOa1zT1tj8GWUYVbkH7gsgLnboTwCLr48JjKNZRHY2ske/sFpF+V3OMYCtkDRRh74p6yKv5x4b3KSvAdZmuGApkP37wf4tA9QSYj7Fg=?iv=E+UwaN6Acc5VuYlhJo2wrA==", + "created_at": 1689791426, + "id": "4c65727b37d96f7937024be773f048cf291569682a6ec93d1f2677eb872900eb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "17494231e1e2ac82f6f5c0ed39bfda56e76687ec29fb45ca6eb56c2621fea1f1612b47247c0a1d9f52fa2f2502c838ea5401cbfa414adcabbafad38be49c17d3", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "hturKi69ZiXh/zuDehkuYisaZulgLhQweuejTUC/NgkSY726pRvx2eMaeeMp/GyXLIiXJmHBbGHN3WcriT1eWg==?iv=145wzmomdE0AgjYK90KDHA==", + "created_at": 1689791397, + "id": "4145c43d088936140a0d03f78fe65d075c09560118a1d10eb9a61f754ac0c3ba", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "28dd7c2ddcf93091f5e24afea68410f5b77c505a271e6eeb17396aa8e7acd47da4ae65f2c7f8fd7b416be2d72955e81cddd884b0e6b2d041cd269c17158907de", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "UGNJsE4FdyJR6HsTJKRZiZ2SjVC+Rur8wg++uOTi8iLdnDTdVhv4hEA41MbHYCjZnCZQO50fFsBICzFnziC1DA==?iv=GpYALl36b8bHbqAL14NZWg==", + "created_at": 1689789316, + "id": "881bccdc3e7ea2e211282406d8a24f9d8b13730be7f7e4aa20cb2a87c3a813ef", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c36ea50e7ad612a1e377851882b2db9f878d591115c769f83bd59344d24c45f4639a9e7b7e281bf2443b57612df932afc3f53f567218683c3b28077f2359c37c", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Of course. But the question is how are they technically going to do it. There are many possibilities.", + "created_at": 1689788786, + "id": "bf39c47e2e5af5751dd5265744185df3d8205ab76a467331ef0bb97d288224af", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c035c989ecb3a567bd0b7d09339276f93d2973d99bed28ec7aaf8a653a27a3f80674804abe0b2eca980060709147431ca910cb7da795648becf72014be0cef1f", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "69601e47112484a0fc1f173d0bceef8ee33ed56c5831a3d30d12693dda4fdf28", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "206e580157eae3395cdf954ff30501e16902074656a4b9493f36f896167496b2" + ] + ] + }, + { + "content": "fRxYgGAPTXND3ruRL3wx/w==?iv=jsuZZX/FaK2XciQ2Wir7yg==", + "created_at": 1689788740, + "id": "81c6f03692855f0e36a4b80d864aaacfecb549d39e390a5f44813e6df5c9f8f4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ea0b345f1d05f62314c6d68a2938b888ccb1a3c0ebebe8848b3df72dc130711042f0144d9e3041c3521bd61ba2845a56bfe3bf745dcb8ebd9e01bd1e77710e53", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "LKXDKYSKDCjyq+6MkQgfGo5esbtZDs6yNBoY2WOiFHxBqa7Jywg556XC4eD/xaM1xh1S2Bd3eHRm3UT9sYJdk1ubyxDdiuKXSEPyhGuLBri76at3UgWfSs0wQknFS+cgnX8V9LhGGGpzMb+8kNZR36k3AEZfcjrbOvOVp0BLKIg=?iv=Glj5CCIi8+1zDbxw+2SeMg==", + "created_at": 1689788734, + "id": "d7f01cdc157a28c915e6077a14820cfb6535321e17d3199420f05d875353d1ae", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b7036aa95b6e68d34089897e1f8edf6263ef52848396894b2b619092fdb2f59ac13f68fb5666a56a65deb04409de0e4fc1e94afe0c3c09098e8b2edd3e04ad42", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "YcjKnGE+tJH/cds7X/vOroR6NVYYpRh0otIc/ZgeZvA=?iv=xsJsUMr3It1EXfc5ZRHPLA==", + "created_at": 1689788612, + "id": "18a06699806b3202f7eba5736d89f8d30302539f7e306bfb7623c1a21c600092", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c8e3241e90b60fac97c4457589816a56b22995be2ec2e2317d51e1d66fb897efb387b6cbeacbf261dda0804c9283211fbf1af1ac5bc9e9c5fdc78a2b2060014b", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "q/kqbaK7wnyDvJ4ORsOwfg==?iv=6rToBnTYx2KUnRTxPEHthA==", + "created_at": 1689788601, + "id": "ae37ff6bf243668b58d3b12a11123912a2e6f71ed1e60206d4e2234df6b9b6a3", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "eb5593f481e1d67d9e6faf18c17b59e5ec06dd8e6c5026b4d076cb3320e1c3ef8115de470005e7afdaacf1e0f1cebecfe8e9d5fdd73510b6fcbb71aad51b8494", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "No yet. ", + "created_at": 1689780075, + "id": "8df02766da387d6dc3bfc283c6b9c2e99650006fb5b1c88884b8d5014c25854d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1040be4fa70fb028dfb8bd622ef37407b7246db688e0e6ad942d8cf3cdba86b635a7f5f1a33feb4046ab77fd1f52ea28ffe98517070b942b1273e670a0f8603b", + "tags": [ + [ + "e", + "e5f11e003dfb5ed948bdd67525adef44c6534df4b529ea881337307810c3e6a8", + "", + "root" + ], + [ + "e", + "45e52aac94688eadc432a5ba9e81a213309c289c86618ce660617239cb7b7d9b", + "wss://nos.lol/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Yep :)", + "created_at": 1689779927, + "id": "de53bc355935d7f20927b9cd580f8351263a0e78664678898267a70769e33573", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a45551d3f326875ccf410c546d770fd1b87af47022ed6125cf0c4b58cb66251efd0089daab8e19563c25c44690b314c227724ff9bb9fcc39c16727152cbbd119", + "tags": [ + [ + "e", + "e5f11e003dfb5ed948bdd67525adef44c6534df4b529ea881337307810c3e6a8", + "", + "root" + ], + [ + "e", + "6fc9fe5427f818be975d11a5647aeee0d669bd417a5422e669a679114313b42a", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Sorry nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 \n\nhttps://cdn.nostr.build/i/e1c9f560acf3e3fbc147d83792313bca299010e5a259b00a8e7ee9724eaa6946.jpg", + "created_at": 1689779837, + "id": "e5f11e003dfb5ed948bdd67525adef44c6534df4b529ea881337307810c3e6a8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "64516164405895e616f16240ee6e0b2aafeea2e78f93d19dd68c22b17e8dd6723adff8508edd20e23c3e9fcabc2f04c86d2fc35d50918169c9dd207aa9f4c873", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "I am waiting for them to start tagging content I am required by law to filter out... ", + "created_at": 1689779811, + "id": "385078f5c6f4d466900e5267755a1a2db58ff0cb9519cf695c5a69391eba29cf", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f1890c97830f613abea4ab6f6958bab0d2afeb0bfc91261f0a9c517aa85c8007094bd6beaab44dbb85084952fff2c0207bb58e509176aa15c4cc5a99475410ea", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "e19338dadac50e080bccc1457d5923aff9aaf88012401e9bc353c4c4b3c28839", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c43bbb58e2e6bc2f9455758257f6ba5329107bd4e8274068c2936c69d9980b7d" + ] + ] + }, + { + "content": "We don't know what's going to happen when the state starts using Nostr.\n\nBut they will. ", + "created_at": 1689779396, + "id": "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9a4740dbfb4894634fc40d6c34151a4e5c2771daa9af119bac96e36fcf0b65a938ea1a8224ddbfbc70034e81dc0f9ab86033c7425488359392d604e1f8257765", + "tags": [] + }, + { + "content": "We are using kind 30,000 mostly because I think it is a good idea to create a feed of the muted folks. Since Amethyst shows all kind 30,000 as feeds, it's an easy trick to show the \"blocked\" feed as well. \n\nBut we can go either way. ", + "created_at": 1689779276, + "id": "24e3fab99020ab31228b2ed48eb63fa7800be963d5503c5c2598ee61990ebeba", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fdf665f4df27fcb7d407e43b888b9f25d96ad77fc95c9114ee3f50b5477e6cc8303f3966a031cff71ca0a9ab492152ebbbfcde597b62c22611d77a3628167e13", + "tags": [ + [ + "e", + "bab81750570876585535c8ac4a4d5f983ea4cb0675c94882af25b256d60f5a7e", + "", + "root" + ], + [ + "e", + "b9d1689cb4c5c3220fc2d8a3ef0731f6bdb2fb7cb2593a085c57488616932dfa", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e" + ] + ] + }, + { + "content": "Not only relays will have to deal with this but clients as well. Agencies can tag Nostr content and write in law that clients operating in the jurisdiction must filter that content out. And agencies can easily enforce this via the PlayStore and the F-Droid store. \n\nWe don't know what's going to happen when the state starts using Nostr. ", + "created_at": 1689779139, + "id": "6d592b4cf06a528138af967f69e425c4175c7dd149776ead72718ca06c7c10a0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "aa5d16975ac357fde1c7814e73ebfef5c326f98cf622ee5150c530c476f545d2db97e94a0278e1cbce7fef4e4546379083b71bf277351df57aea15a54e3e4fc8", + "tags": [ + [ + "e", + "810412a20e676fac2119d0401830601ad2b9bf640d39c5709ae047570476e06f", + "", + "reply" + ], + [ + "p", + "8dfbea712e6263402297a88613240948231dec9172bee9afe2c82b5af27a72cb" + ] + ] + }, + { + "content": "+", + "created_at": 1689778259, + "id": "218d0bcda05431434e82da0689cebd0f29edd9880dba67bed6dbd7106aa21402", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ba1c0b1bc595450196b982d8fde84751ce16b4e2da577a9ecb44c16f1c8fe0c2716edec606ff3cefc0569836f9507ca34c6a9411d9a463004fe5521255ba54d6", + "tags": [ + [ + "e", + "5a7eac5ce4ec6ffad268fb6bbd5450e2361a1ee20efc2aba3f94f41c49ac6d64" + ], + [ + "p", + "564e2192d2c4df33224c15d787cd0172fc8c7e6a7afd67bcc3f8e3447d765981" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689773079, + "id": "f2bae9c9b2c5b1db90828c1cbdd5892485a1a239303a7e7425922ec2757705c4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a64442be4bd5d5d7248f120a966e5d995f8958cad3242f05b145e08ca859c2602d88508f9d8bedab29ad0027349fe5cbfd8e12832208f7ccbb7f0f4419197a2d", + "tags": [ + [ + "e", + "ceade31f339b0d1ef64d951e49153c08d33b995ea73f419cb9d65501438857f9" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "+", + "created_at": 1689770561, + "id": "bbf5451dfa185bec87f2e274e451792d8d203d4f257649c7a54b699d159aebce", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2a74f27d5914cef15399d1a4b52f17c1825755b8a7b70e6d05dce67db7696272387dabc4b6f27cf76c62708b0ea167379ee17db00451a91020cc7a734c967df2", + "tags": [ + [ + "e", + "9fc46ca0fbdee0f4436f1d0b48d1303ea6b10bc6c5d47fad13bd96a6a1968ba6" + ], + [ + "p", + "7f5c2b4e48a0e9feca63a46b13cdb82489f4020398d60a2070a968caa818d75d" + ] + ] + }, + { + "content": "Qju4ICVAki2Ead6iINTA5GaOnJSwZy00Gilvyhm/LmjdnNEcQdQPXifheYc/1eMchN6VUxJDabiafPe1OjX+RD2iciBUlABOgpy+y+weUd8=?iv=BaIQNwhp6g4PiJu+MWdiuQ==", + "created_at": 1689770428, + "id": "389386ae9cbc4b1d5e4fefa464136b4f5eff12ad0e030b15076976a08c9b40c6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1e14b2d38f73f965f987c05c909eafefd97bdb29c04b3cc6d78aad708c0106559282286f9073a45f6f7d4f840b94249de21d5a6e964708cf0fb800401932068e", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "D+mvo+RodsQgWegn9RjuWHyawp7sHFBpLgHW5+2yQLw84TKrY5svyR2NfEUmJcl3?iv=BkB0cjrode2kmi5foZf/qA==", + "created_at": 1689770406, + "id": "bef8d4b6c114ca46c03093577e72ae1aea26b1102242ed1c9d0220c78f31d707", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "87cb9e2c165d9bb931c73a6c8899363ffa975f423989363580090919219406a13c5be2932e0df676931583d8a2be86def4b56bc0f8021543dd1aa584efda08ad", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Sd4jDtAA1cp5RPs3vlXKgKEcnnwyBCX/6gAI8iFUj+vlFhblJc7UCQ0E+FtgPeeK8GYB1lrmll9Z+Xcy8S8yAQ==?iv=YJ/u5liYySfupmcXDjGu/w==", + "created_at": 1689770387, + "id": "f75b58ebfaca4a9697397fe8d2b6f848d3d01908bbfaad613e37b995e0fe0629", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "59bace0ecd181d2a29c13eee6cf238ac70a22a45b6e0ef6d9df39a46a7f5f6243c26b223fc085027cf326fdaf1e8f63028071560abd48fd24f2d912be12bc0a2", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "V+RxWXXhMHFF9yQQmbWK6Q==?iv=QrXWO3cMXO6+rntdblFulw==", + "created_at": 1689770372, + "id": "331ec02999a4ae6aaf33eeb738dc4c9e8535f077b9573f92c78aa75b1c0caa00", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "edc43ce3d7ddcbcb5b72e9c8e869250ccebd76244cfd883f7a4ba0833c7a2d19a0052a6aafdfd0cab1ed7a3d31a58c3b976f5a16c3499e0ddd769849d027699e", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "I will repeat myself: It is utterly embarrassing that YouTube creators have more monetization tools for their creations than developers. \n\nThis starts to fix it: \n\nnostr:nevent1qqsdcan9s3j2pvhgugnfz5vkfsv6ehumxeqzl6lhgkx7vh3h6hg7hegpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhszrnhwden5te0dehhxtnvdakz7kzntv4", + "created_at": 1689770176, + "id": "2526d5ce37b700d416fea47721d3260a605b3a3edbb916adddd8f547cccdb6cc", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f5d7339fa2d1be538e33d67e7cb7c267c54eb79a555719f245eae431471e50ead7735892395bf1bfc876d37c8849b0ff006188e22ceaec322d867f960699cd48", + "tags": [] + }, + { + "content": "An external service for larger purchases is great. But I think it is extremely important to also be able to one-click buy things from any Nostr interface without navigating away. I have explored a few options with Amethyst but it's not good yet. ", + "created_at": 1689769859, + "id": "f0bf7d248e55683e008eea97ad017f87b49e724539148bdfa937cb57caa588a4", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffddf687fc80146e53901dc55547ef00dc010453efa337eaac7c84768daab445bd0b4d1187ca97b35e6d47c7440dc9a995fac7226bf79f60b14cc8784dfc8279", + "tags": [ + [ + "e", + "1a1be57cf73c27aefab4f0b733fe8ba433dbccc7ba2dc8c53c9764d077f79add", + "", + "root" + ], + [ + "e", + "3b24ba5bf6026fa7b210db0895df602084817c5521bccccf80a7bbf64607cff9", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "Strange. Isn't it a relay issue? I noticed that the post is only in 5 relays. ", + "created_at": 1689768641, + "id": "aa82841f3da7f04319e9b5b0ef0d6a934da15fd575ba9ba2055bc7e542fb005a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6eece2f872bbfb9459c7bb3e6d8b73328d3d40b300b8ec31c47165dabf69d30a039103f32c7acd4620e03a1f2a3a4f764bf6c8687524ab117e677c7c74eb0107", + "tags": [ + [ + "e", + "bbcc50d1d1487dca1ae3791ac651c709d5a0b91b139ed5a54f4a1aa2f077c465", + "", + "root" + ], + [ + "e", + "4289c7852d03444b1075f3ca4dd5cf79ad324ec0cd464144470c9db796918604" + ], + [ + "e", + "3c4f2cd1fd77b5f00b946b58a71fc1bc0dd3ba74eb70d6e89d1a1dc025036b44", + "", + "reply" + ], + [ + "p", + "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ], + [ + "p", + "564e2192d2c4df33224c15d787cd0172fc8c7e6a7afd67bcc3f8e3447d765981" + ] + ] + }, + { + "content": "LnYgC0RLdzVdatNVXo/iD6UN+dP4xDT/9sw2E95T958=?iv=rikKQozxy/jFNMVIDjqTlg==", + "created_at": 1689768433, + "id": "6e22cc856c25dc8154959147f4c71ac9ad687385135390bba42df621b0daa1fb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ced9ab7e521432aa54d433b32869c21b402d9fb7e009eee6e70e6b0b2100edc4d78a3571dc79279c05ad11214e1d2429e5c764ac366b932bde2493beb71deee1", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "84qIV+GtKNOE6pCikWXafQ==?iv=IH/gXx+bmRGJz6SoM5pkVA==", + "created_at": 1689768426, + "id": "20ccf572dc67d15526c6a2d39115769a37d3a8dfabab54dec30307e2e137d634", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "34409de0e4e1c707d11d352bdbbbfd45eb5be266302ee5b701cd6f3df97ebf88958b011f41f7e1814c70d7716960d9ff611712c1f261c450ea46d084af43ae76", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "5w39JZPqwCg2wYp3fZi5DxJOfGS3ILijznewkli0xa8=?iv=3rXD8vh3AtHVd0RbFVlyRA==", + "created_at": 1689768408, + "id": "d67ff8794bf1e427c29c978a277e547bb8783799d8ba558a789636639fa64d5d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "cf6ef7ca24fcceeca9095de854e0046098f508d5c4e13e05f283f98ef8b915da1300f6ca3e5929025151b128243980f7c7ed7a3ae958c8c3badb287fba2c0ec9", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "Block the app? I am not sure what you mean", + "created_at": 1689766554, + "id": "95709faa74b8a044c55155344768f868035f4a2efaba9fdcafd91620a83c7ad7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d9348bc5a286dfd6bcabf2e778f85b9aa85a2ce53892951232900dfa964734e9ba4ee6177b4def01c4b8d29478fa228ecee19f474ddbeacee5b5ec80c82787e5", + "tags": [ + [ + "e", + "ab53ded32616980073fc2c9c6ee2e8056ecc5bbd6d46c10a925fd96077a0a574", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b75b9a3131f4263add94ba20beb352a11032684f2dac07a7e1af827c6f3c1505" + ] + ] + }, + { + "content": "Yep, since February.", + "created_at": 1689766494, + "id": "67f9aefdac7e17525dcb9b9a8a4b62e8df734765c71df1b5a653b7e4e871b246", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "02d792d0446f213e8d0624ae50d376552baaeea7be6714aa186b5e2299546cd3e6227ec9e89692963e0ba50b680f7f2e3aa7a663b69e53ec48f96e9977ac7859", + "tags": [ + [ + "e", + "5fbedd48a63ed0a4c8c411b7c4eff4cca22b4430d0c2eabcfa1fdbef25dea8c5", + "", + "root" + ], + [ + "e", + "1b3898f53abd0fa5ad782f8d6175a441e14128d54d65a8ef008468cc5f9123a1", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "331dcd701d4785c9ed7f2cbc0d37e72448c810186c9066f16b9c6c69d454df6c" + ] + ] + }, + { + "content": "Over 100k, but downloads is not a good metric to follow. Daily active users from Nostr.band is way better. ", + "created_at": 1689766394, + "id": "9dcca85455abf03edd6812c740d708370077922204a74933548b8c49a5a39f47", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4e22986a80e3fe0d76f06e70f19be3a1e26b3ad5bb918a750744033c6d7add144ab08532bec52c5edd8f010cc9c114b35ed973ca7f98daf5337c83e8a70c8fa4", + "tags": [ + [ + "e", + "763c8078c7dd051a4a690001664b8dfe984070dd3efacd78846c4cdd2da3d068", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "8dfbea712e6263402297a88613240948231dec9172bee9afe2c82b5af27a72cb" + ], + [ + "r", + "Nostr.band" + ] + ] + }, + { + "content": "What do you mean? I see this note on amethyst just fine. You don't see it? ", + "created_at": 1689765862, + "id": "4289c7852d03444b1075f3ca4dd5cf79ad324ec0cd464144470c9db796918604", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "605e4e0f4389037a02f6dbf16d72b9d616637d5ccf360c1fb40721febe76ac367839816263c20c99c3c4c381c2cb36789784888b7ee1ef780fcbac2e0eda9964", + "tags": [ + [ + "e", + "bbcc50d1d1487dca1ae3791ac651c709d5a0b91b139ed5a54f4a1aa2f077c465", + "", + "reply" + ], + [ + "p", + "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ], + [ + "p", + "564e2192d2c4df33224c15d787cd0172fc8c7e6a7afd67bcc3f8e3447d765981" + ] + ] + }, + { + "content": "", + "created_at": 1689735264, + "id": "568aa3a8feb50e84be9e4eb23e67a1c6caf64477a3f5beed397fd813fb341c51", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c17f12eb3978813697517020585f79ec8f3fb0d40e304d96f04fa187534c9726644782aa2efb38b88cb096f0bdc982836d1d8088ef5a4330deb56f661aca7eae", + "tags": [ + [ + "e", + "2091531f8196ac2334bf5249f0593d25a47516d7b0f05b72f75898a4767105d4" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "44746cee88a03743d35757aaf5b3ba178d527b71831387fb6df6b7ff1c1dfed5" + ], + [ + "size", + "70744" + ], + [ + "dim", + "700x787" + ], + [ + "blurhash", + "{bJQs1xtx[M|aKt6bGkB~qkC%MaeM{ozkCj[_3R-xuWBoIofbHay%gxZWWfQj[juWCayJCR+jbj@WVa}kBWBaeWBV@a#kCf6ofayRij?WBj[kCjsj[WBslWBWAj[j]j?bHWBR+fPRjoLWCWCoLWV" + ] + ] + }, + { + "content": "", + "created_at": 1689735142, + "id": "8baf7f4f57b2ccc2959d4013d8ce8984f23ac63fa501fc4779081183a8d1e607", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2e7ff6a7d5e66d20ebcc295965f5a3819d8b0756f100b3fb2a174a6988b8f989d8abfd81afacb607406019144a19830dee5ec0e81d621d93c825b632db2c152b", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_22f927d3b7728cd4a5e4a346fdde6dc8a5572072ffbc25b4.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "b9f569c3cc11bf0bafc96acd2ecbbb947a291ed28c04a109194122ecb16b96ab" + ], + [ + "size", + "4552970" + ] + ] + }, + { + "content": "", + "created_at": 1689734960, + "id": "257b87c845792f00af0850ec6c1c2c303c03c7de5ced8809d18886e731da4e8f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "08949cd2e8c188fbc65879163fc62b449037a4540c9bdd918a5e0cd505810d61db0a221d84582a24c699f364db7439cb52bea21788370997f666ea580270ff3d", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_8e7b035017ec1de33d2b8566de0f29efa9bca2410efbbe49.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c660cce3f512e9264baabc00b86f886b1a765507c318604695c2485843ee5568" + ], + [ + "size", + "1534508" + ] + ] + }, + { + "content": "", + "created_at": 1689733717, + "id": "523c5afe1411641ffe583c81e838fb5760ac86f933e731eeed1608ed6e469f2a", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f7eccf6879cd24054f088a317c65fec271c06b7c688ab03524321f02699a92a64384c343b14b4300eb21d04925dbb954b4d5fd929f7edfb2739964670f6ba276", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_16541715a9bcd12306d4f61bd5e039284128edcaafe3043f.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "f383c5b2f585a6a986f062ffc658afab97d3e12496037b336820c2aaedc21708" + ], + [ + "size", + "6121702" + ] + ] + }, + { + "content": "Reviewing a NIP vs writing a NIP.\nhttp://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d1d268a25e132f26352546760b8f0fb22fc3c932760edd18.webp", + "created_at": 1689733437, + "id": "de7fa61fdd2072ad8d6b9e3c90cf970268de48290d24e67c0692c394eca466bf", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7f88ffb115a2ad74d347a336b85f858ba6daeee05262332bfb9eaa0502ffe8bb5631909a6a16a2ff0a70e513c640aef3c4eed8545448fc534dddae5dd374a026", + "tags": [ + [ + "r", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d1d268a25e132f26352546760b8f0fb22fc3c932760edd18.webp" + ] + ] + }, + { + "content": "", + "created_at": 1689733322, + "id": "f36e5d3ecbb914f4861ff170b8377d571e6395a02d5d5ac7c17bd99e2df78e3b", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "de6ee5365d0e84255bfef9dca800c94c022ed7569d97069c7ef7d562556d3e9a20a852fa86579931f7370e9f85bcd4d24a64e78e64b1348493acfa6c519311df", + "tags": [ + [ + "url", + "https://nostr.build/i/457757ddef7ccc7c4306282d3442f9cc9a977a58773cee39c1d220055ddd07d8.jpg" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "5fa74a9024ee9cfff829a4750929591f542fff4d45262bee6e22a241a2f77731" + ], + [ + "size", + "189954" + ], + [ + "dim", + "1170x1524" + ], + [ + "blurhash", + "_7ECI9+t-p%L%2~Bofy=Mx0z9sIoNan*%z~CM~E2NHIWX8%c?Z^%?aawsk%2TeJ9%0-:R-E1ae=|xa%2-pM}Nen$TIkoNYxrxYNHfj-owgxbxbxas:bcNFS1NbE2SgsSIW" + ] + ] + }, + { + "content": "Also, this is a tentative go solidify a single API for all service providers. \n\nhttps://github.com/nostr-protocol/nips/pull/547", + "created_at": 1689729262, + "id": "c3b4f7a5fb5b73b48acd49c00097bd48b334c506f7ba60f7e2f784e3ef6f86ad", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6724905af656ff6a3fce1a4e1003c03283b4a9b34ace65915d55431f84edff612ba7bf1a0bd7eaa301fc11372b8ac14bc94d0950d0c793cd3efc7ad372d6aac3", + "tags": [ + [ + "e", + "0fb88d12f3852b003ab2ef21a0d4be2fa5b1471a347fb979d93ae381014b740a", + "", + "reply" + ], + [ + "p", + "0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6" + ], + [ + "p", + "76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/pull/547" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689724634, + "id": "e9c257a0586daf0767e313bb9ba569e432ecf17e3a9a8a7beb81254e29957020", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a66d4cac9ff56f55ff998a665a347485c8e985a6ddbed24dd1405aa3a21291d1ff91ca01b7d2659bf774e5fdf59ad38f17869e1426ed8fe74e53ead866fe25ff", + "tags": [ + [ + "e", + "83a23b4f0584bd4a34c210cbbb5d4dafd03f25b886d0f5f7e5f638139e69d8f7" + ], + [ + "p", + "89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689724333, + "id": "a64c025b82d09bb1fb7d2eeaf810793d880cdd7f3cbb2c4cd30ea13582ca4afa", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e6f16f6dd109d84c54077a705d9e912d1cb2c15fab6b6d12ac434902dffe5dd36cacbb57c760686dd6097c828d93a3ac979b95ef239cb0d42a9041d0cc187234", + "tags": [ + [ + "e", + "473007f975b7e612910adcdb9979e81cc9e3a8a37066c4d03685cc1a8a33928b" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ] + ] + }, + { + "content": "W don't display all notifications. The app only displays it when you are directly cited or when it's a reply to a post you are the author. ", + "created_at": 1689724319, + "id": "c220414cf423280cb3e8f88174a6ee9b8df1b3ede4febe24fbcc1dfdcd0e2208", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9974f8f47a6479bdbf4f2e05bec747a43387a0d9752481ce80a0295c34e96df41b7523ad87ee78e30955f01951ee928ab3fc03a3a586e003932eecd5d8a3c543", + "tags": [ + [ + "e", + "6fd5fce21e31c71d7d27f5cf740f3570b296bde46894eccd5958f234e0e5a820", + "", + "root" + ], + [ + "e", + "bb271e57c3b96c9f7bfbb0e03964c3820d7dc629b46c30899dd11be7af4d4691" + ], + [ + "e", + "497eab572a2cd0716e92e63415b923e7c38f73a1dff0d1cd3216fe1bd51c8787" + ], + [ + "e", + "5e9aa9876a730974ecebef4f52742bd4a9877143146eddc4168424517667307f", + "", + "reply" + ], + [ + "p", + "89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c" + ], + [ + "p", + "180a6d42c7d64f8c3958d9d10dd5a4117eaaacea8e7f980781e9a53136cf5693" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "That's because another client is erasing it. We have always saved hashtags in the contact list. It's was never local.", + "created_at": 1689724277, + "id": "eeae5dde847dec45663970e4fc8423ab4c2a372de0badc1730029bcbbaa39f84", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a8be54bde3be1b9170a3df82823822dffd1159a6561a2e2e84619528e212f0bf606c6a2527194506fb0536a598d0681109d63cac9394fd66943bde06392387fe", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "46a660a9a9b87f8bcdc8471a61c53e2111537285a78f28ee62c495b539a7bd36" + ], + [ + "e", + "5057914f8a3c8425d6c2160c130a256c343dcd4943eaeb94c3f9bb83f03c19be" + ], + [ + "e", + "c67fe72ec0895e4e5c698ad669bd7b5e13ca936ad120a1dd574a86fc20e618ec", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f0fb31d1810a9f95df3d178fcd67ca0b09879ad11e8689e56962cd839fb8ead4" + ] + ] + }, + { + "content": "Nostr devs are like: I am going to spend 2 hours and make a new app.. why not? ", + "created_at": 1689721709, + "id": "dd4375c77daa2335d53318fa39276b0840651e1e77fce1ed61465698e3af3fb1", + "kind": 1311, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6c109ac0f321b61060241790d85acdfb223b6143038aba381f4eed27a20bf175dd44c779981aa0b1d2897e42d0d145fce4557bcb710726252cfba8054c11072a", + "tags": [ + [ + "a", + "30311:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1689719669", + "", + "root" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689721672, + "id": "cbbc2beb927c61a157f748d993d87c52fa0f097b5e468f3aa20b5029f5c08dc7", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9e8106799c6812d6ada4ea8db38ab58d978e33d14347cf6b38335a9594de6dfd95bfeea0a447e016c4689ac2528a01c5e79639643026ba41564260d1f69634c5", + "tags": [ + [ + "e", + "c9911b6d35b4c87c53feae668005604ee0e620274322dfec6570dde6fdd68aa0" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ], + [ + "a", + "30311:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1689719669" + ] + ] + }, + { + "content": "Because everything already is. ", + "created_at": 1689721283, + "id": "173122f70b71b0a057ed6bde5094abbda41540d68c5b1cccbbea5fd428578505", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e82e5064d89be686f02d55ddf6dd7f3ccc206c154db3f18c9a89cdeb9ab399f15e2551f195db839b67c747ca32fd6362a3e6570dd5acdee4486cdffc992cd0c1", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "19aa577d11b0dab38635548fb830f7f3eeb7f4de8c987d2d70d3526e23537c79" + ], + [ + "e", + "f03d390a0416e822a965d83d17ef61c0439979404eb0b0a79080053f64fe16f3", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "8e432ad14d3955d0863b975778f0c8817ef88c9f119d626da1a3face584bda73" + ] + ] + }, + { + "content": "https://github.com/nostr-protocol/nips/pull/673", + "created_at": 1689721195, + "id": "baf9d389c6b1e18a1676efd8904e52270198129c1f61dcc3db97e0c8cc49a000", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "73e5b4f28b8989e3aae1e7c31ddbcc43a392ac945186cb98719c9fb05d93e7bb90a5033b2fea99f5b3b9ab19128089b8f85ca0d0865ffda8daad5c81ade8e4a4", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "46a660a9a9b87f8bcdc8471a61c53e2111537285a78f28ee62c495b539a7bd36" + ], + [ + "e", + "5057914f8a3c8425d6c2160c130a256c343dcd4943eaeb94c3f9bb83f03c19be" + ], + [ + "e", + "f8881207bfdca139717745ced0e86e0ee20d2e73417f21bb813c6ed151e920dc", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/pull/673" + ] + ] + }, + { + "content": "I want to stop doing local settings. Everything should be on Nostr, on relays. ", + "created_at": 1689719485, + "id": "5057914f8a3c8425d6c2160c130a256c343dcd4943eaeb94c3f9bb83f03c19be", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "44a2c6edda25b215cd5187f309d0292bc188607384413bae08625ed6915d73bb95a348119f4a58f991e9bb97a19569c0678eee8599d9339f1e1e21d6d84923e3", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "46a660a9a9b87f8bcdc8471a61c53e2111537285a78f28ee62c495b539a7bd36", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "Devs, who is using kind:10000 for Mute List instead of a kind:30000 with d tag=\"mute\"? \n\nWe should focus on just one way to do Mute Lists.", + "created_at": 1689719265, + "id": "bab81750570876585535c8ac4a4d5f983ea4cb0675c94882af25b256d60f5a7e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "646f33f7f4c005d31c73b9687e041e6b49080606a71d789611a5129f9f1025ed6ada39d2ecb31f4f7c8fb50e33f3946ad11aae23d37c0388d964684772f89098", + "tags": [] + }, + { + "content": "Is this a global setup or each list will have their own keywords? ", + "created_at": 1689719025, + "id": "4738396f74638dca62d58a88764ededd2d89748e20f81f4fba574fb1af1a9b40", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "323a7cc7373289a83eca16c3696d37c05d0860539188eec90b931f54f29ae5e1ca7b6b561dffd4d1b65f5038dd0adab5af21063047667def894e72e9d0d35dcd", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "25c76135357f79245f401168ee01aecb748c7434d8f12c3627a02fa41dd2d15f", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ] + ] + }, + { + "content": "I meant on Nostr. Which NIP? ", + "created_at": 1689718940, + "id": "25c76135357f79245f401168ee01aecb748c7434d8f12c3627a02fa41dd2d15f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a39d29dd625e2af0a636a09fca8047ea6abbeee9258713db413a9f975953ca625448878cd0db49db15bc6aa54e2df67aa8a72ba2b0f2dbdf44229bd4a3e7bdd6", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "6e8e7ab0fc769cf059fbc93c40d7007bd8847a80433cd8e68c15be45bf1594bd", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ] + ] + }, + { + "content": "NIP-53 doesn't have direct invites though. I am very confused about what the invites are doing. Is this like join me now if you can, kind of invite? What happens if they reply yes? ", + "created_at": 1689718906, + "id": "6aae65ba6f84596984069e914b441bb2e29fd031c63a1e3647754c70a49576ad", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "680e70e6d5a2786349fd566cebb3f5678545854e7cb890bfe5ff68c8b374640acc04989e1285b27eb152ca765e938e8b0b39e8d3fb745857fcaf3c125da0ed68", + "tags": [ + [ + "e", + "8a7780984986beef1406caf4139a338349f8d194725eff881157cdc6e69cb983", + "", + "root" + ], + [ + "e", + "573cb50aa1485d3c41ae7324b6c5d469a979bed956417e6bc3d95825c61fe5ff", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "b8beebaac1fea45e8907a52b6d6a57707328276f2f000719de1cbe20b3b9fe80" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1" + ] + ] + }, + { + "content": "nostr:npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc 's Facebook Marketplace NIP was merged! 🚀\n\nhttps://github.com/nostr-protocol/nips/blob/master/99.md\n", + "created_at": 1689715846, + "id": "d7c2b6093d9dfa3f452b871da8b3290a42159db6cd084ff17bcd6c6e25fc2a06", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "90b8a3d9f17ba803b7b2e188a6acad31cda7179dbd9ba6fd13cc9a600d32b9330999d5718c5d34f7823457c2dba964ee46ba7dc34615b4820544d753283ea7d4", + "tags": [ + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ] + ] + }, + { + "content": "It has been on my todo-list for ages. I am not sure where to save it thought. ", + "created_at": 1689710487, + "id": "67cf4b517fb8cf8cde6ff822500fcfa7fde11d0677e8096b7f98f38b6c1dcd1e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a6689c8dab94bf784a18ecaad285c74ef4332cf098eae040c092a1c05054b1479afe5bf066ec76d9eb0f8c86b2274d1688196ed9d7e5c01c68f89b6bd0b5ed84", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ] + ] + }, + { + "content": "I would guess they are requiring users to have a 'user' attribute such as a contact list or a user metadata kind set up. ", + "created_at": 1689710282, + "id": "69aa2f252f0d51e76147257b84866c237f4d05b6eeff41c12e22f717906268dc", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "461eb497159cc49ed2e44fe524e7f3658aa4ea561d9f74c13187f4fb2efa9ce4852c717cc057b85d1060044e69de294f5b8d4e174f1935d4d931d96344b07445", + "tags": [ + [ + "e", + "08f4c8cbe5cba4bc0c68863eabf8aa7c6ad22be19398cfd6ae137f541454a9b8", + "", + "root" + ], + [ + "e", + "fb96d54dcf8128f0ddd8994d53b81fff998e80043b45f15db257265df4d5dc23", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "43a22be283a77d24a3fa218e063e782da195d1adb0edd528b88ac9ba1bebcdfe" + ], + [ + "p", + "8a9402c71384dd0a387d3def483b8f89736fdfe26bc28276671820af942cca7e" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ] + ] + }, + { + "content": "https://github.com/nostr-protocol/nips/pull/597", + "created_at": 1689699142, + "id": "5571cc3a41f0cc0a36c38711307cefdb30504cf851e1328a2b4f852c29a0fa7d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2ce8c864668bb4c1b63a88727e796a870cb8498b20307a40a735fcc30e2759f93d73a96cddf95d9599316e9fcbc060938f87868a972f00fcc6bd24c2494bf44c", + "tags": [ + [ + "e", + "8a7780984986beef1406caf4139a338349f8d194725eff881157cdc6e69cb983", + "", + "root" + ], + [ + "e", + "3c4c22b112912148bfcb2e65ba02a200cb3363d4be8d1bdd77c5e2bf43b9a065" + ], + [ + "e", + "2c592975b96913fe5a856a67b41dff42e70fdfdb72922c86fdc59f31704e9c0b", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "b8beebaac1fea45e8907a52b6d6a57707328276f2f000719de1cbe20b3b9fe80" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/pull/597" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689699060, + "id": "c43ff35ea35d8d58089e68ed52cf9fd6dfaf5f9e25f475aeb88a807818d58812", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fd8b3a15b4dabbde2dbbce1b1dbf3fa81bbc52c653b4473afee0498e1253379c094086a13d2c0e0a3c074b33a752c8faa22caf8faf0e403e64dd027a07228a4f", + "tags": [ + [ + "e", + "9909236b03e8581c67fa93a0409efa71638bda585943b0a1abad59a6196d3255" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689699028, + "id": "20a5feb4699b42f94c0ef86f698d4b39dba306ffaf1a296de086185b21bc008e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f6856444b7ca2df1d3f8b20791b2068ed670c925f0be05c01444d4cd56a60925a35f0114a26ad54fc9799a4c0ec6390cae58755e56fafd47e923e75a6c84e720", + "tags": [ + [ + "e", + "f1dbc22d6c52e9ab63cdbd7ae563b317f00c6206aa7e378965e432b1928ae36c" + ], + [ + "p", + "3493a605e9c26c31ebe1d86a20f96082c4f584d6fde3cf98b9afc2d783ffc952" + ] + ] + }, + { + "content": "2 possible issues: \n- if you were using private bookmarks, some clients do not support it and might have erased that info. \n- if your relays failed to provide a bookmark record before you decided to add another post into it, the app will start a fresh list for you, erasing what was done before. ", + "created_at": 1689698818, + "id": "f50b94d7b1c87b7c9a022a6ea8815f5ae94aace648f1b8f154606d011e14e3a9", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6054a616397ebb2331804aad0eefd7763354bc2a3900d33a6325bd23d38f5d53d30c1b6f244c4935e232302f5a542df2f9a3b41b0bc36d091081ae386a3810e9", + "tags": [ + [ + "e", + "a43fdf0ef52cb242995bf8d173fb5a8c4f308cb07dd2a6b627a5816e929b1f23", + "", + "root" + ], + [ + "e", + "9de05c2edd1d5221dc8428cde09467dda4dfb84a0431015b65cf8ceb347a3d7a", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "880b21ccbe2e079c9ac5a5d14314ce5d47adbfe6cd5c00116e75f472d9a3f910" + ] + ] + }, + { + "content": "Groups of people? Use highlighter.com or listr.lol for now", + "created_at": 1689683437, + "id": "51868a97d597d756d01f3bd54d73946783b6cc217faca7ab288cd0b2769146e7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8bbfe64b5b37a07415f20b90703e7973578ae7213ed8b8eca6b080160ce426d1e6c3abd9c8d5f9440f3cbb9ee6456f6fda0f239c5c01002dfe777108c5b4106e", + "tags": [ + [ + "e", + "b220fa47738212e6935be76b73fb6a6124948113cda700c04dd349641e86c3fa", + "", + "reply" + ], + [ + "p", + "0d97beae567fcec9c6574f1c6ef6126ea969d4992c3198e51c0fac52c5274a14" + ], + [ + "r", + "highlighter.com" + ], + [ + "r", + "listr.lol" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689677043, + "id": "f5b58d047aef44426bd06c0862b0258668ba9ff48f007269c24ee955d9d927e3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "93f58aaf7438b0e630fdbcbfce5df066a2ab8a7fee3351a10fce01b9b100fc48f03d9111337491fe917a96ee677e76930d59e42828c6598244be0a990d6cc10b", + "tags": [ + [ + "e", + "8c1360284291b9602f1ed196fc221b7b4349f8be31795f7161c931fbc4c0b264" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "170,000 lines of code for a video player alone is the answer 😁😅", + "created_at": 1689676994, + "id": "8c05e5903746a676c53f5951af60b204aa74f7ac034bf0f042624c34d4fba0d3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffa6d381a1f5a5b578bf031feb5421ab2c5c9922b13b53a4a28a78e36ec022647ea8584431fdeb1b50872284226b9e1875ff566920096153e2771871d64fa1be", + "tags": [ + [ + "e", + "b3bcf12483811024e77cb1575b4345b88549fe0ee6a26df942c16547b34d7f36", + "", + "root" + ], + [ + "e", + "edf021fc9290fc7f7fe3bcd5c33904536a1710b6a525a33e696723fffb887627" + ], + [ + "e", + "2dce8555ed1824e129910aa57301771a0c1aba3d0f6b5529efcecd0a3f031e2a" + ], + [ + "e", + "8b4b4b56bb850a32efe4f45e68fd84d0ae2d5183062f507c6c68aecbaab29d28", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b8ef68a3073dae2a78a02e1d5921013bffbc82aa5039f77b6f7434512cc65de7" + ] + ] + }, + { + "content": "The relay architecture is terrible for curation. That's why relay-centric browsing is broken. ", + "created_at": 1689676644, + "id": "96f7ebeec77ef2945527c777c9eb0143cdee0a634461a0b52131396c0635650d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7b75d01573bfee59f38469f410ec13f24155d87f913b26a88824af57f5f8b4b6fc87202558d167facc818a73f9286644075fd9474b84ea78382322085d23b04d", + "tags": [ + [ + "e", + "0133b5466212bcf7200ac43448cd44c996d09ec89eec785061637987f9e423de", + "", + "reply" + ], + [ + "p", + "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411" + ] + ] + }, + { + "content": "No idea", + "created_at": 1689676119, + "id": "2dce8555ed1824e129910aa57301771a0c1aba3d0f6b5529efcecd0a3f031e2a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1af2fb3bc6acc1a56ba42a6c0a6db9c4921ac399552de26d03128c69bd7e99b22b769cc2e309c95f7553282715dd2c8640fed3f04f160a1fb68feef83d3c3cbc", + "tags": [ + [ + "e", + "b3bcf12483811024e77cb1575b4345b88549fe0ee6a26df942c16547b34d7f36", + "", + "root" + ], + [ + "e", + "edf021fc9290fc7f7fe3bcd5c33904536a1710b6a525a33e696723fffb887627", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b8ef68a3073dae2a78a02e1d5921013bffbc82aa5039f77b6f7434512cc65de7" + ] + ] + }, + { + "content": "{\"content\":\"Introducing Minibits Wallet for Android – a new Cashu mobile wallet with focus on performance and usability! \\n\\nMinibits aims to make ecash UX less technical.\\n\\nSign up for testing at https://minibits.cash or get one of the alpha releases on the GitHub repository! \\n\\nThis is still at a research stage, read the warnings before use. Please provide feedback and report bugs!\\n\\nhttps://github.com/minibits-cash/minibits_wallet\\nhttps://void.cat/d/SzRmh2jNzHKgKvKQb7bETV.webp\",\"created_at\":1689675049,\"id\":\"eff1e425d2790ec703bacb0373b067499edf24174b845ac1fb9172987552945b\",\"kind\":1,\"pubkey\":\"50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63\",\"sig\":\"1d868c7ec460c110b6861dadfcc6506e3edf9887bd1c5d3e40f35acbed10b9c6f6ca868c69cd6c9582665ffc05c73bd70905a92e26143c77f1a7ef121ccbfb1e\",\"tags\":[]}", + "created_at": 1689676096, + "id": "c2c56a4f0afb483d2e6d9bcacf5662a8b3de06db891a30c1da2f6e91114aa256", + "kind": 6, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2da6d302cc4d8a0072573dc1ab4919d0e79c747b45f52b41f67e001a3e865aab0ada32348324adf490ef86e541b3497857d44eb8c74c2ff8212e8e3299b84438", + "tags": [ + [ + "e", + "eff1e425d2790ec703bacb0373b067499edf24174b845ac1fb9172987552945b" + ], + [ + "p", + "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63" + ] + ] + }, + { + "content": "Possible? Sure. I am not sure if it's desirable. No one likes these translations services that require all your messages to get sent to a server. It would be a lot better if they created a local model to translate in-device like the Play version does. ", + "created_at": 1689676003, + "id": "855a4de95fc1bce83a4da7c28fe823365162a1413503cfbca9cbe322269b07ba", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "16566000229b6a4cb882f5337d0f6ac5513f80345c72f165739f6e058bdec9e9a34d24755f85111ae76f6e5b71c65b4adc669314ed8d1ac7d3af7868eafc6e66", + "tags": [ + [ + "e", + "305284085aa2e647f5f8ac0da34a3898674c10087c4d7a9653d5292d97585a4c", + "", + "root" + ], + [ + "e", + "4d1ce28ed36892e1c3892c05c36a49fadc00a628f194c99e88dd4f2d54e89024" + ], + [ + "e", + "f7f0a6cd0833758383e74399a6f92cef9cc0ffab1243562125d27a283a369b22" + ], + [ + "e", + "996566ec7df1d641e8842673c0b798626e31b8a60c6045e83d07e8c48d4ef948", + "", + "reply" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba31f050a66d6aee73fb258405a44fcffa662e24784e6650944f8fc2d3089427" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689675731, + "id": "808dd71e183d66fbb206ba02bd8f7167e3192851091e70daa4fb691c69a7d4d6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94b58286c108e74b1c3428d7a1c6453c2f27e76a34400af9dcc4afd5f8af863d066e4e6bad885499cfd72c6fc6f10f19bbbb683e8ab4caf8cd2d9dd3524509a1", + "tags": [ + [ + "e", + "f75a1684c2982ce450bbfd1c6e6eeee3aac0d9c57e3a542e2d038f28e54fd2da" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689675726, + "id": "7bd385d259bbf9d331eb4e1ca9ca78ef0d7ea1bb43d44daf2e2029d4c575014e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d5801e6e34971ef41ae34dd1e714823ce8096dcc84f88ad566fd4abf9ffb5eeca82ef8152a88945cb7ec3ba9ffe50a7e21595b2d04daa6b88b586a3bdb790b6f", + "tags": [ + [ + "e", + "536959c20e829ddd2516afa9774c22c332cdb335770d363201f547b9918e1cf7" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "GM, freaks. \n\nnostr:nevent1qqsy9u6jkzy4pa4ktcmrnhwa33uqqkyk6yugjgrkqcrefwlsewxly2cpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygytpg474ahta7f9arnclrc2mfql0zvt3knj4zt3hzvc30mc2lfknupsgqqqqqqss5qlwl", + "created_at": 1689675330, + "id": "a6c102acb21bc9229f23722b4f1d55232c91901b1710be8a4a7c71c9ea128c16", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a6444849124e0bd3479620c2102c1d6defb81852ef3c2118296452b5370901591b02102f5c148005ac8b69ba4fd86d106cc8673306c3a89150a09ca8b4feb790", + "tags": [ + [ + "e", + "42f352b08950f6b65e3639dddd8c78005896d138892076060794bbf0cb8df22b", + "", + "mention" + ], + [ + "p", + "8b0a2beaf6ebef925e8e78f8f0ada41f7898b8da72a8971b89988bf7857d369f", + "", + "mention" + ] + ] + }, + { + "content": "No, only on the Play version ", + "created_at": 1689674872, + "id": "f7f0a6cd0833758383e74399a6f92cef9cc0ffab1243562125d27a283a369b22", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1ba2852e22e55b216ab87c5e2e2b3edc62accf811b033a51d2e4ce79bd015e517d909785db81220d9825c88e976e47498053c9d2995d26a09c9d3e82e6e0a944", + "tags": [ + [ + "e", + "305284085aa2e647f5f8ac0da34a3898674c10087c4d7a9653d5292d97585a4c", + "", + "root" + ], + [ + "e", + "4d1ce28ed36892e1c3892c05c36a49fadc00a628f194c99e88dd4f2d54e89024", + "", + "reply" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba31f050a66d6aee73fb258405a44fcffa662e24784e6650944f8fc2d3089427" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674801, + "id": "b79a18aecbb705999848e430aa48ca8e4a02f205baf0abf1893ad78be5bd097a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8ff23133abaea20df1a6bb81e4d0c17184b10ef4a61c1d9420409b05a155b1765aba40ed409dafca1f1404a70d13f47b8c17b7192c361dd3552f80c3fb40117c", + "tags": [ + [ + "e", + "70526f1c71a579e9a7d0ca60f0c32ddcaa2fb0588e5ccc854ffd5eb706913c1c" + ], + [ + "p", + "d651b0cc52fe150819266fd62efb79e3f859161b33fc054ba98e43537e4a9601" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674786, + "id": "9e44d17fb36c23f8122dcd1a052db0df8b77e67524efab0500b3bfee490fb150", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "52ea3aad9c774be769bd6129aaadba7cff8cf06a573887d2c64f6f4c1c04c7ffa54e2d2dfa60a2323d06cd57b9034a2db1991fbbaad331b8981542ef739ca8a6", + "tags": [ + [ + "e", + "88377d4a0fb02c73c8c9a9ec0c90d29ebe80c30e7e0dd357bd9fdc34719b467f" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674730, + "id": "9cdd27028fa23568917ccee7fe7f1de15b43504e006515f7a0cb124075263b18", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b6261ca964542f01cb714f5089147e316cb30ae016bfa76462840f41c645f6f8e115998b4221a33ee78c025c78f0a3b3dc2961713640e561dce76e7bcea388c4", + "tags": [ + [ + "e", + "f637d77cf787a9f772b7926f9752fa5c2eed3e21e10c41f2e8ca0f7addf08821" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689674506, + "id": "6714667912c344a403c50998ee0c3dcdc66b03174625525a838acf4ba99610e8", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0394f188cb33d3c177e384a639deb8992cc8d0fb94600b9176439b7e02ca4dd77f6e1e706bb18e03d542a2b02db6309c2f09bcce194de2342c1108196134ae88", + "tags": [ + [ + "e", + "2744bfaea2c88d8e1337f635f0e57e14d4ee44d69c67e630c9d15f1eeb86c822" + ], + [ + "p", + "98d3b91eb93faad55a2a43ee21d71262f793f4de3db9a5d7710970ba8a251881" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674497, + "id": "d9af4d5f22a2989278ab2a5e8eb4310bfc8cf3ebb2331d5df74dadedd1beddcb", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "989b263e6f46c5b55cd56a78d3c38b6fb7d66033826d89b61696965e6ac584f7a487ea294612ca0ec256e64c22790e1c7b47e75ad1e6651c54bbe9122eea21e9", + "tags": [ + [ + "e", + "cdc0cd6cdf63c223ba85ddd0612ee3aa6b8a6e222d901c6389e54a9af7d72eb3" + ], + [ + "p", + "7635328aeba6447da86a1b6cd0608232718c045454861fc9e98f432eed66e812" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674432, + "id": "3069edc36af6612cf734f943ae1fde2dda8bfd6c9ae90e92655fd4f6502280c6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0503f95c23a2aa99d0bc3bae631767ea08d64d161ec2a4609c6a0cc3f27b10b2c663442095458e9c59ad90942cf841f37018a33b44dbfc089a85db2397cf5996", + "tags": [ + [ + "e", + "c576696bb51e39aa44e3ca8ecc089ac760f5771961b5d689870f355876c0a12c" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674426, + "id": "0bff08abd0c2d42180c7ab81b4605c46dd425d9a6aaa46796bae9e52b196d929", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3df849993210969fe921274e42bc85dc7401993095bb6ddaa4441f85c08b29220e9851f5cb65c023f5163d55b3c142ec15a0b5464b68f618a73ac9e9fdb6804c", + "tags": [ + [ + "e", + "641ddd94ba362703b7d62819b0188d548837740eb7448a05390da2829c7ba11d" + ], + [ + "p", + "180a6d42c7d64f8c3958d9d10dd5a4117eaaacea8e7f980781e9a53136cf5693" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674423, + "id": "0daf05f8c55e150201937e621cdec9405b9e81f2305165ed36a5558321a581ad", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ef61781ea0683789dadbe55c8448069ad3b95dd7fce6b174b91e0461d29a2d8c83dbc26e17d0ab686e389cf6850d5dc696a344da374861d84a66cf33a29fff2f", + "tags": [ + [ + "e", + "31ccd562c205061d6052946e7125a48c24a371a9459403d3b8fbb58c4c2d52bb" + ], + [ + "p", + "1b9d72d38b422a09cefed126e88f82361d4b1cc11cd19a2fc04e17b00b0c7d15" + ] + ] + }, + { + "content": "", + "created_at": 1689671952, + "id": "29f243eded2b46d7e96facb9bdd51e1a01a759ff5bf80f816e22c34b8fc6d89a", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "082154083f66095ec70b8ae15ebd113e6ee57011b5e1c2d3adaad4fcc9612859e173c364eaf7ba2167387669555497b567b21ddc355cc4105f03f7ed67a0bcf5", + "tags": [ + [ + "p", + "5c070d057298297ff0dd72779a4126efd30c743907371cc9e44bb54861dc3fc9", + "nudity" + ] + ] + }, + { + "content": "", + "created_at": 1689647540, + "id": "01f9c880cff98ef2bbc3348f904be5817bc00a64ee4afb90ed357e3232ff75eb", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "df6eff31de9fb678dee844ce1334802e4578b049b7e4f3a5abf187303ab7acacd0de63f9e6c8ed5cd70a42b43a30c29aeee7fda28377b50677c702e58e156fa1", + "tags": [ + [ + "url", + "https://nostr.build/av/01b69af390dac66fab819c17d93de7b2c9e070f13f444d3a1a4db6cb785222d9.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "01b69af390dac66fab819c17d93de7b2c9e070f13f444d3a1a4db6cb785222d9" + ], + [ + "size", + "1099399" + ] + ] + }, + { + "content": "", + "created_at": 1689647463, + "id": "846a0bd2419f5faf68587a544dd8ad1302675f28c3643938e51377b855581739", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9dd403c8f62393c03a3194cfb25ff444197ee2fc1de46e0c0c8664f65a939846add90d768e6bc0380147d39b44e5600f68cd7102910bd96313df10ccec3b4f13", + "tags": [ + [ + "url", + "https://nostr.build/av/a921fefb2485ca7a2f93121b6eef1f868133eff1931a99e3c847bec96bb45df0.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "a921fefb2485ca7a2f93121b6eef1f868133eff1931a99e3c847bec96bb45df0" + ], + [ + "size", + "189820" + ] + ] + }, + { + "content": "", + "created_at": 1689647421, + "id": "e2a3d9b37e0d6d98bbd8b36a26e8825aa67a5328ebf745dd9f7239fea69d97f5", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7502e7527cf42b647dfc936e399f44e8ca97943cf659b4d98ce541328b90520e28084fd62e13044478845de9b1f45af1b1c813e434dbaa692004f7f3a9cc1196", + "tags": [ + [ + "url", + "https://nostr.build/av/c32f2572a8f23053590a69cbdfa0cb1463a9c6d874dfbc745c2929d69da62ddf.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c32f2572a8f23053590a69cbdfa0cb1463a9c6d874dfbc745c2929d69da62ddf" + ], + [ + "size", + "999370" + ] + ] + }, + { + "content": "", + "created_at": 1689646963, + "id": "3c7e59dcf0b5b682d1983306c92e6173b1247fca0419d837cf912933f670a148", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "129cdc94b7cfc8b034731c0c93fb28cad71f96c489358ea63be5431ebbd0d5b78f813ea9430dfffa46e848ae2f524289d149951c9ad47188200d8da54f759aca", + "tags": [ + [ + "e", + "ad8095ae59d8202718df0cea762210513957e56ca13896b19793f167c2252494" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "27685303c3efe890e7995959baa3c9c3fd46146801a61fec5039100dba0f659f" + ], + [ + "size", + "99764" + ], + [ + "dim", + "700x700" + ], + [ + "blurhash", + "U8RC[6-;~q-;-;j[WBWB-;ayWBay-:WBj[ay" + ] + ] + }, + { + "content": "One of our most important features. \n\nnostr:nevent1qqsqkc2nessaxmj3ak7jcvx8c3wa9h0xd34m6zctt4gg5cflyaa3afsprdmhxue69uhhyetvv9ujummjv9hxwetsd9kxctnyv4mz7q3qkmsnnxw6p8vlk2l2dh0443jkj0txl7jrww8qpx4vd428he0a8jqqxpqqqqqqz344mdr", + "created_at": 1689646713, + "id": "305284085aa2e647f5f8ac0da34a3898674c10087c4d7a9653d5292d97585a4c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "cb0c8d9d2a800b445fd0091ac8e0d90eec909c626447b54357a66db89a4e68a2786e81fa69afe0b3c6ef1d32b42134d8acc3d0c3dc3b4aa4554931dbf2d4fcd8", + "tags": [ + [ + "e", + "0b6153cc21d36e51edbd2c30c7c45dd2dde66c6bbd0b0b5d508a613f277b1ea6", + "", + "mention" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80", + "", + "mention" + ] + ] + }, + { + "content": "", + "created_at": 1689645973, + "id": "fcfbcc09a29bccfacc30648f2896e98a743fe53b9575c2b74e22ef27132b418f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9afb7d2d297b056c6ee3ec07640158d89b736e98711caf65d9af0c102d5edae9f967d9138e9318c1ab5e26640527b0826bee942c631990e41cb303401317e440", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_037bcb8edf70a9558ac91aa3fe682d52629fc5d104a560a4.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c213bc58d4976a10ad979803510c0354b35bbb4388c127513179862f513bc49c" + ], + [ + "size", + "634647" + ] + ] + }, + { + "content": "", + "created_at": 1689645460, + "id": "fe0e0f2ee6955b49c6b669e7a837a89c8f62bacb3c965659acfa2cc62a1aa432", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "11225b4d9c32b97ab6be603f424304e022c92b1ecc8fc2398b93f8e1e1b407c7fc43fa02662d1d992dfff73801df36aca8c0f855bded7e409df6306acfdb6865", + "tags": [ + [ + "e", + "763298a22775bf8dcc7bd53a680c013772ca89684ebd7bcefc7893ccfae55418" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "aa0c52d9b8a1ea1d8f13a8a7ce5347d54ae9498d4596720cc9a6a2d107a384ef" + ], + [ + "size", + "19585" + ], + [ + "dim", + "700x439" + ], + [ + "blurhash", + "rcQJ$X=]$~M}xXNHj@NHt59dt6%1s:fPRka}WXoexsRlj[t6WDfkazoeWC%0t6RlNHoes:oeayj[~9IqIpocRlt6fRt5R+^hIWM|ocRkoea|s.R+" + ] + ] + }, + { + "content": "", + "created_at": 1689644892, + "id": "cf1c11434611087cf4067cecac3d3cbf3a4c9cb3ab7d0d2ecda568f09922446c", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d9fe14a564778961e829343e5532af5d02ee22c74229ccb162252a1f22819d13ebf3c71911acbc9dba42a1cba53d83c4bf5cb7b47967ed20fb1db0b988b4a314", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_23d68bdbf34b80739e049afca249e8b1657865d97add8530.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "aabd46855472a53dbf4b53fe1a635e21866846a13f4dc3f64db1ea9f753994fb" + ], + [ + "size", + "1839932" + ] + ] + }, + { + "content": "FdG8mzOV58zqAkp7Qxv0WLTmWFvQloax9jYy974bV40=?iv=d4w5JdfkhAMEl41xUDZx7w==", + "created_at": 1689637676, + "id": "663d4a5985e3cad8f67eb65ad0e00d173caf765175cb55603694bb423a4cdfe2", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fbc5da1ce36dcd903874429230297028ae7864dadf0c709a1308a837e14e8a79663646189f9a6448d7d27af318fcc2b7fdd51d252240935cdcdff301a179570d", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "o2Mg9Aje0jyl7nuUseRxsg==?iv=to7+pWNFngqM+mwIPy5AdQ==", + "created_at": 1689637614, + "id": "db71590c17eecf3119d78cbb5859dc3406be14854f65cdbb8bdccaa3d2a5df0f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b19739ccf233092f79aa739deb09cbce4c3657f709692c9c8ab95f354dcfbace3f6e3e6aec440902b6bb1aec0f77dbb9b39733159ebc9feee3999b588488e91c", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "3uVnxDkGt4xxFs7zdSPpdxDR33Q2FJWqsnPPu4IDoP0thesChUq4DsYBidQ9iIh7?iv=KnNpoML3YnkaGI/DnD6CxQ==", + "created_at": 1689637180, + "id": "28f3ddb0d16d4a752c73dc7531c2d221d3689035e8695fdde111cd122edf5831", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c1b26f20a66f2cf91caa9159fabf9ee17f1b98d98a250e6c67971763e38690ef013b1a81eceee504e85e99b8ea9747e89a35fa3a7207daef51a0d768df8f9458", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "Cwn68+FXCmX4W6qSl1eNiTbmn1vTeAHF25GpMBMid5Mg9x1BazZvpbT/k3/TgHS8W502zcYqCeyVhVPfPU87fQ==?iv=5/HJRrixXfJtO7/n8bDYZw==", + "created_at": 1689637166, + "id": "fa1b6d36c3194a2d7779f9d52c00f534fb0f389955da59edd1f5f9bac99c97d0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b48844f9754c36ab4e9469fcd70350a165ab631c740a2f285f62790a1cda8804f2b87da09c3ff9df755234a8bf3e12aae60c0b0461057680a3620531e499171e", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "+q5Stn5eAtTfm/ULw2SkApBV1gqztggFhMwa0OjCBx3syNt/asVFpoPqxguGd9QQErTTDIFDoHAiCX5vEIh6YQ==?iv=Cj00Q8hQLpbHfVh1hQb1oA==", + "created_at": 1689637117, + "id": "30d057504b23277b8b9d8654e46f2a66a3adcbd194706c9c37ce4864763b3d74", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dcf3aec8217171253ab614be7cffc239f5767a0d9cd6bd396af6665016c7ed77263f446e1c8f5e10ed60cc81bc78c8ff4b908a2908df5a90ed210bd69784469a", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + } +] \ No newline at end of file diff --git a/quartz/src/commonTest/resources/nostr_vitor_startup_data.json b/quartz/src/commonTest/resources/nostr_vitor_startup_data.json new file mode 100644 index 0000000000..4d1fc04e5b Binary files /dev/null and b/quartz/src/commonTest/resources/nostr_vitor_startup_data.json differ diff --git a/quartz/src/iosTest/resources/ovxxk2vz.jpg b/quartz/src/commonTest/resources/ovxxk2vz.jpg similarity index 100% rename from quartz/src/iosTest/resources/ovxxk2vz.jpg rename to quartz/src/commonTest/resources/ovxxk2vz.jpg diff --git a/quartz/src/iosTest/resources/relayDB.txt b/quartz/src/commonTest/resources/relayDB.txt similarity index 100% rename from quartz/src/iosTest/resources/relayDB.txt rename to quartz/src/commonTest/resources/relayDB.txt diff --git a/quartz/src/iosTest/resources/trouble_video b/quartz/src/commonTest/resources/trouble_video similarity index 100% rename from quartz/src/iosTest/resources/trouble_video rename to quartz/src/commonTest/resources/trouble_video diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt index d77753912b..22ab71c948 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt @@ -20,29 +20,73 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.SerializationException actual object OptimizedJsonMapper { - actual fun fromJson(json: String): Event = TODO("Not yet implemented") + actual fun fromJson(json: String): Event = + try { + KotlinSerializationMapper.fromJson(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun toJson(event: Event): String = TODO("Not yet implemented") + actual fun toJson(event: Event): String = KotlinSerializationMapper.toJson(event) - actual fun fromJsonToMessage(json: String): Message = TODO("Not yet implemented") + actual fun fromJsonToMessage(json: String): Message = + try { + KotlinSerializationMapper.fromJsonToMessage(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToCommand(json: String): Command = TODO("Not yet implemented") + actual fun fromJsonToCommand(json: String): Command = + try { + KotlinSerializationMapper.fromJsonToCommand(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToTagArray(json: String): Array> = TODO("Not yet implemented") + actual fun fromJsonToTagArray(json: String): Array> = + try { + KotlinSerializationMapper.fromJsonToTagArray(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToEventTemplate(json: String): EventTemplate = TODO("Not yet implemented") + actual fun fromJsonToEventTemplate(json: String): EventTemplate = + try { + KotlinSerializationMapper.fromJsonToEventTemplate(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToRumor(json: String): Rumor = TODO("Not yet implemented") + actual fun fromJsonToEventList(json: String): List = + try { + KotlinSerializationMapper.fromJsonToEventList(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun toJson(tags: Array>): String = TODO("Not yet implemented") + actual fun fromJsonToRumor(json: String): Rumor = + try { + KotlinSerializationMapper.fromJsonToRumor(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual inline fun fromJsonTo(json: String): T = TODO("Not yet implemented") + actual fun toJson(tags: Array>): String = KotlinSerializationMapper.toJson(tags) - actual fun toJson(value: OptimizedSerializable): String = TODO("Not yet implemented") + actual inline fun fromJsonTo(json: String): T = + try { + KotlinSerializationMapper.fromJsonTo(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } + + actual fun toJson(value: OptimizedSerializable): String = KotlinSerializationMapper.toJson(value) } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt index 24676f8126..708f6f8153 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt @@ -73,9 +73,14 @@ actual object GZip { val written = input.usePinned { pinIn -> output.usePinned { pinOut -> - stream.next_in = pinIn.addressOf(0).reinterpret() + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } stream.avail_in = input.size.toUInt() - stream.next_out = pinOut.addressOf(0).reinterpret() + + if (output.isNotEmpty()) { + stream.next_out = pinOut.addressOf(0).reinterpret() + } stream.avail_out = maxSize.toUInt() deflate(stream.ptr, Z_FINISH) @@ -97,6 +102,8 @@ actual object GZip { * Output is collected in fixed-size chunks to handle arbitrary output size. */ actual fun decompress(content: ByteArray): String { + if (content.isEmpty()) return "" + val chunks = ArrayList() val chunkSize = maxOf(content.size * 4, 4096) @@ -108,7 +115,9 @@ actual object GZip { .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } content.usePinned { pinIn -> - stream.next_in = pinIn.addressOf(0).reinterpret() + if (content.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } stream.avail_in = content.size.toUInt() var status: Int = Z_OK diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt index 05c06c5478..ba0890c38f 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt @@ -26,32 +26,32 @@ import platform.Foundation.NSURLQueryItem actual class UriParser actual constructor( uri: String, ) { - private val nsUrlComponents: NSURLComponents? = NSURLComponents(string = uri) + private val nsUrlComponents: NSURLComponents = NSURLComponents(string = uri) - actual fun scheme(): String? = nsUrlComponents?.scheme + actual fun scheme(): String? = nsUrlComponents.scheme - actual fun host(): String? = nsUrlComponents?.host + actual fun host(): String? = nsUrlComponents.host actual fun port(): Int? { // The NSNumber?.intValue is a way to handle a nullable port and convert it // from a platform-specific number type to a Kotlin Int. - return nsUrlComponents?.port?.intValue + return nsUrlComponents.port?.intValue } - actual fun path(): String? = nsUrlComponents?.path + actual fun path(): String? = nsUrlComponents.path actual fun queryParameterNames(): Set { - val queryItems = nsUrlComponents?.queryItems ?: return emptySet() + val queryItems = nsUrlComponents.queryItems ?: return emptySet() return queryItems.mapNotNull { (it as? NSURLQueryItem)?.name }.toSet() } actual fun getQueryParameter(param: String): String? { - val queryItems = nsUrlComponents?.queryItems ?: return null + val queryItems = nsUrlComponents.queryItems ?: return null return (queryItems.firstOrNull { (it as? NSURLQueryItem)?.name == param } as? NSURLQueryItem)?.value } val fragments: Map by lazy { - nsUrlComponents?.fragment()?.ifBlank { null }?.let { keyValuePair -> + nsUrlComponents.fragment()?.ifBlank { null }?.let { keyValuePair -> keyValuePair.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt index 00c7bda278..3b8338cd3c 100644 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz +import com.vitorpamplona.quartz.utils.GZip import dev.whyoleg.cryptography.CryptographyProviderApi import dev.whyoleg.cryptography.providers.base.toByteArray import kotlinx.cinterop.ExperimentalForeignApi @@ -33,6 +34,11 @@ import platform.Foundation.stringWithContentsOfFile import platform.posix.getenv actual class TestResourceLoader { + actual fun loadDecompressString(file: String): String { + val data = loadFileData(file) + return GZip.decompress(data) + } + @OptIn(ExperimentalForeignApi::class) actual fun loadString(file: String): String { val resourceDir = getenv("TEST_RESOURCES_ROOT")?.toKString() diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 0000000000..90117ac43a --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt index c011e785ee..c0cee1c9d4 100644 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt @@ -26,15 +26,15 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.fail -public class NIP49Test { +class NIP49Test { companion object { - val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + const val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" - val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" - val TEST_CASE_PASSWORD = "nostr" + const val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + const val TEST_CASE_PASSWORD = "nostr" val MAIN_TEST_CASES = - listOf( + listOf( Nip49TestCase(".ksjabdk.aselqwe", "14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a", 1, 0x00), Nip49TestCase("skjdaklrnçurbç l", "f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab", 2, 0x01), Nip49TestCase("777z7z7z7z7z7z7z", "11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944", 3, 0x02), @@ -74,7 +74,7 @@ public class NIP49Test { @Test fun encryptDecryptTestCase() { val encrypted = nip49.encrypt(TEST_CASE_EXPECTED, TEST_CASE_PASSWORD, 16, 0) - val decrypted = nip49.decrypt(encrypted!!, TEST_CASE_PASSWORD) + val decrypted = nip49.decrypt(encrypted, TEST_CASE_PASSWORD) assertEquals(TEST_CASE_EXPECTED, decrypted) } @@ -86,7 +86,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, it.password) + val decrypted = nip49.decrypt(encrypted, it.password) assertEquals(it.secretKey, decrypted) } @@ -105,7 +105,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, samePassword2) + val decrypted = nip49.decrypt(encrypted, samePassword2) assertEquals(TEST_CASE_EXPECTED, decrypted) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt index 65af774218..89a3287d63 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import com.fasterxml.jackson.databind.RuntimeJsonMappingException import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command @@ -71,6 +72,13 @@ actual object OptimizedJsonMapper { throw IllegalArgumentException(e.message, e) } + actual fun fromJsonToEventList(json: String): List = + try { + JacksonMapper.fromJsonToEventList(json) + } catch (e: com.fasterxml.jackson.core.JsonParseException) { + throw IllegalArgumentException(e.message, e) + } + actual fun toJson(tags: Array>): String = JacksonMapper.toJson(tags) actual inline fun fromJsonTo(json: String): T = @@ -78,6 +86,10 @@ actual object OptimizedJsonMapper { JacksonMapper.fromJsonTo(json) } catch (e: com.fasterxml.jackson.core.JsonParseException) { throw IllegalArgumentException(e.message, e) + } catch (e: com.fasterxml.jackson.core.JsonProcessingException) { + throw IllegalArgumentException(e.message, e) + } catch (e: RuntimeJsonMappingException) { + throw IllegalArgumentException(e.message, e) } actual fun toJson(value: OptimizedSerializable): String = JacksonMapper.toJson(value) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index bec5f11b4b..53c9810918 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -52,10 +52,15 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationSerializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer @@ -91,8 +96,12 @@ class JacksonMapper { .addSerializer(Rumor::class.java, RumorSerializer()) .addDeserializer(Rumor::class.java, RumorDeserializer()) // nip 47 + .addSerializer(Response::class.java, ResponseSerializer()) .addDeserializer(Response::class.java, ResponseDeserializer()) + .addSerializer(Request::class.java, RequestSerializer()) .addDeserializer(Request::class.java, RequestDeserializer()) + .addSerializer(Notification::class.java, NotificationSerializer()) + .addDeserializer(Notification::class.java, NotificationDeserializer()) // nip 46 .addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer()) .addSerializer(BunkerRequest::class.java, BunkerRequestSerializer()) @@ -108,6 +117,7 @@ class JacksonMapper { val tagArrayTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) val rumorTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) val eventTemplateTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef>()) + val eventListTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef>()) val messageTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) val commandTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) @@ -123,6 +133,8 @@ class JacksonMapper { fun fromJsonToEventTemplate(json: String): EventTemplate = mapper.readValue(json, eventTemplateTypeInstance) + fun fromJsonToEventList(json: String): List = mapper.readValue(json, eventListTypeInstance) + inline fun fromJsonTo(json: String): T = mapper.readValue(json) inline fun fromJsonTo(json: InputStream): T = mapper.readValue(json) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt index dd5cfa0bb0..3858f32368 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt @@ -26,8 +26,8 @@ class CountResultDeserializer { companion object { fun fromJson(jsonObject: JsonNode): CountResult = CountResult( - count = jsonObject.get("count").asInt(), - approximate = jsonObject.get("approximate").asBoolean(), + count = jsonObject.get("count")?.asInt() ?: 0, + approximate = jsonObject.get("approximate")?.asBoolean() ?: false, ) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 470360c800..345b9d9c46 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -72,8 +72,8 @@ class MessageSerializer : StdSerializer(Message::class.java) { countSerializer.serialize(msg.result, gen, provider) } - else -> { - null + is EoseMessage -> { + gen.writeString(msg.subId) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index c56dd72b1b..b0ba1d6b7d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -277,7 +278,7 @@ class ElectrumXClient( */ private fun electrumScriptHash(script: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256").digest(script) - return digest.reversedArray().joinToString("") { "%02x".format(it) } + return digest.reversedArray().toHexKey() } /** diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt new file mode 100644 index 0000000000..70535bb019 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification +import com.vitorpamplona.quartz.utils.asTextOrNull + +class NotificationDeserializer : StdDeserializer(Notification::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Notification? { + val jsonObject: JsonNode = jp.codec.readTree(jp) + val notificationType = jsonObject.get("notification_type")?.asTextOrNull() + + return when (notificationType) { + NwcNotificationType.PAYMENT_RECEIVED -> jp.codec.treeToValue(jsonObject, PaymentReceivedNotification::class.java) + NwcNotificationType.PAYMENT_SENT -> jp.codec.treeToValue(jsonObject, PaymentSentNotification::class.java) + NwcNotificationType.HOLD_INVOICE_ACCEPTED -> jp.codec.treeToValue(jsonObject, HoldInvoiceAcceptedNotification::class.java) + else -> null + } + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt new file mode 100644 index 0000000000..6d7376fbfe --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification + +class NotificationSerializer : StdSerializer(Notification::class.java) { + override fun serialize( + value: Notification, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + gen.writeStringField("notification_type", value.notification_type) + when (value) { + is PaymentReceivedNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + + is PaymentSentNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + + is HoldInvoiceAcceptedNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt index 8e0c5c1778..2fe07f584e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt @@ -24,8 +24,21 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod import com.vitorpamplona.quartz.utils.asTextOrNull class RequestDeserializer : StdDeserializer(Request::class.java) { @@ -36,9 +49,21 @@ class RequestDeserializer : StdDeserializer(Request::class.java) { val jsonObject: JsonNode = jp.codec.readTree(jp) val method = jsonObject.get("method")?.asTextOrNull() - if (method == "pay_invoice") { - return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + return when (method) { + NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java) + NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java) + NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java) + NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsMethod::class.java) + NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceMethod::class.java) + NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoMethod::class.java) + NwcMethod.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetMethod::class.java) + NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageMethod::class.java) + NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionMethod::class.java) + NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceMethod::class.java) + NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceMethod::class.java) + NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceMethod::class.java) + else -> null } - return null } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt new file mode 100644 index 0000000000..098740be17 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod + +class RequestSerializer : StdSerializer(Request::class.java) { + override fun serialize( + value: Request, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + if (value.method != null) { + gen.writeStringField("method", value.method) + } + when (value) { + is PayInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is PayKeysendMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is MakeInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is LookupInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is ListTransactionsMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is MakeHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is CancelHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is SettleHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is SignMessageMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is CreateConnectionMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index f903f01718..275daa5c1a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -24,9 +24,24 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse import com.vitorpamplona.quartz.utils.asTextOrNull class ResponseDeserializer : StdDeserializer(Response::class.java) { @@ -36,25 +51,86 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { ): Response? { val jsonObject: JsonNode = jp.codec.readTree(jp) val resultType = jsonObject.get("result_type")?.asTextOrNull() + val hasError = jsonObject.has("error") && !jsonObject.get("error").isNull + val hasResult = jsonObject.has("result") && !jsonObject.get("result").isNull - if (resultType == "pay_invoice") { - val result = jsonObject.get("result") - val error = jsonObject.get("error") - if (result != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (error != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) - } - } else { - // tries to guess - if (jsonObject.get("result")?.get("preimage") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (jsonObject.get("error")?.get("code") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + if (hasError) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> { + jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + } + + else -> { + val error = jp.codec.treeToValue(jsonObject.get("error"), NwcError::class.java) + NwcErrorResponse(resultType ?: "", error) + } } } + + if (hasResult || resultType != null) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> { + jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + + NwcMethod.PAY_KEYSEND -> { + jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java) + } + + NwcMethod.MAKE_INVOICE -> { + jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java) + } + + NwcMethod.LOOKUP_INVOICE -> { + jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java) + } + + NwcMethod.LIST_TRANSACTIONS -> { + jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java) + } + + NwcMethod.GET_BALANCE -> { + jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java) + } + + NwcMethod.GET_INFO -> { + jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java) + } + + NwcMethod.GET_BUDGET -> { + jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) + } + + NwcMethod.SIGN_MESSAGE -> { + jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) + } + + NwcMethod.CREATE_CONNECTION -> { + jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::class.java) + } + + NwcMethod.MAKE_HOLD_INVOICE -> { + jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java) + } + + NwcMethod.CANCEL_HOLD_INVOICE -> { + jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java) + } + + NwcMethod.SETTLE_HOLD_INVOICE -> { + jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java) + } + + else -> { + // tries to guess for backward compatibility + if (jsonObject.get("result")?.get("preimage") != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + null + } + } + } + return null } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt new file mode 100644 index 0000000000..94fa792972 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse + +class ResponseSerializer : StdSerializer(Response::class.java) { + override fun serialize( + value: Response, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + if (value.resultType.isNotEmpty()) { + gen.writeStringField("result_type", value.resultType) + } + when (value) { + is NwcErrorResponse -> { + if (value.error != null) { + gen.writeObjectField("error", value.error) + } + } + + is PayInvoiceErrorResponse -> { + if (value.error != null) { + gen.writeObjectField("error", value.error) + } + } + + is PayInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is PayKeysendSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is MakeInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is LookupInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is ListTransactionsSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetBalanceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetInfoSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is MakeHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is CancelHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is SettleHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetBudgetSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is SignMessageSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is CreateConnectionSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt index 42f5dd8911..dbd0bbb271 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -119,7 +119,6 @@ actual class ChessEngine { val sq = Square.fromValue(square.uppercase()) return board .legalMoves() - .filterNotNull() .filter { it.from == sq } .map { it.to.toString().lowercase() } } @@ -132,7 +131,7 @@ actual class ChessEngine { } else { false } - } catch (e: Exception) { + } catch (_: Exception) { false } @@ -156,7 +155,7 @@ actual class ChessEngine { val move = Move(fromSquare, toSquare, promotionPiece) board.legalMoves().contains(move) - } catch (e: Exception) { + } catch (_: Exception) { false } @@ -192,7 +191,7 @@ actual class ChessEngine { val toSquare = move.to val piece = board.getPiece(fromSquare) val pt = piece.pieceType ?: return move.toString() - val promotionPiece = move.promotion ?: Piece.NONE + val promotionPiece = move.promotion // Castling if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) { @@ -213,7 +212,8 @@ actual class ChessEngine { board.getPiece(toSquare) != Piece.NONE || ( pt == com.github.bhlangonijr.chesslib.PieceType.PAWN && - epTarget != null && epTarget != Square.NONE && toSquare == epTarget + epTarget != Square.NONE && + toSquare == epTarget ) if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) { @@ -221,7 +221,7 @@ actual class ChessEngine { // Disambiguation: check if other pieces of same type can reach the same square val ambiguous = - board.legalMoves().filterNotNull().filter { + board.legalMoves().filter { it.to == toSquare && board.getPiece(it.from).pieceType == pt && it.from != fromSquare @@ -342,7 +342,7 @@ actual class ChessEngine { blackKingSide = board.castleRight.toString().contains("k"), blackQueenSide = board.castleRight.toString().contains("q"), ), - enPassantSquare = board.enPassantTarget?.let { it.toString().lowercase() }, + enPassantSquare = board.enPassantTarget.toString().lowercase(), halfMoveClock = board.halfMoveCounter, ) } diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt similarity index 86% rename from quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt index ebdf489700..3e75d0dc87 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt @@ -26,28 +26,29 @@ import java.net.URLDecoder actual class UriParser actual constructor( uri: String, ) { - val myUri = URI.create(uri) - val queryParameters: Map by lazy { + private val myUri = URI.create(uri) + + private val queryParameters: Map by lazy { myUri.query?.ifBlank { null }?.let { query -> query.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { - parts[0] to parts[1] + parts[0] to URLDecoder.decode(parts[1], "UTF-8") } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" + parts[0] to "" // Handle parameters without a value } } } ?: emptyMap() } - val fragments: Map by lazy { + private val fragments: Map by lazy { myUri.rawFragment?.ifBlank { null }?.let { keyValuePair -> keyValuePair.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { parts[0] to URLDecoder.decode(parts[1], "UTF-8") } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" + parts[0] to "" // Handle parameters without a value } } } ?: emptyMap() @@ -58,7 +59,6 @@ actual class UriParser actual constructor( actual fun host(): String? = myUri.host actual fun port(): Int? { - // java.net.URI.getPort() returns -1 if the port is not set, so we handle that case. val port = myUri.port return if (port == -1) null else port } @@ -69,5 +69,5 @@ actual class UriParser actual constructor( actual fun getQueryParameter(param: String): String? = queryParameters[param] - actual fun fragments() = fragments + actual fun fragments(): Map = fragments } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt new file mode 100644 index 0000000000..959b4de530 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -0,0 +1,740 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KotlinSerializationMapperTest { + val tags = + arrayOf( + arrayOf("title", "Retro Computer Fans"), + arrayOf("d", "xmbspe8rddsq"), + arrayOf("image", "https://blog.johnnovak.net/2022/04/15/achieving-period-correct-graphics-in-personal-computer-emulators-part-1-the-amiga/img/dream-setup.jpg"), + arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"), + arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"), + arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"), + arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"), + arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"), + arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"), + arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"), + arrayOf("description", "Retro computer fans and enthusiasts "), + ) + + val followCard = + FollowListEvent( + id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", + pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", + createdAt = 1761736286, + tags = tags, + content = "", + sig = "3aa388edafad151e81cb0228fe04e115dbbcaa851c666bfe3c8740b6cd99575f0fc3ba2d47acda86f7626564a05e9dbc05ef452a7bd0ac00f828dbad0e1bae6c", + ) + + val followCardRumor = + Rumor( + id = followCard.id, + pubKey = followCard.pubKey, + createdAt = followCard.createdAt, + kind = followCard.kind, + tags = followCard.tags, + content = followCard.content, + ) + + val followCardTemplate = + EventTemplate( + createdAt = followCard.createdAt, + kind = followCard.kind, + tags = followCard.tags, + content = followCard.content, + ) + + // ========================================================================= + // TagArray Tests + // ========================================================================= + + @Test + fun serializeTagArray_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(tags) + val kotlinJson = KotlinSerializationMapper.toJson(tags) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeTagArray_matchesJackson() { + val json = JacksonMapper.toJson(tags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized[index]) + } + } + + @Test + fun tagArrayRoundTrip() { + val json = KotlinSerializationMapper.toJson(tags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(tags.size, deserialized.size) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized[index]) + } + } + + @Test + fun tagArrayWithNullValues() { + val json = """[["key",null,"value"]]""" + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(1, deserialized.size) + assertEquals("key", deserialized[0][0]) + assertEquals("", deserialized[0][1]) // null -> "" + assertEquals("value", deserialized[0][2]) + } + + @Test + fun emptyTagArray() { + val json = "[]" + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(0, deserialized.size) + } + + // ========================================================================= + // Event Tests + // ========================================================================= + + @Test + fun serializeEvent_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCard) + val kotlinJson = KotlinSerializationMapper.toJson(followCard) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEvent_matchesJackson() { + val json = JacksonMapper.toJson(followCard) + val deserialized = KotlinSerializationMapper.fromJson(json) + + assertEquals(followCard.id, deserialized.id) + assertEquals(followCard.pubKey, deserialized.pubKey) + assertEquals(followCard.createdAt, deserialized.createdAt) + assertEquals(followCard.kind, deserialized.kind) + assertEquals(followCard.content, deserialized.content) + assertEquals(followCard.sig, deserialized.sig) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized.tags[index]) + } + } + + @Test + fun eventRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCard) + val deserialized = KotlinSerializationMapper.fromJson(json) + + assertEquals(followCard.id, deserialized.id) + assertEquals(followCard.kind, deserialized.kind) + assertEquals(followCard.createdAt, deserialized.createdAt) + assertEquals(followCard.pubKey, deserialized.pubKey) + assertEquals(followCard.content, deserialized.content) + assertEquals(followCard.sig, deserialized.sig) + } + + @Test + fun deserializeEventWithUnknownFields() { + val json = + """{"id":"abc123","pubkey":"def456","created_at":12345,"kind":1,"tags":[],"content":"test","sig":"sig123","unknown_field":"ignored"}""" + // Should not throw with unknown fields, should be ignored + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals("abc123", deserialized.id) + assertEquals("def456", deserialized.pubKey) + assertEquals("test", deserialized.content) + } + + @Test + fun deserializeEventWithSpecialCharactersInContent() { + val content = "Hello \"world\" \n\ttab\\backslash" + val event = + FollowListEvent( + id = "abc", + pubKey = "def", + createdAt = 1000, + tags = emptyArray(), + content = content, + sig = "sig", + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals(content, deserialized.content) + } + + @Test + fun crossDeserializationEvent() { + // Serialize with Jackson, deserialize with Kotlin Serialization + val jacksonJson = JacksonMapper.toJson(followCard) + val kotlinDeserialized = KotlinSerializationMapper.fromJson(jacksonJson) + + assertEquals(followCard.id, kotlinDeserialized.id) + assertEquals(followCard.pubKey, kotlinDeserialized.pubKey) + assertEquals(followCard.kind, kotlinDeserialized.kind) + + // Serialize with Kotlin Serialization, deserialize with Jackson + val kotlinJson = KotlinSerializationMapper.toJson(followCard) + val jacksonDeserialized = JacksonMapper.fromJson(kotlinJson) + + assertEquals(followCard.id, jacksonDeserialized.id) + assertEquals(followCard.pubKey, jacksonDeserialized.pubKey) + assertEquals(followCard.kind, jacksonDeserialized.kind) + } + + // ========================================================================= + // Rumor Tests + // ========================================================================= + + @Test + fun serializeRumor_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCardRumor) + val kotlinJson = KotlinSerializationMapper.toJson(followCardRumor) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeRumor_matchesJackson() { + val json = JacksonMapper.toJson(followCardRumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals(followCardRumor.id, deserialized.id) + assertEquals(followCardRumor.pubKey, deserialized.pubKey) + assertEquals(followCardRumor.createdAt, deserialized.createdAt) + assertEquals(followCardRumor.kind, deserialized.kind) + assertEquals(followCardRumor.content, deserialized.content) + } + + @Test + fun rumorRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCardRumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals(followCardRumor.id, deserialized.id) + assertEquals(followCardRumor.kind, deserialized.kind) + } + + @Test + fun rumorWithNullFields() { + val rumor = Rumor(null, null, null, null, null, null) + val json = KotlinSerializationMapper.toJson(rumor) + assertEquals("{}", json) + + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + assertNull(deserialized.id) + assertNull(deserialized.pubKey) + assertNull(deserialized.createdAt) + assertNull(deserialized.kind) + assertNull(deserialized.tags) + assertNull(deserialized.content) + } + + @Test + fun rumorPartialFields() { + val rumor = Rumor("abc", null, 1000, 1, null, "content") + val json = KotlinSerializationMapper.toJson(rumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals("abc", deserialized.id) + assertNull(deserialized.pubKey) + assertEquals(1000L, deserialized.createdAt) + assertEquals(1, deserialized.kind) + assertNull(deserialized.tags) + assertEquals("content", deserialized.content) + } + + // ========================================================================= + // EventTemplate Tests + // ========================================================================= + + @Test + fun serializeTemplate_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCardTemplate) + val kotlinJson = KotlinSerializationMapper.toJson(followCardTemplate) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeTemplate_matchesJackson() { + val json = JacksonMapper.toJson(followCardTemplate) + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + + assertEquals(followCardTemplate.kind, deserialized.kind) + assertEquals(followCardTemplate.createdAt, deserialized.createdAt) + assertEquals(followCardTemplate.content, deserialized.content) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized.tags[index]) + } + } + + @Test + fun templateRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCardTemplate) + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + + assertEquals(followCardTemplate.kind, deserialized.kind) + assertEquals(followCardTemplate.createdAt, deserialized.createdAt) + assertEquals(followCardTemplate.content, deserialized.content) + } + + @Test + fun templateWithDifferentFieldOrder() { + // Fields in different order than expected + val json = """{"kind":1,"content":"test","created_at":1234,"tags":[]}""" + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + assertEquals(1, deserialized.kind) + assertEquals("test", deserialized.content) + assertEquals(1234L, deserialized.createdAt) + } + + // ========================================================================= + // Filter Tests + // ========================================================================= + + @Test + fun emptyFilter_matchesJackson() { + val filter = Filter() + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun filterWithAllFields_matchesJackson() { + val filter = + Filter( + ids = listOf("abc123" + "0".repeat(58)), + authors = listOf("def456" + "0".repeat(58)), + kinds = listOf(1, 2, 3), + tags = mapOf("p" to listOf("3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da")), + tagsAll = mapOf("p" to listOf("3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da")), + since = 1000L, + until = 2000L, + limit = 50, + search = "hello", + ) + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun filterRoundTrip() { + val expectedTagValue = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da" + val filter = + Filter( + tags = mapOf("p" to listOf(expectedTagValue)), + tagsAll = mapOf("p" to listOf(expectedTagValue)), + ) + val json = KotlinSerializationMapper.toJson(filter) + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + + assertEquals(true, deserialized.tags?.keys?.contains("p")) + assertEquals(listOf(expectedTagValue), deserialized.tags?.get("p")) + assertEquals(true, deserialized.tagsAll?.keys?.contains("p")) + assertEquals(listOf(expectedTagValue), deserialized.tagsAll?.get("p")) + } + + @Test + fun deserializeEmptyFilter() { + val json = Filter().toJson() + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertNull(deserialized.ids) + } + + @Test + fun crossDeserializationFilter() { + val expectedTagValue = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da" + val filter = + Filter( + tags = mapOf("p" to listOf(expectedTagValue)), + tagsAll = mapOf("p" to listOf(expectedTagValue)), + ) + + // Jackson serialized -> Kotlin deserialized + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonTo(jacksonJson) + assertEquals(listOf(expectedTagValue), kotlinDeserialized.tags?.get("p")) + assertEquals(listOf(expectedTagValue), kotlinDeserialized.tagsAll?.get("p")) + + // Kotlin serialized -> Jackson deserialized + val kotlinJson = KotlinSerializationMapper.toJson(filter) + val jacksonDeserialized = JacksonMapper.fromJsonTo(kotlinJson) + assertEquals(listOf(expectedTagValue), jacksonDeserialized.tags?.get("p")) + assertEquals(listOf(expectedTagValue), jacksonDeserialized.tagsAll?.get("p")) + } + + // ========================================================================= + // Message Tests + // ========================================================================= + + @Test + fun serializeEventMessage_matchesJackson() { + val msg = EventMessage("sub1", followCard) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEventMessage() { + val msg = EventMessage("sub1", followCard) + val json = KotlinSerializationMapper.toJson(msg) + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is EventMessage) + assertEquals("sub1", deserialized.subId) + assertEquals(followCard.id, deserialized.event.id) + } + + @Test + fun serializeNoticeMessage_matchesJackson() { + val msg = NoticeMessage("something went wrong") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeOkMessage_matchesJackson() { + val msg = OkMessage("abc123", true, "success") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeOkMessage() { + val msg = OkMessage("abc123", false, "rate limited") + val json = KotlinSerializationMapper.toJson(msg) + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is OkMessage) + assertEquals("abc123", deserialized.eventId) + assertEquals(false, deserialized.success) + assertEquals("rate limited", deserialized.message) + } + + @Test + fun serializeAuthMessage_matchesJackson() { + val msg = AuthMessage("challenge123") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeNotifyMessage_matchesJackson() { + val msg = NotifyMessage("notification text") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeClosedMessage_matchesJackson() { + val msg = ClosedMessage("sub1", "subscription closed") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEoseMessage() { + val json = """["EOSE","sub123"]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(deserialized is EoseMessage) + assertEquals("sub123", (deserialized).subId) + } + + @Test + fun serializeCountMessage_matchesJackson() { + val msg = CountMessage("q1", CountResult(42, false)) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun crossDeserializationMessages() { + val messages = + listOf( + NoticeMessage("test"), + AuthMessage("challenge"), + NotifyMessage("notify"), + ClosedMessage("sub1", "reason"), + ) + + for (msg in messages) { + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertEquals(msg.label(), kotlinDeserialized.label()) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertEquals(msg.label(), jacksonDeserialized.label()) + } + } + + // ========================================================================= + // Command Tests + // ========================================================================= + + @Test + fun serializeReqCmd_matchesJackson() { + val filter = + Filter( + kinds = listOf(1), + limit = 10, + ) + val cmd = ReqCmd("sub1", listOf(filter)) + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeReqCmd() { + val filter = Filter(kinds = listOf(1), limit = 10) + val cmd = ReqCmd("sub1", listOf(filter)) + val json = KotlinSerializationMapper.toJson(cmd) + val deserialized = KotlinSerializationMapper.fromJsonToCommand(json) + + assertTrue(deserialized is ReqCmd) + assertEquals("sub1", deserialized.subId) + assertEquals(1, deserialized.filters.size) + assertEquals(listOf(1), deserialized.filters[0].kinds) + assertEquals(10, deserialized.filters[0].limit) + } + + @Test + fun serializeEventCmd_matchesJackson() { + val cmd = EventCmd(followCard) + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeCloseCmd_matchesJackson() { + val cmd = CloseCmd("sub1") + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun crossDeserializationCommands() { + val cmd = CloseCmd("sub1") + + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToCommand(jacksonJson) + assertTrue(kotlinDeserialized is CloseCmd) + assertEquals("sub1", (kotlinDeserialized).subId) + + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + val jacksonDeserialized = JacksonMapper.fromJsonToCommand(kotlinJson) + assertTrue(jacksonDeserialized is CloseCmd) + assertEquals("sub1", (jacksonDeserialized).subId) + } + + // ========================================================================= + // BunkerRequest Tests + // ========================================================================= + + @Test + fun serializeBunkerRequest_matchesJackson() { + val req = BunkerRequest("id1", "connect", arrayOf("pubkey", "secret")) + val jacksonJson = JacksonMapper.toJson(req) + val kotlinJson = KotlinSerializationMapper.toJson(req) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeBunkerRequest() { + val json = """{"id":"id1","method":"ping","params":[]}""" + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertEquals("id1", deserialized.id) + assertEquals("ping", deserialized.method) + } + + @Test + fun crossDeserializationBunkerRequest() { + val req = BunkerRequest("id1", "sign_event", arrayOf("{\"created_at\":1234,\"kind\":1,\"tags\":[],\"content\":\"This is an unsigned event.\"}")) + val jacksonJson = JacksonMapper.toJson(req) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonTo(jacksonJson) + assertEquals(req.id, kotlinDeserialized.id) + assertEquals(req.method, kotlinDeserialized.method) + assertContentEquals(req.params, kotlinDeserialized.params) + + val kotlinJson = KotlinSerializationMapper.toJson(req) + val jacksonDeserialized = JacksonMapper.fromJsonTo(kotlinJson) + assertEquals(req.id, jacksonDeserialized.id) + assertEquals(req.method, jacksonDeserialized.method) + assertContentEquals(req.params, jacksonDeserialized.params) + } + + // ========================================================================= + // BunkerResponse Tests + // ========================================================================= + + @Test + fun serializeBunkerResponse_matchesJackson() { + val resp = BunkerResponse("id1", "ok", null) + val jacksonJson = JacksonMapper.toJson(resp) + val kotlinJson = KotlinSerializationMapper.toJson(resp) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeBunkerResponseWithError_matchesJackson() { + val resp = BunkerResponse("id1", null, "something went wrong") + val jacksonJson = JacksonMapper.toJson(resp) + val kotlinJson = KotlinSerializationMapper.toJson(resp) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeBunkerResponse() { + val json = """{"id":"id1","result":"pong"}""" + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertEquals("id1", deserialized.id) + assertNotNull(deserialized.result) + } + + // ========================================================================= + // OptimizedSerializable toJson dispatch Tests + // ========================================================================= + + @Test + fun toJsonDispatchForFilter() { + val filter = Filter(kinds = listOf(1)) + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun toJsonDispatchForRumor() { + val jacksonJson = JacksonMapper.toJson(followCardRumor) + val kotlinJson = KotlinSerializationMapper.toJson(followCardRumor) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun toJsonDispatchForEventTemplate() { + val jacksonJson = JacksonMapper.toJson(followCardTemplate) + val kotlinJson = KotlinSerializationMapper.toJson(followCardTemplate) + assertEquals(jacksonJson, kotlinJson) + } + + // ========================================================================= + // Edge Cases + // ========================================================================= + + @Test + fun emptyContentEvent() { + val event = + FollowListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 0, + tags = emptyArray(), + content = "", + sig = "c".repeat(64), + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals("", deserialized.content) + assertEquals(0, deserialized.tags.size) + } + + @Test + fun largeTagArray() { + val largeTags = Array(100) { i -> arrayOf("p", "key$i") } + val json = KotlinSerializationMapper.toJson(largeTags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(100, deserialized.size) + assertEquals("key99", deserialized[99][1]) + } + + @Test + fun eventWithUnicodeContent() { + val content = "Hello \uD83D\uDE00 world \u00E9\u00E8\u00EA" + val event = + FollowListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1000, + tags = emptyArray(), + content = content, + sig = "c".repeat(64), + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals(content, deserialized.content) + } + + @Test + fun filterWithMultipleTagTypes() { + val filter = + Filter( + tags = + mapOf( + "p" to listOf("pubkey1", "pubkey2"), + "e" to listOf("eventid1"), + "t" to listOf("nostr", "bitcoin"), + ), + ) + val json = KotlinSerializationMapper.toJson(filter) + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + + assertEquals(3, deserialized.tags?.size) + assertEquals(listOf("pubkey1", "pubkey2"), deserialized.tags?.get("p")) + assertEquals(listOf("eventid1"), deserialized.tags?.get("e")) + assertEquals(listOf("nostr", "bitcoin"), deserialized.tags?.get("t")) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt index 44528e4df7..1431b7af5c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -21,11 +21,34 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +class DefaultContentTypeInterceptor( + private val userAgentHeader: String, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = + originalRequest + .newBuilder() + .header("User-Agent", userAgentHeader) + .build() + return chain.proceed(requestWithUserAgent) + } +} open class BaseNostrClientTest { companion object { - val rootClient = OkHttpClient.Builder().build() + val rootClient = + OkHttpClient + .Builder() + .followRedirects(true) + .followSslRedirects(true) + .addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05")) + .build() val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index bff422a78b..e974d964da 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -70,7 +70,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -81,7 +81,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { client.openReqSubscription(mySubId, filters, listener) - withTimeoutOrNull(30000) { + withTimeoutOrNull(10000) { while (events.size < 101) { val event = resultChannel.receive() events.add(event) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt new file mode 100644 index 0000000000..d58ed363fd --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.Test + +class NostrClientQueryCountTest : BaseNostrClientTest() { + val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() + val utxo = "wss://news.utxo.one".normalizeRelayUrl() + + val metadata = Filter(kinds = listOf(0)) + val outboxRelays = Filter(kinds = listOf(10002)) + + @Test + fun testQueryCountSuspend() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = client.queryCountSuspend(relay = fiatjaf, filter = metadata) + + assertTrue((result?.count ?: 0) > 1) + + client.disconnect() + appScope.cancel() + } + + @Test + fun testQueryCountSuspendAllEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = client.queryCountSuspend(relay = fiatjaf, filter = Filter()) + + assertTrue((result?.count ?: 0) > 1) + + client.disconnect() + appScope.cancel() + } + + @Test + fun testQueryCountSuspendMultipleRelays() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = + client.queryCountSuspend( + filters = + mapOf( + fiatjaf to listOf(metadata, outboxRelays), + utxo to listOf(metadata, outboxRelays), + ), + ) + + result.forEach { url, result -> + println("${url.url}: ${result.count}") + assertTrue(result.count > 1) + } + + client.disconnect() + appScope.cancel() + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index e583343c50..7334cb1583 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -83,7 +83,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -94,7 +94,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldIgnore = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), @@ -105,7 +105,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldSendAfterEOSE = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt new file mode 100644 index 0000000000..352ecada21 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { + @Test + fun testDownloadFromRelayReturnsMetadataEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val events = mutableListOf() + + // nos.lol returns only 500 events per req + val totalFound = + client.reqBypassingRelayLimits( + relay = "wss://nos.lol", + filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 1000, + ), + ), + ) { event -> + events.add(event) + } + + client.disconnect() + delay(500) + appScope.cancel() + + assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol") + assertEquals(1000, events.size, "Events list should be 1000 events") + events.forEach { event -> + assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") + } + } + + @Test + fun testDownloadFromRelayReturnsMetadataAndContactListEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val metadataEvents = mutableListOf() + val contactListEvents = mutableListOf() + + // nos.lol returns only 500 events per req + val totalFound = + client.reqBypassingRelayLimits( + relay = "wss://nos.lol", + filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 1000, + ), + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 1500, + ), + ), + ) { event -> + if (event.kind == MetadataEvent.KIND) { + metadataEvents.add(event) + } + if (event.kind == ContactListEvent.KIND) { + contactListEvents.add(event) + } + } + + client.disconnect() + delay(500) + appScope.cancel() + + assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol") + assertEquals(1000, metadataEvents.size, "Events list should be 1000 events") + assertEquals(1500, contactListEvents.size, "Events list should be 1000 events") + metadataEvents.forEach { event -> + assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") + } + contactListEvents.forEach { event -> + assertEquals(ContactListEvent.KIND, event.kind, "All events should be kind ${ContactListEvent.KIND}") + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index 5f289a4c61..e942fe1345 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -48,7 +48,7 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() { val resultDamus = client.sendAndWaitForResponse( event = event, - relayList = setOf("wss://relay.damus.io".normalizeRelayUrl()), + relayList = setOf("wss://nostr.bitcoiner.social".normalizeRelayUrl()), ) val resultNos = diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index f7faca43c4..7afd9caad4 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -54,7 +54,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { val flow = client.reqAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -93,7 +93,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { val flow = client.reqAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 511a0d2a5b..00c37f00ea 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -48,7 +48,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { val sub = client.req( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index ddcb9fde94..de36239681 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -54,7 +54,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { val flow = client.reqUntilEoseAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -93,7 +93,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { val flow = client.reqUntilEoseAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt index bb522a8b7c..314166c723 100644 --- a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -20,7 +20,17 @@ */ package com.vitorpamplona.quartz +import java.util.zip.GZIPInputStream + actual class TestResourceLoader { + actual fun loadDecompressString(file: String): String = + this@TestResourceLoader + .javaClass.classLoader + ?.getResourceAsStream(file) + ?.let { GZIPInputStream(it) } + ?.bufferedReader() + ?.use { it.readText() } ?: throw IllegalArgumentException("Resource not found: $file") + actual fun loadString(file: String): String = this@TestResourceLoader .javaClass.classLoader diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt similarity index 100% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt rename to quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt diff --git a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 0000000000..90117ac43a --- /dev/null +++ b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt index 52822c49cb..0c975e7c33 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -261,7 +261,8 @@ class NamecoinNameResolverTest { pubkey = rootMatch.content } - firstEntry != null && firstEntry.value is kotlinx.serialization.json.JsonPrimitive && + firstEntry != null && + firstEntry.value is kotlinx.serialization.json.JsonPrimitive && (firstEntry.value as kotlinx.serialization.json.JsonPrimitive) .content .matches(Regex("^[0-9a-fA-F]{64}$")) -> { diff --git a/quartz/src/nativeInterop/cinterop/Clibsodium.def b/quartz/src/nativeInterop/cinterop/Clibsodium.def deleted file mode 100644 index a60f3fda69..0000000000 --- a/quartz/src/nativeInterop/cinterop/Clibsodium.def +++ /dev/null @@ -1,3 +0,0 @@ -package = Clibsodium -staticLibraries = libsodium.a libsodium-simulator.a -libraryPaths = src/nativeInterop/libsodium/ios/lib src/nativeInterop/libsodium/ios-simulators/lib