diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 6b51cdab0..f85c0d9a1 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 af65d2dbc..4ab4d1726 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/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 3f3820398..9417851c4 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 b0aa2f8d6..6f8e43f42 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 ec09e0033..b950c57d5 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 7ccf720fb..c2a4d7129 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/.git-hooks/pre-push b/.git-hooks/pre-push index 903d42dc6..2c0dbdc27 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-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml new file mode 100644 index 000000000..0a65ca6c1 --- /dev/null +++ b/.github/workflows/build-benchmark-apk.yml @@ -0,0 +1,69 @@ +name: Build APK For Claude + +on: + push: + branches: + - 'claude/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-benchmark: + 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 Benchmark APK + run: ./gradlew assemblePlayBenchmark + + - name: Upload Play Benchmark APK + id: upload + uses: actions/upload-artifact@v6 + with: + name: Play Benchmark APK + path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk + + - name: Comment on PR with APK link + uses: actions/github-script@v7 + with: + script: | + const artifactId = `${{ steps.upload.outputs.artifact-id }}`; + const downloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`; + const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${downloadUrl})`; + + const branch = context.ref.replace('refs/heads/', ''); + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:${branch}`, + state: 'open' + }); + + for (const pr of prs) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body + }); + } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61e76d85f..0377b3735 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 44f3d56fd..beb08874b 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 d2707654d..923e7fa49 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 43404b259..945f2e02a 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 @@ -149,3 +150,6 @@ lint/tmp/ # Local task tracking TASKS.md + +# Claude Code local settings +.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 193605cf9..51195f4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ 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 Adds support for NIP-52 Calendar appointments @@ -23,6 +23,8 @@ 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. diff --git a/README.md b/README.md index d1d9dbe7b..a19f16550 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 fe5af44fe..b5973ec7d 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 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 84e3cc0f7..0c5f5c084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -171,6 +171,7 @@ 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.Request import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -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?, @@ -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 1e2eeda5d..6ed5a2cf1 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( @@ -340,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 3789e1c98..a1ab6665b 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 750d10ae9..923e7a91b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -358,19 +358,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 +394,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) @@ -2250,6 +2250,7 @@ object LocalCache : ILocalCache, ICacheProvider { requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { responseCallback(event) } 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 d4f012cbb..aa499a6b9 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 @@ -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 91f5fc1ac..54c03aec6 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 3787caac9..d279ec20e 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 f3d7ae385..dfb319822 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/cashu/v4/V4Models.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt index 5f2b17690..efd6e12fd 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/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index be3e7e252..91358cdfd 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/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index d27935f49..f4978575b 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 @@ -264,6 +264,7 @@ fun EditPostView( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) @@ -372,6 +373,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -379,7 +381,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 1c9d57d60..fec34ac2a 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 @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator 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 +80,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 @@ -180,7 +183,7 @@ open class EditPostViewModel : ViewModel() { val myAccount = account val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -243,7 +246,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 +258,7 @@ open class EditPostViewModel : ViewModel() { multiOrchestrator = null urlPreview = null - isUploadingImage = false + mediaUploadTracker.finishUpload() wantsInvoice = false @@ -296,7 +299,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/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 84142984b..23a5e561d 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 000000000..6ff5e72ec --- /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 354853720..7ed03deb2 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 0e38451c5..fcae06fc3 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/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 32aeaa679..90bad3c7f 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/ZonedSwipeModifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt new file mode 100644 index 000000000..b72c34315 --- /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/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fa59d0dc4..a613eac3c 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 @@ -120,6 +122,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 @@ -138,7 +144,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, @@ -152,6 +168,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) } 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 8ca73fb97..139ad10c3 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,6 +46,7 @@ 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 @@ -463,6 +464,14 @@ fun ListContent( route = Route.Drafts, ) + NavigationRow( + title = R.string.wallet, + icon = Icons.Outlined.AccountBalanceWallet, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Wallet, + ) + NavigationRow( title = R.string.route_chess, icon = R.drawable.ic_chess, 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 24a8e8ad4..ae29f61c3 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() 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 ac0e28646..321187de8 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/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 2e1de3e28..14c5ed899 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 2856360ee..ab8aa2015 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/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 9d4b31d4a..62d8be2b7 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 @@ -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 9e40f6e1d..09f9339dc 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,6 +78,7 @@ import kotlinx.collections.immutable.toImmutableList fun ImageVideoDescription( uris: MultiOrchestrator, defaultServer: ServerName, + isUploading: Boolean, onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, @@ -319,6 +320,7 @@ fun ImageVideoDescription( Modifier .fillMaxWidth() .padding(vertical = 10.dp), + enabled = !isUploading, onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) }, shape = QuoteBorder, colors = 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 c60b0a42e..b8682a34d 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 @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator 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 +150,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 @@ -487,7 +490,7 @@ open class CommentPostViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -551,7 +554,7 @@ open class CommentPostViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -564,7 +567,7 @@ open class CommentPostViewModel : externalIdentity = null multiOrchestrator = null - isUploadingImage = false + mediaUploadTracker.finishUpload() notifying = null @@ -676,7 +679,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 ed922a496..c4f826825 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 @@ -300,6 +300,7 @@ private fun GenericCommentPostBody( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) @@ -391,6 +392,7 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -398,7 +400,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/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 4840d06a1..066de85c4 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 6cdcec848..29cb0937a 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 d69e4a5a0..e7772e9c0 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/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 84ec07af7..8dc7d01f9 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 09a901b7d..312e6b541 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 @@ -158,7 +158,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( @@ -1009,7 +1008,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/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index a37f645e8..250b18264 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 @@ -141,7 +141,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 @@ -403,7 +404,16 @@ class ChatNewMessageViewModel : accountViewModel.launchSigner { if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.HOLD + }, + context, + ) { uploadsWaitingToBeSent += it draftTag.newVersion() onceUploaded() @@ -428,7 +438,19 @@ class ChatNewMessageViewModel : accountViewModel.launchSigner { if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.SEND + pendingRetryOnError = onError + pendingRetryContext = context + pendingRetryOnceUploaded = onceUploaded + }, + context, + ) { ChatFileSender(room, account).sendNIP17(it) draftTag.newVersion() onceUploaded() @@ -443,6 +465,69 @@ class ChatNewMessageViewModel : } } + // 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 + val uploadState = uploadState + + dismissEncryptedUploadError() + + if (uploadState == null || context == null) return + + uploadState.encryptFiles = false + + accountViewModel.launchSigner { + when (mode) { + RetryMode.HOLD -> { + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + ) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded?.invoke() + } + } + + RetryMode.SEND -> { + if (room == null) return@launchSigner + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + ) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded?.invoke() + } + } + } + } + } + private suspend fun innerSendPost(draftTag: String?) { val room = room ?: return @@ -561,6 +646,9 @@ class ChatNewMessageViewModel : userSuggestionsMainMessage = null uploadsWaitingToBeSent = emptyList() + uploadState?.reset() + + dismissEncryptedUploadError() iMetaAttachments.reset() @@ -692,7 +780,7 @@ class ChatNewMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - uploadState?.isUploadingImage != true && + uploadState?.mediaUploadTracker?.isUploading != true && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && 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 3a2f1f859..117f96d36 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 @@ -287,6 +287,7 @@ fun GroupDMScreenContent( ImageVideoDescription( selectedFiles, accountViewModel.account.settings.defaultFileServer, + isUploading = uploading.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.uploadAndHold( accountViewModel.toastManager::toast, @@ -301,6 +302,15 @@ fun GroupDMScreenContent( ) } } + + postViewModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = postViewModel.encryptedUploadErrorMessage ?: "", + onDismiss = postViewModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = postViewModel::retryWithoutEncryption, + ) + } } } @@ -386,6 +396,7 @@ private fun BottomRowActions( if (postViewModel.room != null) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -393,7 +404,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/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 2615ac2ec..b5b38901d 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 @@ -27,8 +27,10 @@ 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.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.ui.Alignment @@ -88,10 +90,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 +118,15 @@ fun PrivateMessageEditFieldRow( } } + channelScreenModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = channelScreenModel.encryptedUploadErrorMessage ?: "", + onDismiss = channelScreenModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption, + ) + } + Column( modifier = EditFieldModifier, ) { @@ -215,3 +228,36 @@ fun KeyboardLeadingIcon( 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/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 7ec7bd245..9164a505b 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,14 @@ 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.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent 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 +42,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,6 +75,31 @@ class ChatFileSender( ) } + suspend fun sendNIP17AsHiddenLink( + result: UploadOrchestrator.OrchestratorResult.ServerResult, + caption: String?, + contentWarningReason: String?, + ) { + val iMetaAttachments = IMetaAttachments() + iMetaAttachments.add(result, caption, contentWarningReason) + + val toUsers = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() } + + val template = + ChatMessageEvent.build(result.url, toUsers) { + references(listOf(result.url)) + + if (!caption.isNullOrEmpty()) { + alt(caption) + } + contentWarningReason?.let { contentWarning(it) } + + imetas(iMetaAttachments.filterIsIn(setOf(result.url))) + } + + account.sendNip17PrivateMessage(template) + } + // ------ // NIP 04 // ------ 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 f019e335f..3d48d9915 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 @@ -75,5 +75,6 @@ fun RoomChatFileUploadDialog( onCancel, accountViewModel, nav, + isNip17 = channelScreenModel.nip17, ) } 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 7f2d16378..eee1d1e39 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,59 +39,62 @@ class ChatFileUploader( suspend fun justUploadNIP17( viewState: ChatFileUploadState, onError: (title: String, message: String) -> Unit, + onEncryptedUploadError: (title: String, message: String) -> Unit, context: Context, 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, - stripMetadata = viewState.stripLocationMetadata, - ) + val results = + orchestrator.uploadEncrypted( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + cipher, + viewState.selectedServer, + account, + context, + ) - 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, 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, onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return - viewState.isUploadingImage = true + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) val results = orchestrator.upload( @@ -122,6 +125,51 @@ 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, + 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.stripLocationMetadata, + ) + + 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 c3e75c22f..09be707af 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 @@ -105,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 = @@ -205,7 +205,7 @@ class ChannelMetadataViewModel : ViewModel() { onUploaded: (String) -> Unit, onError: (String, String) -> Unit, ) { - val account = account ?: return + val account = account onUploading(true) val strippedUri = 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 8888e5b4c..f89f06b3b 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 @@ -134,7 +134,8 @@ open class ChannelNewMessageViewModel : 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 @@ -337,7 +338,7 @@ open class ChannelNewMessageViewModel : val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch - isUploadingImage = true + uploadState.mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -379,7 +380,7 @@ open class ChannelNewMessageViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + uploadState.mediaUploadTracker.finishUpload() } } @@ -547,6 +548,8 @@ open class ChannelNewMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null + uploadState?.reset() + iMetaAttachments.reset() emojiSuggestions?.reset() @@ -633,7 +636,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/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 9f48375a4..5bf51f508 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 @@ -48,6 +48,7 @@ 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.zonedDrawerSwipe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -124,7 +125,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, 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 79f6309d1..f15e11a26 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 = "", 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 586cd8ea7..13d4ab7b5 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 @@ -37,7 +38,9 @@ class ChatFileUploadState( val defaultServer: ServerName, defaultStripLocationMetadata: 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("") @@ -53,8 +56,8 @@ class ChatFileUploadState( // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED var mediaQualitySlider by mutableIntStateOf(1) - // Strip location and sensitive metadata from files before upload var stripLocationMetadata by mutableStateOf(defaultStripLocationMetadata) + var encryptFiles by mutableStateOf(true) fun load(uris: ImmutableList) { reset() @@ -68,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 ad9ad0e22..96a2502eb 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/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index aa651348e..0400191e6 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 @@ -269,6 +269,7 @@ private fun NewProductBody( ImageVideoDescription( uris = it, defaultServer = accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) @@ -359,6 +360,7 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -366,7 +368,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 be86ac151..66b46d0a7 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 @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator 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 +134,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 @@ -217,7 +220,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 +307,6 @@ open class NewProductViewModel : } suspend fun sendPostSync() { - val accountViewModel = accountViewModel ?: return val template = createTemplate() ?: return val version = draftTag.current @@ -317,8 +319,6 @@ open class NewProductViewModel : } suspend fun sendDraftSync() { - val accountViewModel = accountViewModel ?: return - if (message.text.isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { @@ -328,7 +328,7 @@ open class NewProductViewModel : } private suspend fun createTemplate(): EventTemplate? { - val accountViewModel = accountViewModel ?: return null + val accountViewModel = accountViewModel val tagger = NewMessageTagger( @@ -337,7 +337,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() } @@ -396,10 +396,10 @@ open class NewProductViewModel : context: Context, ) { 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( @@ -444,7 +444,7 @@ open class NewProductViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -454,7 +454,7 @@ open class NewProductViewModel : message = TextFieldValue("") multiOrchestrator = null - isUploadingImage = false + mediaUploadTracker.finishUpload() wantsInvoice = false wantsZapraiser = false @@ -498,8 +498,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 +575,7 @@ open class NewProductViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - !isUploadingImage && + !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && title.text.isNotBlank() && @@ -613,7 +613,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/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 9a95a0e46..e9c468e94 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 @@ -83,7 +83,8 @@ class HashtagFeedFilter( event is PrivateDmEvent || event is PollNoteEvent || 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/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 86891c7fe..feaeb1543 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 8f799892e..7239f8e82 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 @@ -356,6 +356,7 @@ private fun NewPostScreenBody( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) accountViewModel.account.settings.changeDefaultFileServer(server) @@ -494,6 +495,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -501,7 +503,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { } 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/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 422394b5b..87fd5939a 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 @@ -49,6 +49,7 @@ 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 @@ -178,7 +179,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 @@ -829,7 +832,7 @@ open class ShortNotePostViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -885,7 +888,7 @@ open class ShortNotePostViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -897,7 +900,7 @@ open class ShortNotePostViewModel : forkedFromNote = null multiOrchestrator = null - isUploadingImage = false + mediaUploadTracker.finishUpload() voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = null @@ -1032,19 +1035,20 @@ open class ShortNotePostViewModel : 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() ) ) && 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 105d71a93..f112a4173 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/chart/AmountValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt index 34675af1a..70a2444ea 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 7cc79fa6f..50223df25 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 f438df0fd..df1f61ef8 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 961895f1c..8c568b7af 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/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index a442d467c..d236275d1 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 @@ -246,6 +246,7 @@ fun PublicMessageScreenContent( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) @@ -321,6 +322,7 @@ private fun BottomRowActions( ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -328,7 +330,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 d3ccd5421..414c808c1 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 @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator 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 +150,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 @@ -319,7 +322,7 @@ class NewPublicMessageViewModel : } suspend fun sendPostSync() { - val template = createTemplate() ?: return + val template = createTemplate() val extraNotesToBroadcast = mutableListOf() if (nip95attachments.isNotEmpty()) { @@ -351,7 +354,7 @@ class NewPublicMessageViewModel : broadcast.add(it.second) } - val template = createTemplate() ?: return + val template = createTemplate() accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast) } } @@ -439,7 +442,7 @@ class NewPublicMessageViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -456,7 +459,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 +497,7 @@ class NewPublicMessageViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false + mediaUploadTracker.finishUpload() } } @@ -523,6 +526,8 @@ class NewPublicMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null + mediaUploadTracker.finishUpload() + iMetaAttachments.reset() emojiSuggestions?.reset() @@ -628,7 +633,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/followers/dal/UserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index a614105d4..d931e2cdc 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 dab557484..0667d781c 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 0a8f6f31d..0e943641d 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/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index 8514dde91..c55f6e580 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/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 1c95843c2..35fd0bd23 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/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index e2b79d62c..e45562fe9 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 @@ -188,6 +188,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 +269,7 @@ fun MappedAllRelayListView( SettingsCategoryFirstModifier, ) } - renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav, outboxCounts) item { SettingsCategory( @@ -270,7 +278,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav, inboxCounts) item { SettingsCategoryWithButton( @@ -282,7 +290,7 @@ fun MappedAllRelayListView( }, ) } - renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav) + renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav, dmCounts) item { SettingsCategory( @@ -291,7 +299,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav) + renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav, privateHomeCounts) item { SettingsCategory( @@ -300,7 +308,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav) + renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav, proxyCounts) item { SettingsCategory( @@ -320,7 +328,7 @@ fun MappedAllRelayListView( ResetIndexerRelays(indexerViewModel) } } - renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav) + renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav, indexerCounts) item { SettingsCategoryWithButton( @@ -331,7 +339,7 @@ fun MappedAllRelayListView( ResetSearchRelays(searchViewModel) } } - renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav) + renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav, searchCounts) item { SettingsCategory( 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 64ebae295..91d26f937 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 @@ -931,7 +931,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 73fad951f..625fde7c2 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 1a85e2aec..3196a1174 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 758c39646..91bc1f373 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 000000000..835eb764d --- /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 000000000..5aaa39b6e --- /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/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 37e02b99b..f6e939571 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 d08612a48..53a6a5ed0 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/indexer/IndexerRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt index 1af2490cd..56dbf8018 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 837966624..46da1f83e 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 212e1b3b5..a372d7286 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 a3e5f9f41..1003e9560 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 7ac2497f0..73c201b6b 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 134f7425c..e6fb7883a 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 2997a5c39..a737641f2 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 15f697596..1077d42e2 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 e2e3ba0aa..2f219c534 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 028b9eec3..d29bb7037 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 21760c45e..1f2473402 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 ef2f01e2c..16af23f49 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/wallet/WalletReceiveScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt new file mode 100644 index 000000000..c0451ede7 --- /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 000000000..8ef787815 --- /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 000000000..ca5bd2609 --- /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 000000000..db4c81307 --- /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.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.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 000000000..770ae0cbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -0,0 +1,384 @@ +/* + * 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.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.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 + +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-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index bb5983ec0..56b1c6a49 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -669,6 +669,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 +924,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 @@ -1045,6 +1048,29 @@ 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 Bezpečnostní filtry Importovat sledované Nový příspěvek @@ -1092,6 +1118,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 @@ -1161,6 +1194,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í @@ -1266,6 +1302,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 +1324,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 +1553,9 @@ Vybrat vše %1$d%% dostupnost Nastavení Namecoin + Průzkumník Bitcoin (OTS) + události + DMs + profily + nastavení relé diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 2684746a0..13ba58cc3 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -674,6 +674,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 +929,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 @@ -1050,6 +1053,29 @@ 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 Sicherheitsfilter Folgeliste importieren Neuer Beitrag @@ -1097,6 +1123,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 @@ -1166,6 +1199,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 @@ -1271,6 +1307,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 +1329,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 +1558,9 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen + Bitcoin Explorer (OTS) + ereignisse + DMs + profile + relaiseinstellungen diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e733a9483..0c808a742 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 @@ चलचित्र को संचारयन्त्र के चलचित्रालय में सुरक्षित रखा गया चलचित्र को सुरक्षित रखने में असफल चित्र आरोहण + अभिलेख आरोहण एक चित्र लें चलचित्र का अभिलेखन करें ऐक संदेश का अभिलेखन करें @@ -429,6 +432,7 @@ ज्साप गोपनीयता नियन्त्रण करता है कि आपका परिचय कैसे दिखाया जाता है ज्साप भेजने पर। संयोजन धनकोष + पुनःप्रसारक सूचनावली देखें प्रतिज्ञा मात्रा साट्स में मतदान प्रकाशित करें अनिवार्य प्रपत्रस्थान : @@ -642,6 +646,7 @@ जुडें आज विषयवस्तु चेतावनी + सावधान : %1$s इस पत्र में संवेदनशील विषयवस्तु समावेशित है जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है संवेदनशील विषयवस्तु सर्वदा छिपाएँ संवेदनशील विषयवस्तु सर्वदा दिखाएँ @@ -670,6 +675,7 @@ अष्टकों में मात्रा जो इस पुनःप्रसारक से प्राप्त हुआ था छलनियाँ तथा घटनाएँ समेत %1$s से पुनःप्रसारक जानकारी प्राप्त करने के प्रयास में अपक्रम हुआ अधिपति + द्वारा उपयुक्त सेवा कुंजी %1$s चलाया जा रहा है %1$s (%2$s) चलाया जा रहा है @@ -828,6 +834,7 @@ स्थान प्राप्त किया जा रहा है स्थान प्राप्त करने की अनुमति नहीं आपके विषयवस्तु दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है। यह आदर्श है किसी कार्यालय अनुचित विषयवस्तु के लिए अथवा जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है + कारण (विकल्पात्मक) नयी सुविधा इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा निप॰-१७ संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह निप॰-१७ नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं। सक्रिय करें @@ -866,6 +873,7 @@ चित्रों का अवरोहण कब करें चिति की अनुकृति करें टाँकाफलक में अनुकृति करें + टाँकाफलक में एन॰परिचय की अनुकृति करें टाँकाफलक में एनपुब॰ की अनुकृति करें बाँटें अथवा अभिलेखन करें टाँकाफलक में जालपता की अनुकृति करें @@ -920,6 +928,8 @@ सुनिश्चित करें हस्ताक्षर क्रमक ने इस व्यापार को अनुमति दिया कोई धनकोष प्राप्त नहीं लैटनिंग चालान चुकाने के लिए (अपक्रम : %1$s)। कृपया एक लैटनिंग धनकोष की स्थापना करें ज्सापों का प्रयोग करने के लिए कोई धनकोष प्राप्त नहीं लैटनिंग चालान चुकाने के लिए। कृपया एक लैटनिंग धनकोष की स्थापना करें ज्सापों का प्रयोग करने के लिए + ब्लोस्सम॰ योजक खोला नहीं जा सकता + ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए छिपाए गये शब्द नया शब्द अथवा वाक्य छिपाएँ परिचय चित्र @@ -1042,7 +1052,31 @@ वैश्विक छोटे चतुरंग + धनकोष + शेष + भेजें + प्राप्त करें + लेनदेन + कोई धनकोष संयोजित नहीं + एक नोस्टर धनकोष संयोजन (एनडबल्यूसी॰) स्थापित करें आपके ज्साप स्थापना विकल्पों में धनकोष का उपयोग करने के लिए। + धनकोष की स्थापना करें + साट्स + बोल्ट॰-११ चालान चिपकाएँ + भुगतान करें + भुगतान सफल + भुगतान भेजा जा रहा है… + संख्या (साट्स) + विवरण (विकल्पात्मक) + चालान बनाएँ + चालान बनाया जा रहा है… + चालान अनुकृति + अभी कोई लेनदेन नहीं + आवहन चालू… + प्राप्त + भेजा गया + नवीकरण सुरक्षार्थ छलनियाँ + अनुचरित आयात करें नया पत्र प्रकाशन नये छोटे : चित्र अथवा चलचित्र नया सामुदायिक टीका @@ -1077,8 +1111,8 @@ पुनःप्रसारक सूची चयनकर्ता मतदान मतदान अक्षम करें - बिटकोयिन चालान - बिटकोयिन चालान निरस्त करें + द्व्यंकरूप्य चालान + द्व्यंकरूप्य चालान निरस्त करें वस्तु बिक्री निरस्त करें ज्सापोपार्जन योजना ज्सापोपार्जन योजना निरस्त करें @@ -1088,7 +1122,15 @@ ज्साप विभाजन निरस्त करें विषयवस्तु चेतावनी जोडें विषयवस्तु चेतावनी हटाएँ + समापन दिनांक जोडें + समापन दिनांक हटाएँ + समापन दिनांक + पत्र इस दिनांक के पश्चात ग्राहकों द्वारा छिपाया जाएगा (निप॰४०) + समापन दिनांक तथा समय चुनें + %1$s में समाप्त + समापन समय क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ + क्यूआर॰ क्रमचित्र के रूप में एन॰परिचय को दिखाएँ अमान्य पता अमेथिस्ट को एक वैश्विक वस्तु विभेदक प्राप्त हुआ खोलने के लिए परन्तु वह विभेदक अमान्य था : %1$s सीधा संदेश आगतपेटिका पुनःप्रसारक @@ -1156,6 +1198,9 @@ बाधित पुनःप्रसारक बाधित पुनःप्रसारक अमेथिस्ट इन पुनःप्रसारकों से कभी नहीं जुडेगा + पुनःप्रसारक स्थापना विकल्प निर्यात + लेख के रूप में निर्यात + ज्सिप॰ (जेसोन॰) के रूप में निर्यात क्रमलेखकों को ज्साप करें! आपका दान हमारा सहायक है परिवर्तन लाने में। प्रत्येक साट गणनीय है! दान करें अभी @@ -1172,7 +1217,7 @@ अनुकृति : ओ॰टी॰एस : %1$s समयांकन प्रमाण - प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया बिटकोयिन खण्डश्रृंखला में उस समय उस दिन पर। + प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। पत्र का सम्पादन करें पत्र शोधन के लिए प्रस्ताव परिवर्तनों का साराम्श @@ -1261,6 +1306,19 @@ विषयसूचक खोज : #%1$s अनुवाद ना करें यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा। + अनुवाद इस में + भाषा चयन करें जिसमें विषयवस्तु का अनुवाद करना है। + भाषा प्रस्तुति आद्यताएँ + प्रत्येक अनुवाद भाषा युग्म के लिए चयन करें किस भाषा को पहले दिखाना है। + %1$s से %2$s + भाषा ढूँढें + भाषा जोडें + भाषा युग्म जोडें + स्रोत भाषा + लक्ष्य भाषा + पहले %1$s दिखाएँ + अभी कोई भाषा प्रस्तुति आद्यताएँ नहीं। ये स्वचालित रूप से बनाए जाएँगे जब अनुवाद होगा अथवा आप इनहें स्वयम हाथ से जोड सकते हैं। + आद्यता मिटाएँ विराम चलाएँ विकल्प सूची खोलें @@ -1270,6 +1328,7 @@ क्रमदोष सूचनापत्र प्राप्त क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी भेजें + यह सन्देश %1$s में अदृश्य हो जाएगा यह सन्देश %1$d दिनों में अदृश्य हो जाएगा हस्ताक्षरकर्ता चुनें पहले से ही सूची में @@ -1393,8 +1452,8 @@ अभिलेख शीर्षक परिचय चित्रालय अभिलेख सेवासंगणक - अंकीय तथ्याभिलेख - अंकीय अभिलेख शीर्षक + द्व्यंकीय अभिलेख + द्व्यंकीय अभिलेख शीर्षक चिकित्सा अभिलेख अनुचरण पोटलियाँ पुनःप्रकाशन (१६) @@ -1471,4 +1530,32 @@ ध्वनि सन्देश ध्वनि उत्तर विकि॰ + एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं। + अनुचरित सूची आयात + प्रयोक्ता चुनें अनुचरण करने के लिए + प्रयोक्ता परिचय जिससे आयात करना है + खोज, एनपुब॰१…, 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%% समय निरन्तर उपलब्ध + नामरूप्य स्थापना विकल्प + द्व्यंकरूप्य समन्वेषक (ओटीएस॰) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f385c1347..c1b1eed46 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -418,7 +418,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 @@ -673,6 +673,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: @@ -1052,6 +1053,29 @@ 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 Biztonsági szűrők Követettek importálása Új bejegyzés @@ -1099,6 +1123,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 @@ -1168,6 +1199,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 @@ -1295,6 +1329,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 +1558,9 @@ Összes kijelölése Üzemidő: %1$d%% Namecoin-beállítások + Bitcoin felfedező (OTS) + események + Közvetlen üzenetek + profilok + átjátszóbeállítások diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a10363d70..3e737905b 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -670,6 +670,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 +926,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 @@ -1047,6 +1050,29 @@ 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ż Filtry bezpieczeństwa Importuj Obserwujących Nowy post @@ -1282,6 +1308,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 +1326,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 diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7a20de62c..003f4da75 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -669,6 +669,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 +924,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 @@ -1045,6 +1048,29 @@ 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 Filtros de Segurança Importar Seguidos Novo Post @@ -1092,6 +1118,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 @@ -1161,6 +1194,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 @@ -1266,6 +1302,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 +1324,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 +1553,9 @@ Selecionar tudo %1$d%% de disponibilidade Configurações do Namecoin + Explorador Bitcoin (OTS) + eventos + DMs + perfils + configurações de Relay diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 64f743251..ad0ee1a94 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -668,6 +668,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 +923,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 @@ -1044,6 +1047,29 @@ 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 Säkerhetsfilter Importera följare Nytt inlägg @@ -1091,6 +1117,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 @@ -1160,6 +1193,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 @@ -1265,6 +1301,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 +1323,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 +1552,9 @@ Välj alla %1$d%% drifttid Namecoin-inställningar + Bitcoin Explorer (OTS) + händelser + DMs + profiler + relä inställningar diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 6d7eafd38..95b1bad1c 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -673,6 +673,7 @@ 写入到继电器 向该中继发送事件和过滤请求所使用的数据量 从该中继接收事件和过滤响应所使用的数据量 + 已保存的事件 尝试从 %1$s 获取中继器信息时出错 机主 使用者 @@ -1052,6 +1053,29 @@ 全球 短篇 国际象棋 + 钱包 + 余额 + 发送 + 接收 + 交易 + 未连接钱包 + 在打闪设置中设置Nostr Wallet Connect (NWC) 连接以使用钱包。 + 设置钱包 + + 粘贴 BOLT-11 发票 + 付款 + 支付成功 + 正在发送付款… + 聪金额 + 描述(可选) + 创建发票 + 正在创建发票… + 复制发票 + 尚无交易 + 正在加载… + 已收到 + 已发送 + 刷新 安全滤镜 导入关注 新帖子 @@ -1099,6 +1123,13 @@ 取消打闪拆分 添加内容警告 移除内容警告 + 添加过期日期 + 删除过期日期 + 过期日期 + 帖子将在此日期后被客户端隐藏 (NIP-40) + 选择到期日期和时间 + 在 %1$s 过期 + 到期时间 将 npub 显示为二维码 以二维码显示 nprofile 地址无效 @@ -1168,6 +1199,9 @@ 中继黑名单 中继黑名单 应用永远不会连接的中继 + 导出中继设置 + 导出为文本 + 导出为 ZIP (JSON) 打闪开发人员! 你的捐赠帮助我们做出不同的贡献。每个聪都很重要! 立即捐款 @@ -1295,6 +1329,7 @@ 找到了崩溃报告 要用私信将最近的崩溃报告发送给 Amethyst 吗?不会分享个人信息 发送它 + 此消息将在 %1$s 后消失 此消息将在 %1$d 天内消失 选择签名者 已经在列表中 @@ -1523,4 +1558,9 @@ 全选 %1$d%% 运行时间 Namecoin 设置 + 比特币资源管理器 (OTS) + 事件 + 私信 + 个人资料 + 中继设置 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a97cbbd93..3b5feb37b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -776,6 +776,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 +1216,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 @@ -1238,6 +1246,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 @@ -1780,4 +1814,8 @@ %1$d%% uptime Namecoin Settings Bitcoin Explorer (OTS) + events + DMs + profiles + relay settings 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 a48f700ac..697583552 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 af85d0b47..245a440a1 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/commons/build.gradle.kts b/commons/build.gradle.kts index 1d6ce95e1..c55f9e409 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 2dba6f760..c04a06f36 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 f9a1b51c3..0f5cb9693 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 674a38219..6725ef332 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 ea46a6db8..e640624d3 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 e4e0aa72d..be8d13a79 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 1a831f0b3..e1de172b1 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 5daa578c5..53c4ec730 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 d290cee85..9e0295efe 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 7ea0d4104..2630d212e 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 7362b1ac2..d28818a2d 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 41166aadb..bec0a2ff6 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 242adac53..7b9611921 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 50b96eee9..8f8174d98 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 ee6b91f12..511507d62 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 dab15b1f6..f4f989545 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 ccbd67b1e..3c47a957d 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 ddb1fe555..a8aa8bd35 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 3cff5f596..338eef364 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 7b3631fc7..3924aaa01 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 89684d1c9..1a3259529 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 583ebfd72..326474d8a 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 1bcf1a6b7..52a15572b 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 fd4eb5fb0..d41cc91b3 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 533fc64f7..b3775729f 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 32d726838..3d2025d7e 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 f3e0726d6..5d99988b4 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 bab4cda6f..34c17c3b9 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 40156d93c..345dd88af 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 a56999f41..73ee2a4d2 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 ba3d83567..252b34265 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/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index a66d32bab..f9c3c3f37 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 @@ -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 a5c197bb6..6a493dd04 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 f2261f6fd..e98d7c67d 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 23c05187a..349a44766 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 c02c969f4..0faf7f839 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 230f701c2..ae1054843 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 0dbbc39b0..cb093dfbb 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 fcff3c8ba..d37661830 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 4adcd72bc..f07e5875d 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 1b50a772f..992e24088 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 c5c13ff25..ad57bc5ec 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 c403b9054..52597a40d 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 6f3335c7c..dadf2805f 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 3d2219eab..515a8c5ca 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 635c438e5..dc56fa1ef 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 0200c6737..20f50c631 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 046f6c26c..e0218819e 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 2c58e6d83..3742241bf 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 000000000..88702439c --- /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 000000000..e7b1d053b --- /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 000000000..8a843955a --- /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 000000000..e914be294 --- /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 000000000..073e9cc41 --- /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 000000000..3cd27a017 --- /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 000000000..20302fba7 --- /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 000000000..fff17c1df --- /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 dc77eba39..21cd3a0a2 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 000000000..6cff2d8cf --- /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 000000000..bc1ea8177 --- /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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt new file mode 100644 index 000000000..1b4350787 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt @@ -0,0 +1,39 @@ +/* + * 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 + +enum class SearchSortOrder( + val label: String, +) { + NEWEST("Newest"), + OLDEST("Oldest"), + RELEVANCE("Relevance"), + NAME_AZ("A → Z"), + NAME_ZA("Z → A"), + ; + + companion object { + 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 7c295ef91..9a3f43f22 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/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 000000000..5a03ab61c --- /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 000000000..df5c3af77 --- /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 000000000..0534e5013 --- /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 000000000..bdf3b3cf6 --- /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/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 85c1c5607..1a728e171 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -23,9 +23,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")) 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 000000000..9c3e3b01a --- /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/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index daf10b553..2668756b8 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 e0f5086d1..ae3725798 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 @@ -86,9 +86,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 +108,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 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 5266f9621..e31000e7e 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/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index d4d3bac20..a7d4a633a 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 000000000..6a61b000d --- /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.PollNoteEvent +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, + PollNoteEvent.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 8aecc2f2d..f7ce8727d 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/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 1caa34d94..da405b6c4 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,265 @@ 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, + ) + } 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 +614,7 @@ private fun SearchHint( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - identifier, + example, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.primary, @@ -390,25 +641,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 +661,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 +674,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 +684,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/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index eed9c760f..75769e59f 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 @@ -214,7 +214,7 @@ fun UserProfileScreen( latestMetadataEvent = event } } - } catch (e: Exception) { + } catch (_: Exception) { // Ignore parse errors } } @@ -332,7 +332,7 @@ fun UserProfileScreen( } // Edit button for own profile - if (isOwnProfile && account?.isReadOnly == false) { + if (isOwnProfile && account.isReadOnly == false) { OutlinedButton( onClick = { editingDisplayName = displayName ?: "" 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 d8c7c1632..bae3732b1 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 67d1bcdf7..ea6c00113 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 f4a3c432c..573d03586 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/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index ce90b7ef6..b59abab0f 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 @@ -120,37 +122,42 @@ 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, + 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 = 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() }, + ) + } } } } 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 e58734588..a00dc029a 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 @@ -156,90 +150,47 @@ fun SinglePaneLayout( 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), ) { + // Always keep RootContent composed so state (e.g. search results) survives navigation + 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)) }, + ) 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/search/AdvancedSearchPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt new file mode 100644 index 000000000..4b18918c4 --- /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 000000000..2a8a36085 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -0,0 +1,379 @@ +/* + * 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.Card +import androidx.compose.material3.CardDefaults +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.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.KindRegistry +import com.vitorpamplona.amethyst.commons.search.SearchSortOrder +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +@Composable +fun SearchResultsList( + state: AdvancedSearchBarState, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + 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 -> + NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + } + if (textNotes.size > 5) { + item(key = "notes-expand") { + ExpandableSection( + remaining = textNotes.drop(5), + ) { event -> + NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + } + } + } + } + } + + // 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 -> + NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + } + if (articles.size > 5) { + item(key = "articles-expand") { + ExpandableSection( + remaining = articles.drop(5), + ) { event -> + NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + } + } + } + } + } + + // 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 -> + NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + } + } + } + + // 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 NotePreviewCard( + event: Event, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Kind badge + val kindName = KindRegistry.nameFor(event.kind) ?: "kind ${event.kind}" + Text( + kindName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + // Author (hex truncated) + Text( + event.pubKey.take(8) + "..." + event.pubKey.takeLast(4), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + // Timestamp + Text( + event.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(4.dp)) + // Content preview + Text( + event.content.take(200), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@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 000000000..348dcaf0f --- /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/resources/icon.icns b/desktopApp/src/jvmMain/resources/icon.icns new file mode 100644 index 000000000..c46a41b0b 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 000000000..61b727bfe Binary files /dev/null and b/desktopApp/src/jvmMain/resources/icon.ico differ 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 000000000..609a0fdfb --- /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/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 000000000..244213eba --- /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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b721a2ef..b3cf1a6bb 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,22 +53,24 @@ 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" 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" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } @@ -102,7 +106,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" } @@ -124,6 +127,14 @@ dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.crypto 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" } @@ -155,10 +166,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" } @@ -180,6 +190,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 488f904f1..f0d4f6815 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,21 +138,23 @@ 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") } } @@ -252,8 +275,15 @@ 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) } } @@ -263,7 +293,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 +317,6 @@ kotlin { } } - val iosX64Main by getting { - dependsOn(iosMain.get()) - } - val iosArm64Main by getting { dependsOn(iosMain.get()) } @@ -296,10 +331,6 @@ kotlin { } } - val iosX64Test by getting { - dependsOn(iosTest.get()) - } - val iosArm64Test by getting { dependsOn(iosTest.get()) } @@ -315,7 +346,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 000000000..9175d0818 --- /dev/null +++ b/quartz/src/androidDeviceTest/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 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 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 5dfcfb8ba..3eeae8055 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/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index c876c7b96..8ab91cfae 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -678,7 +678,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/utils/UriParser.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt deleted file mode 100644 index 7d157db80..000000000 --- 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/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt new file mode 100644 index 000000000..4d3eba299 --- /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 000000000..ed0ff95ae --- /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 000000000..4eba5b397 --- /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 000000000..58ca13df3 --- /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 000000000..0ce754e0f --- /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 000000000..d0f81a3bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -0,0 +1,148 @@ +/* + * 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.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.Response +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.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 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 000000000..34ee1b491 --- /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 000000000..c9ebbe5ea --- /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/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt new file mode 100644 index 000000000..b1ea3d05d --- /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/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index eba39649b..ceb4b36de 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/tags/aTag/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt index d79c73717..f3e9df472 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 72abf7546..4d4a44aa8 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 000000000..025e586cc --- /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 000000000..7797cf337 --- /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 000000000..9787b10eb --- /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/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt index 2a766ef0b..452c99b2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt @@ -52,25 +52,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/LnZapPaymentResponseEvent.kt index 9a7de947c..a75ba45e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt @@ -26,6 +26,8 @@ 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.utils.TimeUtils @Immutable class LnZapPaymentResponseEvent( @@ -55,6 +57,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/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt new file mode 100644 index 000000000..05cb6e21f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -0,0 +1,260 @@ +/* + * 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 + +/** + * 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 000000000..947bbecf8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -0,0 +1,301 @@ +/* + * 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 + +/** + * 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 e3694b02a..53eb83d7f 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/Notification.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt new file mode 100644 index 000000000..2c1bbdfc3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/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 + +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/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt new file mode 100644 index 000000000..cef350602 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/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 + +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/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt new file mode 100644 index 000000000..42ca7529a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/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 + +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/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt new file mode 100644 index 000000000..afc6474ba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/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 + +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/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt new file mode 100644 index 000000000..d5884bbb1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.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.quartz.nip47WalletConnect + +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.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/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt new file mode 100644 index 000000000..c0db67e71 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/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 + +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/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt new file mode 100644 index 000000000..a275ac469 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/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 + +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/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md new file mode 100644 index 000000000..0d372e1a4 --- /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/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index b53f3482e..b338438ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -27,15 +27,235 @@ abstract class Request( var method: String? = null, ) : OptimizedSerializable -// PayInvoice Call +// pay_invoice class PayInvoiceParams( var invoice: String? = null, + var amount: Long? = null, + var metadata: Map? = null, ) class PayInvoiceMethod( var params: PayInvoiceParams? = null, -) : Request("pay_invoice") { +) : 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/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt index cb449136a..6547443ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt @@ -27,49 +27,131 @@ abstract class Response( val resultType: String, ) : OptimizedSerializable -// PayInvoice Call +// 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("pay_invoice") { +) : 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("pay_invoice") { +) : Response(NwcMethod.PAY_INVOICE) { class PayInvoiceErrorParams( - val code: ErrorType? = null, + val code: NwcErrorCode? = 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. - } +} + +// 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/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt new file mode 100644 index 000000000..472972cb6 --- /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 000000000..715bd6bee --- /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.HoldInvoiceAcceptedData +import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.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 000000000..87e7e0a0f --- /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.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionParams +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsParams +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendParams +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageParams +import com.vitorpamplona.quartz.nip47WalletConnect.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 000000000..e7b9bed88 --- /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.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.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/tags/EncryptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt new file mode 100644 index 000000000..3d732528f --- /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 000000000..cb42cce54 --- /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/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt new file mode 100644 index 000000000..f6fb5622a --- /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 997e9921c..4dd69fbd4 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 eee5e8689..4679e4665 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -78,6 +78,8 @@ 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.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -256,6 +258,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) diff --git a/quartz/src/commonTest/kotlin/android/util/Log.kt b/quartz/src/commonTest/kotlin/android/util/Log.kt index ba75a2cab..eb9574b08 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/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 4a6ccb8f9..c9f3040b0 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 5feaa1621..eec79ca77 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/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt new file mode 100644 index 000000000..4b70331eb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -0,0 +1,588 @@ +/* + * 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 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 000000000..18a1f577c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt @@ -0,0 +1,159 @@ +/* + * 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 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 000000000..825069de6 --- /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 000000000..39fd0f56b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.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.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +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 000000000..82d55cc64 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.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.quartz.nip47WalletConnect + +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 000000000..f8806444b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.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.nip47WalletConnect + +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 000000000..3b392eb55 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt @@ -0,0 +1,144 @@ +/* + * 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.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 000000000..5b43e7bf9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -0,0 +1,404 @@ +/* + * 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 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 000000000..ff7ad12a7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -0,0 +1,391 @@ +/* + * 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 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 000000000..801058935 --- /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/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt index d77753912..05e0bbbc0 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,66 @@ */ 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 fromJsonToRumor(json: String): Rumor = + try { + KotlinSerializationMapper.fromJsonToRumor(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun toJson(tags: Array>): String = TODO("Not yet implemented") + actual fun toJson(tags: Array>): String = KotlinSerializationMapper.toJson(tags) - actual inline fun fromJsonTo(json: String): T = 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 = TODO("Not yet implemented") + 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 24676f812..708f6f815 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 05c06c547..ba0890c38 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/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 000000000..fa07bc58e --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,81 @@ +/* + * 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 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 c011e785e..c0cee1c9d 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 65af77421..5a501c070 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 @@ -78,6 +79,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 bec5f11b4..34757f17c 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.Notification 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.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()) 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 dd5cfa0bb..3858f3236 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 470360c80..345b9d9c4 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 c56dd72b1..b0ba1d6b7 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 000000000..6f3d2ccab --- /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.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.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 000000000..591197df4 --- /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.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.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 8e0c5c177..07e6eac96 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.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.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 000000000..ac34c82a7 --- /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.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.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 f903f0171..ccb98c8b7 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.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.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 000000000..933025b63 --- /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.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.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 42f5dd891..dbd0bbb27 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 ebdf48970..3e75d0dc8 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 000000000..959b4de53 --- /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/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index bff422a78..e974d964d 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 000000000..d58ed363f --- /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 e583343c5..7334cb158 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/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index 5f289a4c6..e942fe134 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 f7faca43c..7afd9caad 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 511a0d2a5..00c37f00e 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 ddcb9fde9..de3623968 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/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 000000000..fa07bc58e --- /dev/null +++ b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,81 @@ +/* + * 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 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 52822c49c..0c975e7c3 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 a60f3fda6..000000000 --- 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