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
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