diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 6b51cdab0a..f85c0d9a18 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -119,7 +119,7 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat **Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation **SKILL.md sections:** -- iOS source sets: iosMain, iosX64Main, iosArm64Main +- iOS source sets: iosMain, iosArm64Main - Swift interop: type mapping, nullability - expect/actual iOS: 10+ examples from quartz/iosMain - XCFramework setup: baseName = "quartz-kmpKit" diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh old mode 100644 new mode 100755 diff --git a/.claude/settings.json b/.claude/settings.json index 80496cbbab..4ab4d17262 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "./gradlew spotlessApply", + "command": "./gradlew spotlessApply 2>/dev/null || spotless-apply", "timeout": 120 } ] diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 7754d58029..fc9693e924 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -745,8 +745,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 - versionCode = 430 - versionName = "1.04.2" + versionCode = 435 + versionName = "1.06.3" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md new file mode 100644 index 0000000000..fe0163c5de --- /dev/null +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -0,0 +1,96 @@ +--- +name: find-missing-translations +description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language +--- + +# Find Missing Translations + +## Overview + +Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them. + +## When to Use + +- Need to find untranslated strings for a specific locale +- Preparing a batch of strings for a translator +- Checking translation coverage after adding new features + +## Target Locales + +The default set of locales (unless the user specifies otherwise): + +| Locale | Language | Directory | +|--------|----------|-----------| +| `cs-rCZ` | Czech | `values-cs-rCZ` | +| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` | +| `sv-rSE` | Swedish | `values-sv-rSE` | +| `de-rDE` | German | `values-de-rDE` | + +## Technique + +### 1. Identify files + +``` +Default: amethyst/src/main/res/values/strings.xml +Target: amethyst/src/main/res/values-/strings.xml +``` + +### 2. Find missing keys using cs-rCZ as reference + +Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales. + +```bash +# Extract translatable keys from default (exclude translatable="false") +comm -23 \ + <(grep 'Valid + Valid from %1$s + Lists +``` + +Also check `` and `` tags using the same approach if the project uses them. + +**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?" + +### 5. Adding translations (if approved) + +When adding translated strings to locale files: + +- **Append new strings at the bottom** of the file, just before the closing `` tag. +- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering. + +## Common Mistakes + +- **Forgetting `translatable="false"`** — these should never appear in locale files +- **Not checking string-arrays/plurals** — only checking `` misses other resource types +- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere +- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately \ No newline at end of file diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 3f38203989..9417851c41 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -47,7 +47,7 @@ ### :quartz (KMP Nostr Library) **Type:** Kotlin Multiplatform Library -**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64) +**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64) **Dependencies:** - External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable - Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain @@ -127,7 +127,6 @@ commonMain (base) │ ├─ androidMain (Android platform) │ └─ jvmMain (Desktop platform) └─ iosMain (iOS platform) - ├─ iosX64Main ├─ iosArm64Main └─ iosSimulatorArm64Main ``` diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md index b0aa2f8d6d..6f8e43f42d 100644 --- a/.claude/skills/kotlin-multiplatform/SKILL.md +++ b/.claude/skills/kotlin-multiplatform/SKILL.md @@ -110,8 +110,8 @@ Think of source sets as a dependency graph, not folders. │ - Jackson │ │ │ │ - OkHttp │ └────┬─────────────┘ └───┬───────────┬───┘ │ - │ │ ├─→ iosX64Main - ▼ ▼ ├─→ iosArm64Main + │ │ │ + ▼ ▼ ├─→ iosArm64Main ┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main │android │ │jvmMain │ │Main │ │(Desktop) │ @@ -252,7 +252,7 @@ expect fun currentTimeSeconds(): Long **iOS (iosMain):** - Active development, framework configured -- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main +- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main - Platform APIs via platform.posix, Security framework ### Web, wasm - Future Targets diff --git a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md index ec09e00332..b950c57d5b 100644 --- a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md +++ b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md @@ -29,7 +29,7 @@ Visual guide to source set organization with concrete examples from the codebase │ - Jackson │ │ - Platform libs │ │ - OkHttp │ └───────┬───────────┘ └────┬─────────┬───┘ │ - │ │ ├─→ iosX64Main (simulator Intel) + │ │ │ │ │ ├─→ iosArm64Main (device ARM64) │ │ └─→ iosSimulatorArm64Main (Apple Silicon) ▼ ▼ @@ -238,7 +238,6 @@ iosMain { } } -val iosX64Main by getting { dependsOn(iosMain.get()) } val iosArm64Main by getting { dependsOn(iosMain.get()) } val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } ``` @@ -249,7 +248,6 @@ val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } - Different from Android/Desktop **Architecture targets:** -- iosX64Main: Intel simulator - iosArm64Main: Device (iPhone, iPad) - iosSimulatorArm64Main: Apple Silicon simulator @@ -326,7 +324,7 @@ commonMain | androidMain | jvmAndroid | Android framework | Activity, ViewModel | | jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar | | iosMain | commonMain | iOS platform | Security framework | -| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific | +| iosMain | Simulator (Intel) | Architecture-specific | | iosArm64Main | iosMain | Device (ARM64) | Architecture-specific | | jsMain | commonMain | JS/DOM | Web (future) | | wasmMain | commonMain | wasm APIs | WebAssembly (future) | diff --git a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md index 7ccf720fb8..c2a4d71299 100644 --- a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md +++ b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md @@ -76,7 +76,6 @@ fun main() = application { **Source sets:** - iosMain (common iOS code) -- iosX64Main (Intel simulator) - iosArm64Main (device - iPhone/iPad) - iosSimulatorArm64Main (Apple Silicon simulator) @@ -110,7 +109,7 @@ actual object Secp256k1Instance { ```kotlin // quartz/build.gradle.kts kotlin { - listOf(iosX64(), iosArm64(), iosSimulatorArm64()) + listOf(macosArm64(), iosArm64(), iosSimulatorArm64()) .forEach { target -> target.binaries.framework { baseName = "quartz-kmpKit" @@ -310,7 +309,7 @@ fun parseJson(json: String): Event { - Manual desktop app testing **iOS:** -- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.) +- Unit tests: iosTest (iosArm64Test, etc.) - Simulator/device testing **Web (future):** diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index a103377d8b..e0d193b0f8 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.05.1` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.05.1" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.05.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` @@ -609,7 +609,7 @@ SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP- import com.vitorpamplona.quartz.nip01Core.store.EventStore import android.content.Context -val store = EventStore(context) +val store = EventStore() // Insert store.insert(event) diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index e1d9895214..eae28c616f 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.05.1 +com.vitorpamplona.quartz:quartz:1.06.3 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.05.1" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.05.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.05.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index 246c840e37..3ebfdd9b70 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.05.1 +com.vitorpamplona.quartz:quartz:1.06.3 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9bb6fd2951..0377b37359 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,22 +6,26 @@ on: push: branches: [main] +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 @@ -46,16 +50,16 @@ jobs: shell: bash 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 @@ -72,7 +76,7 @@ jobs: if: ${{ always() && matrix.os == 'ubuntu-latest' }} - name: Upload Test Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 if: ${{ always() && matrix.os == 'ubuntu-latest' }} with: name: Test Reports @@ -84,16 +88,16 @@ jobs: timeout-minutes: 30 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 @@ -106,13 +110,13 @@ jobs: 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 @@ -121,19 +125,19 @@ jobs: 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 @@ -163,16 +167,16 @@ jobs: shell: bash 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 @@ -185,7 +189,7 @@ jobs: run: ./gradlew :desktopApp:${{ matrix.task }} - name: Upload Desktop Distribution - uses: actions/upload-artifact@v4 + 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 70f337ecdf..beb08874b5 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -27,16 +27,16 @@ jobs: 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 @@ -263,16 +263,16 @@ jobs: shell: bash 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 diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index d2707654d0..923e7fa492 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: crowdin action uses: crowdin/github-action@v2 diff --git a/.gitignore b/.gitignore index 945f2e02a8..de1c06cf0b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ /captures .cxx +# superpowers skill +.superpowers +docs/brainstorms +docs/superpowers # Built application files *.apk @@ -153,3 +157,8 @@ TASKS.md # Claude Code local settings .claude/settings.local.json + +# Downloaded VLC binaries (vlc-setup plugin) +desktopApp/src/jvmMain/appResources/linux/ +desktopApp/src/jvmMain/appResources/macos/ +desktopApp/src/jvmMain/appResources/windows/ diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 513727d825..fddb100a7e 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -7,6 +7,7 @@ Ohne Kompression H.265/HEVC-Codec verwenden Bessere Qualität bei kleinerer Dateigröße, aber nicht alle Geräte unterstützen die H.265-Wiedergabe. + Private Metadaten entfernen + Versucht, private Metadaten aus unterstützten Mediendateien vor dem Hochladen zu entfernen + Metadaten konnten nicht entfernt werden + Dieses Dateiformat unterstützt das Entfernen von Metadaten nicht. Private Informationen wie Standort und Geräteinformationen können enthalten sein. Trotzdem hochladen? + Trotzdem hochladen + Private Metadaten konnten nicht aus der Mediendatei entfernt werden. Hochladen abgebrochen. + Hochladen abgebrochen Entwurf bearbeiten Einloggen mit QR-Code Route @@ -1075,6 +1095,9 @@ anz der Bedingungen ist erforderlich Empfangen Gesendet Aktualisieren + Alle + Zaps + Keine Zaps Sicherheitsfilter Folgeliste importieren Neuer Beitrag @@ -1082,6 +1105,13 @@ anz der Bedingungen ist erforderlich Neue Community-Notiz Neues Produkt Neuer Geo-Exklusiver Beitrag + Neuer Artikel + Titel + Zusammenfassung (optional) + Cover-Bild-URL (optional) + Schreib deinen Artikel in Markdown… + Vorschau + Bearbeiten Alle Reaktionen auf diesen Beitrag öffnen Alle Reaktionen auf diesen Beitrag schließen Antworten @@ -1142,6 +1172,7 @@ anz der Bedingungen ist erforderlich Gute Optionen sind:\n - auth.nostr1.com (gratis)\n - inbox.nostr.wine (bezahlt)\n - relay.0xchat.com (gratis) Fügen Sie 1–3 Relais ein, die als Ihr privater Posteingang dienen sollen. DM Posteingangs-Relais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Jetzt einrichten + DM-Inbox-Relays nicht gefunden. Nachrichten können nicht zugestellt werden, bis die Relay-Liste konfiguriert wurde. Suchrelais Richten Sie Ihre Suchrelais ein Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern. @@ -1218,6 +1249,7 @@ anz der Bedingungen ist erforderlich OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. + Artikel bearbeiten Beitrag bearbeiten Vorschlag zur Verbesserung Ihres Beitrags Zusammenfassung der Änderungen @@ -1293,6 +1325,10 @@ anz der Bedingungen ist erforderlich Meine Listen Benutzer Liste zum Filtern des Feeds auswählen + Feeds + Hash-Tags + Gemeinschaften + Listen Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1557,5 +1593,91 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen + Relay-Synchronisierung + Relay-Synchronisierung + Veröffentliche deine Ereignisse erneut auf allen bekannten Relays, um deine Outbox-, Inbox- und DM-Relays aktuell zu halten. Erfordert WLAN — dies kann viele Daten verbrauchen. + Relay-Synchronisierung öffnen… + Was passiert + Dieses Tool durchsucht alle Relays, die deine App gesehen hat, und verteilt deine Ereignisse an die richtigen Ziele: + Lade alle Ereignisse herunter, die du erstellt hast, und sende sie an deine Outbox-Relays. + Lade alle Ereignisse herunter, die dich erwähnen, und sende sie an deine Inbox-Relays. + Lade alle Direktnachrichten herunter, die an dich gerichtet sind, und sende sie an deine DM-Relays. + ⚠ Du scheinst eine getaktete oder mobile Verbindung zu nutzen. Dieser Vorgang kann sehr viele Daten übertragen. Verbinde dich vor dem Start mit einem WLAN. + Mobile Daten verwenden? + Synchronisierung starten + Trotzdem starten (mobile Daten) + Pausen + Fortsetzen + Neu starten + Abbrechen + Relais: %1$d / %2$d + Ereignisse verteilt: %1$d neue von %2$d gesendeten und %3$d empfangenen + Synchronisierung pausiert + %1$d von %2$d Relays abgeschlossen — bisher %3$d Ereignisse verteilt. Tippe auf Fortsetzen. + Synchronisierung abgeschlossen + %1$d Ereignisse an Ziel-Relays weitergeleitet von %2$d empfangenen. + %1$d Ereignisse von Ziel-Relays als neu akzeptiert. + Abgeschlossen in %1$d Sekunden. + Synchronisierungsfehler + Senden an + Outbox + Inbox + DMs + Wird geprüft (%1$d Relays) + Abgeschlossen (%1$d Relays) + ges. %1$s + empf. %1$s + neu %1$s + keine Ereignisse Bitcoin Explorer (OTS) + ereignisse + DMs + profile + relaiseinstellungen + Zuletzt gesehen vor %1$s + <%1$s + Verbinden + Herunterladen + Fehler + Abgeschlossen + + Als gelesen markieren + Notiz-Aktionen + Profilaktionen + Medienaktionen + Wiedergabe + Paket-Aktionen + Listenaktionen + Lesezeichen-Aktionen + Gruppenaktionen + Lesezeichen hinzufügen + Mitglied hinzufügen + Listenverwaltung + Link-Aktionen + Exportieren + Attestierung + Gültig + Ungültig + Akzeptiert + Abgelehnt + Wird verifiziert + Verifiziert + Widerrufen + Gültig ab %1$s + Gültig bis %1$s + Attestierungsanfrage + Attestierung für ein Ereignis anfordern + Empfehlung des Attestierers + Empfohlen für Arten: %1$s + Kompetenz des Attestierers + Kompetent für die Verifizierung von Arten: %1$s + Bestätigt + Beantragt Attestierung für + Zeitraum + Von + Bis + Jetzt + Gesamter Zeitraum + Letzte Synchronisierung: %1$s + Seit letzter Synchronisierung diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 7e962d5e02..e30acbafd6 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen. Autor zum Folge-Set hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste + Symbol für Liste %1$s ist nicht in dieser Liste Deine Folge-Sets Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen. diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index c4e013be18..ebe34bf4e5 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -515,4 +515,5 @@ Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 29171ea5f1..fec8238fe1 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -8,4 +8,5 @@ Show Anyway 👀 + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 69a3a6a64d..1719ad9c97 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -443,4 +443,5 @@ Mesaĝi la Uzanto Okej + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 5ef8dfde6b..ff871b996b 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Imagen de grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Icono del transmisor Autor desconocido @@ -100,10 +103,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Imagen de vista previa para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se ha encontrado la descripción "Sobre nosotros…" @@ -118,15 +123,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Fuente de mensajes privados Fuente de chat público Fuente global Fuente de búsqueda + Buscar y añadir usuario + Añadir un usuario Añadir transmisor Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -140,6 +151,7 @@ Pronombres Dirección LN Dirección LN (antigua) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería La descarga del vídeo ha comenzado… @@ -148,11 +160,14 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Cargar imagen + Cargar archivo Hacer una foto Grabar un video Grabar un mensaje Grabar un mensaje Haz clic y mantén pulsado para grabar un mensaje + Volver a grabar + Grabación Cargando… El usuario no tiene una configuración de dirección Lightning para recibir sats "responde aquí… " @@ -353,6 +368,25 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Icono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores + Transmitir listas de marcadores + Eliminar lista de marcadores + Ver publicaciones + Ver artículos + Ver enlaces + Ver hashtags + Aún no tienes ninguna lista de marcadores. Pulsa el nuevo botón de abajo para crear una. + Publicaciones privadas + Publicaciones privadas(%1$s) + Publicaciones públicas + Eliminar de la lista de marcadores + Los metadatos de las listas de marcadores son visibles para cualquier usuario de Nostr. Solo los miembros privados están cifrados. + Mover a público + Mover a privado Servicio de conexión a monedero Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a monedero @@ -360,11 +394,21 @@ Secreto de conexión a monedero Clave secreta de conexión a monedero clave privada nsec / hex + Conectada + No conectada + Avanzado: introduce manualmente los detalles de la conexión + Cantidad de zap rápido + Aparece al presionar el botón de zap. Pulsa una cantidad para eliminarla. Si la dejas vacía, se abrirá el cuadro de diálogo para introducir una cantidad cada vez. + Privacidad de zaps + Controla cómo se muestra tu identidad al enviar un zap. + Conectar cartera + Ver fuente de relés Destinar cantidad en sats Publicar encuesta Campos obligatorios: Destinatarios de zaps Descripción de encuesta principal… + Opción Opción %s Descripción de opción de encuesta Campos opcionales: @@ -372,6 +416,7 @@ Zap máximo Consenso (0–100)% + Fecha y hora de cierre de la encuesta Cerrar después de días No se puede votar @@ -457,11 +502,26 @@ Añadir autor a conjunto de seguimiento Añadir o eliminar usuario de las listas, o crear una nueva lista con este usuario. + Perfiles privados + miembro + miembros + Sin miembros + Vacío %1$s no se encuentra en esta lista + %1$s no es miembro + Tus listas y %1$s Tus conjuntos de seguimiento No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Pulsa abajo para actualizar o utiliza el menú para crear uno. Ha habido un problema al recuperar: %1$s Crear nueva lista + Nueva lista con membresía de %1$s + Crea un nuevo conjunto de seguimiento y añade a %1$s como miembro de %2$s. + Nueva lista de seguimiento + Nuevo paquete de seguimiento + Copiar/Clonar lista de seguimiento + Modificar descripción + Esta lista no tiene una descripción + Descripción actual: Nombre del conjunto Descripción del conjunto (opcional) Crear conjunto @@ -568,13 +628,27 @@ Contacto NIP compatibles Tasas de admisión + Publicación + Pagos %1$s URL de pagos + Público objetivo + Políticas y enlaces + Comisiones y pagos Limitaciones Países Idiomas Etiquetas + Temas + Todos los países + Todos los idiomas Política de publicación + Política de privacidad + Términos y condiciones Errores y avisos de este relé + Suscripciones activas + Eventos de outbox pendientes + Suscripciones REQ (%1$d) + Suscripciones COUNT (%1$d) Longitud del mensaje Suscripciones Filtros @@ -582,9 +656,20 @@ Prefijo mínimo Etiquetas de evento máximas Longitud del contenido + Tamaño de contenido + Conectividad + Control de acceso PoW mínima Autenticación + Se requiere autorización Pago + Se requiere pago + Longitud máxima del mensaje + Suscripciones máximas + Filtros máximos por suscripción + Límite máximo (devolución de eventos) + Límite predeterminado (devolución de eventos) + Longitud máxima de subID Token de Cashu Canjear Enviar a monedero de zaps @@ -603,6 +688,7 @@ Etiquetas seguidas Relés Paquetes de seguimiento + Estas son listas de usuarios que recomiendas a otras personas. Solo se permiten usuarios públicos. Lecturas Algoritmos de fuentes Mercado @@ -614,8 +700,11 @@ Esta comunidad no tiene descripción. Habla con el propietario para agregar una. Contenido delicado Agrega una advertencia de contenido sensible antes de mostrarlo + Preferencias de interfaz de usuario Preferencias de la app Preferencias de usuario + Traducciones + Reacciones Configuración Siempre Solo Wi-Fi @@ -849,6 +938,9 @@ No se pudo preparar la información del encabezado: %1$s Compresión cancelada La compresión no pudo devolver un archivo + Muchos servidores no aceptan archivos cifrados en cuentas gratuitas. Puedes volver a intentarlo sin cifrado. + Reintentar sin cifrado + Advertencia: Sin cifrado, cualquiera con el enlace del archivo puede ver el contenido. Calidad multimedia Selecciona baja calidad para comprimir tus archivos multimedia a un tamaño más pequeño con menos calidad, alta calidad para comprimir a un archivo más grande con mayor calidad, o sin comprimir para subir los medios sin compresión. Baja @@ -867,7 +959,20 @@ Notificaciones Global Cortos + Ajedrez + Cartera + Saldo + Enviar + Recibir + Transacciones + No hay cartera conectada + Configure una conexión de Nostr Wallet Connect (NWC) en la configuración de zaps para usar la cartera. + Configurar cartera + sats + Pegar una factura BOLT-11 + Pagar Filtros de seguridad + Importar seguidos Nueva publicación Nuevos cortos: imágenes o vídeos Nueva nota comunitaria @@ -880,6 +985,20 @@ Me gusta Zap Cambiar reacciones rápidas + Fila de reacciones + Configura qué botones de reacción se muestran, su orden y si se muestran o no. + Activado + Mostrar recuento + Reordenar + Responder + Responder a esta nota + Impulsar + Volver a publicar o citar esta nota + Me gusta + Reaccionar a esta nota con un emoji + Zap + Enviar un pago de Lightning al autor + Compartir Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -1064,4 +1183,5 @@ Este mensaje desaparecerá en %1$d días Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 1639273c36..8a61866011 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Foto de grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Ícono de relé Autor desconocido @@ -99,10 +102,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Vista previa de imagen de tarjeta para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se encontró la descripción "Quiénes somos…" @@ -117,15 +122,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Feed de mensajes privados Feed del chat público Feed global Feed de búsqueda + Buscar y agregar usuario + Agregar un usuario Agregar un relé Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -139,6 +150,7 @@ Pronombres Dirección de Lightning URL de Lightning (obsoleta) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería Comenzó la descarga del video… @@ -147,10 +159,13 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Subir imagen + Subir archivo Tomar una foto Grabar un mensaje Grabar un mensaje Haz clic y mantén presionado para grabar un mensaje + Volver a grabar + Grabación Subiendo… El usuario no tiene configurada una dirección de Lightning para recibir sats "responde aquí… " @@ -349,6 +364,25 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Ícono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores + Transmitir listas de marcadores + Eliminar lista de marcadores + Ver publicaciones + Ver artículos + Ver enlaces + Ver hashtags + Aún no tienes ninguna lista de marcadores. Toca el nuevo botón de abajo para crear una. + Publicaciones privadas + Publicaciones privadas(%1$s) + Publicaciones públicas + Eliminar de la lista de marcadores + Los metadatos de las listas de marcadores son visibles para cualquier usuario de Nostr. Solo los miembros privados están cifrados. + Mover a público + Mover a privado Servicio de conexión a billetera Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a billetera @@ -356,11 +390,21 @@ Secreto de conexión a billetera Clave secreta de conexión a billetera clave privada nsec / hex + Conectada + No conectada + Avanzado: introduce manualmente los detalles de la conexión + Cantidad de zap rápido + Aparece al presionar el botón de zap. Toca una cantidad para eliminarla. Si la dejas vacía, se abrirá el cuadro de diálogo para introducir una cantidad cada vez. + Privacidad de zaps + Controla cómo se muestra tu identidad al enviar un zap. + Conectar billetera + Ver fuente de relés Destinar cantidad en sats Publicar encuesta Campos obligatorios: Destinatarios de zaps Descripción de encuesta principal… + Opción Opción %s Descripción de opción de encuesta Campos opcionales: @@ -368,6 +412,7 @@ Zap máximo Consenso (0–100)% + Fecha y hora de cierre de la encuesta Cerrar después de días No se puede votar @@ -452,11 +497,26 @@ Agregar autor a conjunto de seguimiento Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario. + Perfiles privados + miembro + miembros + Sin miembros + Vacío %1$s no se encuentra en esta lista + %1$s no es miembro + Tus listas y %1$s Tus conjuntos de seguimiento No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Toca abajo para actualizar o utiliza el menú para crear uno. Hubo un problema al recuperar: %1$s Crear nueva lista + Nueva lista con membresía de %1$s + Crea un nuevo conjunto de seguimiento y añade a %1$s como miembro de %2$s. + Nueva lista de seguimiento + Nuevo paquete de seguimiento + Copiar/Clonar lista de seguimiento + Modificar descripción + Esta lista no tiene una descripción + Descripción actual: Nombre del conjunto Descripción del conjunto (opcional) Crear conjunto @@ -563,13 +623,27 @@ Contacto NIP compatibles Tarifas de admisión + Publicación + Pagos %1$s URL de pagos + Público objetivo + Políticas y enlaces + Comisiones y pagos Limitaciones Países Idiomas Etiquetas + Temas + Todos los países + Todos los idiomas Política de publicación + Política de privacidad + Términos y condiciones + N/D Errores y avisos de este relé + Suscripciones activas + Eventos de outbox pendientes + Suscripciones REQ (%1$d) Longitud del mensaje Suscripciones Filtros @@ -577,9 +651,20 @@ Prefijo mínimo Etiquetas de evento máximas Longitud del contenido + Tamaño de contenido + Conectividad + Control de acceso PoW mínima Autenticación + Se requiere autorización Pago + Se requiere pago + Longitud máxima del mensaje + Suscripciones máximas + Filtros máximos por suscripción + Límite máximo (devolución de eventos) + Límite predeterminado (devolución de eventos) + Longitud máxima de subID Token de Cashu Canjear Enviar a billetera de zaps @@ -598,6 +683,7 @@ Etiquetas seguidas Relés Paquetes de seguimiento + Estas son listas de usuarios que recomiendas a otras personas. Solo se permiten usuarios públicos. Lecturas Algoritmos del feed Mercado @@ -609,8 +695,11 @@ Esta comunidad no tiene descripción. Habla con el propietario para agregar una. Contenido delicado Agrega una advertencia de contenido sensible antes de mostrarlo + Preferencias de interfaz de usuario Preferencias de la app Preferencias de usuario + Traducciones + Reacciones Configuración Siempre Solo Wi-Fi @@ -839,6 +928,9 @@ No se pudo preparar la información del encabezado: %1$s Compresión cancelada La compresión no pudo devolver un archivo + Muchos servidores no aceptan archivos cifrados en cuentas gratuitas. Puedes volver a intentarlo sin cifrado. + Reintentar sin cifrado + Advertencia: Sin cifrado, cualquiera con el enlace del archivo puede ver el contenido. Calidad multimedia Selecciona baja calidad para comprimir tus archivos multimedia a un tamaño más pequeño con menos calidad, alta calidad para comprimir a un archivo más grande con mayor calidad, o sin comprimir para subir los medios sin compresión. Baja @@ -857,7 +949,20 @@ Notificaciones Global Cortos + Ajedrez + Billetera + Saldo + Enviar + Recibir + Transacciones + No hay billetera conectada + Configure una conexión de Nostr Wallet Connect (NWC) en la configuración de zaps para usar la billetera. + Configurar billetera + sats + Pegar una factura BOLT-11 + Pagar Filtros de seguridad + Importar seguidos Nueva publicación Nuevos cortos: imágenes o videos Nueva nota comunitaria @@ -870,6 +975,20 @@ Me gusta Zap Cambiar reacciones rápidas + Fila de reacciones + Configura qué botones de reacción se muestran, su orden y si se muestran o no. + Activado + Mostrar recuento + Reordenar + Responder + Responder a esta nota + Impulsar + Volver a publicar o citar esta nota + Me gusta + Reaccionar a esta nota con un emoji + Zap + Enviar un pago de Lightning al autor + Compartir Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -1040,4 +1159,5 @@ Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index ce5ac0327a..817fc89e87 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Imagen del grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Ícono de relé Autor desconocido @@ -99,10 +102,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Vista previa de imagen de tarjeta para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se encontró la descripción "Quiénes somos…" @@ -117,15 +122,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Feed de mensajes privados Feed del chat público Feed global Feed de búsqueda + Buscar y agregar usuario + Agregar un usuario Agregar un relé Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -139,6 +150,7 @@ Pronombres Dirección de Lightning URL de Lightning (obsoleta) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería Comenzó la descarga del video… @@ -147,10 +159,13 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Subir imagen + Subir archivo Tomar una foto Grabar un mensaje Grabar un mensaje Haz clic y mantén presionado para grabar un mensaje + Volver a grabar + Grabación Subiendo… El usuario no tiene configurada una dirección de Lightning para recibir sats "responde aquí… " @@ -349,6 +364,25 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Ícono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores + Transmitir listas de marcadores + Eliminar lista de marcadores + Ver publicaciones + Ver artículos + Ver enlaces + Ver hashtags + Aún no tienes ninguna lista de marcadores. Toca el nuevo botón de abajo para crear una. + Publicaciones privadas + Publicaciones privadas(%1$s) + Publicaciones públicas + Eliminar de la lista de marcadores + Los metadatos de las listas de marcadores son visibles para cualquier usuario de Nostr. Solo los miembros privados están cifrados. + Mover a público + Mover a privado Servicio de conexión a billetera Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a billetera @@ -356,11 +390,21 @@ Secreto de conexión a billetera Clave secreta de conexión a billetera clave privada nsec / hex + Conectada + No conectada + Avanzado: introduce manualmente los detalles de la conexión + Cantidad de zap rápido + Aparece al presionar el botón de zap. Toca una cantidad para eliminarla. Si la dejas vacía, se abrirá el cuadro de diálogo para introducir una cantidad cada vez. + Privacidad de zaps + Controla cómo se muestra tu identidad al enviar un zap. + Conectar billetera + Ver fuente de relés Destinar cantidad en sats Publicar encuesta Campos obligatorios: Destinatarios de zaps Descripción de encuesta principal… + Opción Opción %s Descripción de opción de encuesta Campos opcionales: @@ -368,6 +412,7 @@ Zap máximo Consenso (0–100)% + Fecha y hora de cierre de la encuesta Cerrar después de días No se puede votar @@ -452,11 +497,26 @@ Agregar autor a conjunto de seguimiento Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario. + Perfiles privados + miembro + miembros + Sin miembros + Vacío %1$s no se encuentra en esta lista + %1$s no es miembro + Tus listas y %1$s Tus conjuntos de seguimiento No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Toca abajo para actualizar o utiliza el menú para crear uno. Hubo un problema al recuperar: %1$s Crear nueva lista + Nueva lista con membresía de %1$s + Crea un nuevo conjunto de seguimiento y añade a %1$s como miembro de %2$s. + Nueva lista de seguimiento + Nuevo paquete de seguimiento + Copiar/Clonar lista de seguimiento + Modificar descripción + Esta lista no tiene una descripción + Descripción actual: Nombre del conjunto Descripción del conjunto (opcional) Crear conjunto @@ -563,13 +623,28 @@ Contacto NIP compatibles Comisiones de admisión + Publicación + Pagos %1$s URL de pagos + Público objetivo + Políticas y enlaces + Comisiones y pagos Limitaciones Países Idiomas Etiquetas + Temas + Todos los países + Todos los idiomas Política de publicación + Política de privacidad + Términos y condiciones + N/D Errores y avisos de este relé + Suscripciones activas + Eventos de outbox pendientes + Suscripciones REQ (%1$d) + Suscripciones COUNT (%1$d) Longitud del mensaje Suscripciones Filtros @@ -577,9 +652,20 @@ Prefijo mínimo Etiquetas de evento máximas Longitud del contenido + Tamaño de contenido + Conectividad + Control de acceso PoW mínima Autenticación + Se requiere autorización Pago + Se requiere pago + Longitud máxima del mensaje + Suscripciones máximas + Filtros máximos por suscripción + Límite máximo (devolución de eventos) + Límite predeterminado (devolución de eventos) + Longitud máxima de subID Token de Cashu Canjear Enviar a billetera de zaps @@ -598,6 +684,7 @@ Etiquetas seguidas Relés Paquetes de seguimiento + Estas son listas de usuarios que recomiendas a otras personas. Solo se permiten usuarios públicos. Lecturas Algoritmos del feed Mercado @@ -609,8 +696,11 @@ Esta comunidad no tiene descripción. Habla con el propietario para agregar una. Contenido delicado Agrega una advertencia de contenido sensible antes de mostrarlo + Preferencias de interfaz de usuario Preferencias de la app Preferencias de usuario + Traducciones + Reacciones Configuración Siempre Solo Wi-Fi @@ -839,6 +929,9 @@ No se pudo preparar la información del encabezado: %1$s Compresión cancelada La compresión no pudo devolver un archivo + Muchos servidores no aceptan archivos cifrados en cuentas gratuitas. Puedes volver a intentarlo sin cifrado. + Reintentar sin cifrado + Advertencia: Sin cifrado, cualquiera con el enlace del archivo puede ver el contenido. Calidad multimedia Selecciona baja calidad para comprimir tus archivos multimedia a un tamaño más pequeño con menos calidad, alta calidad para comprimir a un archivo más grande con mayor calidad, o sin comprimir para subir los medios sin compresión. Baja @@ -857,7 +950,20 @@ Notificaciones Global Cortos + Ajedrez + Billetera + Saldo + Enviar + Recibir + Transacciones + No hay billetera conectada + Configure una conexión de Nostr Wallet Connect (NWC) en la configuración de zaps para usar la billetera. + Configurar billetera + sats + Pegar una factura BOLT-11 + Pagar Filtros de seguridad + Importar seguidos Nueva publicación Nuevos cortos: imágenes o videos Nueva nota comunitaria @@ -870,6 +976,20 @@ Me gusta Zap Cambiar reacciones rápidas + Fila de reacciones + Configura qué botones de reacción se muestran, su orden y si se muestran o no. + Activado + Mostrar recuento + Reordenar + Responder + Responder a esta nota + Impulsar + Volver a publicar o citar esta nota + Me gusta + Reaccionar a esta nota con un emoji + Zap + Enviar un pago de Lightning al autor + Compartir Imagen de perfil de %1$s Relé %1$s Ampliar lista de relés @@ -1040,4 +1160,5 @@ Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index ca89c3a9e7..b7918beed9 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -948,4 +948,5 @@ با قفل کردن دستگاه از حساب کاربری خارج شو پیام خصوصی + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index a8aaca7bd9..1e71d96dfb 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -570,4 +570,5 @@ Mediaa ei voitu ladata palvelimelta Paikallista tiedostoa ei voitu valmistella ladattavaksi: %1$s + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index eb90843ab3..378c19b9fa 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -125,6 +125,7 @@ Octets Erreur Erreurs + Pourcentage de connexions réussies vers le relais Le nombre d\'erreurs de connexion dans cette session Vos notifications Flux de messages privés @@ -245,6 +246,7 @@ "Erreur lors du chargement des réponses : " Essayer à nouveau Aucune notification pour le moment. + Ouvrir le sondage Le flux est vide. Rafraîchir créé @@ -633,6 +635,10 @@ %1$s sats De %1$s pour %1$s + Répondre + Marquer comme lu + Nouveaux messages + Nouveaux zaps Notifier: Rejoindre Conversation Utilisateur ou Groupe ID @@ -641,6 +647,7 @@ Rejoindre Aujourd\'hui Avertissement de contenu + Attention : %1$s Ce message contient du contenu sensible que certaines personnes peuvent trouver offensant ou dérangeant Toujours cacher les contenus sensibles Toujours afficher les contenus sensibles @@ -667,6 +674,7 @@ Écrire sur le Relay Le nombre d\'octets qui ont été envoyés à ce relais, y compris les filtres et les événements Le nombre d\'octets qui ont été reçus depuis ce relais, y compris les filtres et les événements + Événements enregistrés Une erreur s\'est produite en récupérant les informations du relay depuis %1$s Propriétaire Utilisé par @@ -700,6 +708,9 @@ N/A Erreurs et Notifications de ce Relais Abonnements actifs + Abonnements REQ (%1$d) + Abonnements COUNT (%1$d) + Aucun abonnement actif pour ce relais %1$d auteurs %1$d ids depuis %1$s @@ -822,6 +833,7 @@ Chargement de la localisation Pas d\'autorisations de localisation Ajoute un avertissement de contenu sensible avant de montrer votre contenu. C\'est idéal pour tout contenu NSFW ou contenu que certaines personnes peuvent trouver offensant ou dérangeant + Raison (optionnel) Nouvelle Fonctionnalité Pour activer ce mode, Amethyst doit envoyer un message NIP-17 (GiftWrapped, Sealed Direct et Group Messages). Le protocole NIP-17 est nouveau et la plupart des clients ne l\'ont pas encore mis en oeuvre. Assurez-vous que le destinataire utilise un client compatible. Activer @@ -906,6 +918,8 @@ Assurez-vous que l\'application signataire a autorisé cette transaction Aucun portefeuille trouvé pour payer une facture lightning (Erreur : %1$s). Veuillez installer un portefeuille Lightning pour utiliser les zaps Aucun portefeuille trouvé pour payer une facture lightning. Veuillez installer un portefeuille Lightning pour utiliser les zaps + Impossible d\'ouvrir les liens Blossom + Les applications Blossom n\'ont pas été trouvées. Veuillez installer une application Blossom locale pour voir ce fichier Mots Masqués Masquer un nouveau mot ou une phrase Photo de profil @@ -1009,6 +1023,7 @@ Impossible de préparer les informations d\'en-tête : %1$s Compression annulée La compression n\'a pas réussi à renvoyer un fichier + Chiffrer les fichiers Qualité des médias Sélectionnez une qualité basse pour compresser vos médias en un fichier plus petit, avec moins de qualité ou sélectionnez qualité haute pour compresser en un fichier plus grand, avec une meilleure qualité. Basse @@ -1017,6 +1032,7 @@ Non compressé Utiliser le codec H.265/HEVC Une meilleure qualité avec des tailles de fichier plus petites, mais tous les appareils ne supportent pas la lecture H.265. + Supprimer les métadonnées privées Modifier le brouillon Se connecter avec un QR Code Route @@ -1028,6 +1044,31 @@ Général Vidéos courtes Échecs + Portefeuille + Solde + Envoyer + Recevoir + Transactions + Aucun portefeuille connecté + Configurer le portefeuille + sats + Coller une facture BOLT-11 + Payer + Paiement réussi + Envoi du paiement… + Montant (sats) + Description (optionnel) + Créer une facture + Création de la facture… + Copier la facture + Pas encore de transactions + Chargement… + Reçu + Envoyé + Rafraîchir + Tout + Zaps + Non Zaps Filtres de Sécurité Nouveau Message Nouveaux Shorts : images ou vidéos @@ -1072,6 +1113,13 @@ Annuler le partage des Zaps Ajouter un avertissement de contenu Retirer l\'avertissement de contenu + Ajouter une date d\'expiration + Supprimer la date d\'expiration + Date d\'expiration + La publication sera masquée par les clients après cette date (NIP-40) + Sélectionner la date et l\'heure d\'expiration + Expire dans %1$s + Date d\'expiration Afficher npub en tant que QR code Afficher le nprofile en tant que code QR Adresse invalide @@ -1137,6 +1185,8 @@ Relais bloqués Relais bloqués Amethyst ne se connectera jamais à ces relais + Exporter en texte + Exporter en ZIP (JSON) Zap les Devs ! Votre don nous aide à faire la différence. Chaque sat compte ! Faire un don maintenant @@ -1229,6 +1279,10 @@ Mes listes Utilisateurs Sélectionnez une liste pour filtrer le fil + Flux + Hashtags + Communautés + Listes Se déconnecter au verrouillage de l\'appareil Message privé Message public @@ -1242,6 +1296,16 @@ Hashtag de recherche : #%1$s Ne pas traduire depuis Les langues affichées ici ne seront pas traduites. Sélectionnez une langue pour la supprimer et l\'avoir traduite à nouveau. + Traduire vers + Choisir la langue dans laquelle traduire le contenu. + Préférences d\'affichage de la langue + %1$s - %2$s + Rechercher des langues + Ajouter une langue + Ajouter une paire de langues + Langue source + Langue cible + Afficher %1$s en premier Pause Lecture Ouvrir le menu déroulant @@ -1273,13 +1337,17 @@ Supprimer la liste Supprimer le pack Kinds + Échec de la nouvelle tentative Appuyer pour afficher les détails %1$s envoyé Diffuser les résultats Diffusions (%1$d) Réduire Agrandir + Échec de la nouvelle tentative (%1$d) +%1$d diffusions supplémentaires + " (%1$d en cours)" + Relais %1$d/%2$d Délai expiré Nouvelle tentative… Réessayer @@ -1296,6 +1364,7 @@ Réaction Message vocal Réponse vocale + Heure de fermeture Confirmer Suivant Ajouter un bouton d\'option de sondage @@ -1320,6 +1389,7 @@ Recommandations d\'applications Paramètres utilisateur Piste audio + Récompenses de badge Relais bloqués Calendrier Rendez-vous @@ -1340,6 +1410,7 @@ Blogs Profil Liste en sourdine + NIP Nostr Connect Listes de personnes Photos @@ -1365,4 +1436,7 @@ Continuer Ignorer pour le moment Tout sélectionner + Recommencer + Annuler + diff --git a/amethyst/src/main/res/values-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 0c808a742b..d87351af47 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -246,6 +246,7 @@ "प्रतिवचनों को प्राप्त करने में अपक्रम : " पुनः प्रयास करें अभी तक कोई सूचनाएँ नहीं। + खुला मतदान सूचनावली रिक्त है। नवीकरण बनाया गया @@ -638,6 +639,10 @@ %1$s साट्स %1$s से %1$s के लिए + उत्तर + पढा हुआ चिह्नित करें + नए सन्देश + नए ज्साप सूचित करें : संवाद में जुड जाएँ उपयोगकर्ता अथवा झुण्ड विभेदक @@ -673,6 +678,7 @@ पुनःप्रसारक को लिखकर भेजें अष्टकों में मात्रा जो इस पुनःप्रसारक को भेजा गया था छलनियाँ तथा घटनाएँ समेत अष्टकों में मात्रा जो इस पुनःप्रसारक से प्राप्त हुआ था छलनियाँ तथा घटनाएँ समेत + घटनाएँ रखे गए %1$s से पुनःप्रसारक जानकारी प्राप्त करने के प्रयास में अपक्रम हुआ अधिपति द्वारा उपयुक्त @@ -1033,6 +1039,12 @@ शीर्षक जानकारी आयोजित करने में असफल : %1$s संक्षिप्तीकरण निरस्त किया गया संक्षिप्तीकरण से अभिलेख प्राप्त नहीं हुआ + अभिलेख रहस्यीकरण + गोपनीयता के लिए अभिलेखों का रहस्यीकरण करें। कुछ सेवासंगणक रहस्यीकृत अभिलेखों को सम्भाव्यतः अस्वीकार कर सकते हैं निःशुल्क लेखाओं के लिए। + रहस्यीकृत आरोहण असफल + अनेक सेवासंगणक रहस्यीकृत अभिलेखों को स्वीकार नहीं करते निःशुल्क लेखाओं के लिए। आप पुनःप्रयास कर सकते हैं रहस्यीकरण के बिना। + रहस्यीकरण के बिना पुनःप्रयास + सावधान : रहस्यीकरण के बिना कोई भी विषयवस्तु देख सकेगा अभिलेख योजक के साथ। अभिलेख गुणस्तर निम्न गुणस्तर चुनें अपने अभिलेख को अल्प गुणवत्ता युक्त छोटे आकार अभिलेख तक संकुचित करने के लिए अथवा उच्च गुणस्तर चुनें उच्चतर गुणवत्ता युक्त बृहत्तर अभिलेख तक संकुचित करने के लिए। निम्न @@ -1041,6 +1053,13 @@ असंकुचित उच्च कार्यक्षमता चलचित्र संकुचनविस्तारण अथवा एच॰२६५ क्रमलेख का प्रयोग करें अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं। + निजी परितथ्य हटाएँ + निजी परितथ्य हटाने का प्रयास करता है आलम्बित चित्रध्वनिदृश्य अभिलेखों से आरोहण पूर्व + परितथ्य हटाने में असफल + यह अभिलेख प्रारूप परितथ्य हटाने का अवलम्बन नहीं करता। निजी जानकारी जैसे स्थान तथा यन्त्र विवरण समाविष्ट हो सकते हैं। क्या आरोहण करें। + आरोहण करें + अभिलेख से निजी परितथ्य हटाने में असफल। आरोहण निरस्त। + आरोहण निरस्त पाण्डुलिपि का सम्पादन करें क्यूआर॰ क्रमचित्र के साथ प्रवेशांकन करें मार्ग @@ -1075,6 +1094,9 @@ प्राप्त भेजा गया नवीकरण + सभी + ज्साप + ज्साप अतिरिक्त सुरक्षार्थ छलनियाँ अनुचरित आयात करें नया पत्र प्रकाशन @@ -1142,6 +1164,7 @@ ये अच्छे विकल्प हैं :\n - auth.nostr1.com (शुल्करहित)\n - inbox.nostr.wine (सशुल्क)\n - relay.0xchat.com (शुल्करहित) निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को सब से सन्देश स्वीकारना चाहिए पर उनका अवरोहण करने की अनुमति केवल आपको देना चाहिए। अभी स्थापना करें + सीधासन्देश आगतपेटिका पुनःप्रसारक अनुपलब्ध। सन्देश नहीं भेजे जा सकते जब तक वे अपने पुनःप्रसारक सूची की समाकृति नहीं करते। खोज पुनःप्रसारक आपके खोज पुनःप्रसारकों की स्थापना करें खोज तथा उपयोगकर्ता सूचक जोडने के लिए स्पष्टतः रूपांकित पुनःप्रसारक सूची बनाने से इन परिणामों में शोधन होगा। @@ -1293,6 +1316,10 @@ मेरे सूचियाँ प्रयोक्ता सूची सूचनावली छानने के लिए सूची चुनें + सूचनावली + विषयसूचक + समुदाय + सूचियाँ यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश सार्वजनिक संदेश @@ -1557,5 +1584,84 @@ सभी चुनें %1$d%% समय निरन्तर उपलब्ध नामरूप्य स्थापना विकल्प + पुनःप्रसारक समचरणीकरण + पुनःप्रसारक समचरणीकरण + अपने घटनाओं को पुनःप्रकाशित करें सभी ज्ञात पुनःप्रसारकों तक अपने निर्गतपेटिका आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों का अद्यतन करने के लिए। वैफै॰ आवश्यक। यह सम्भाव्यतः बहुत जानकारी भेज सकता है। + पुनःप्रसारक समचरणीकरण खोलें… + यह क्या करता है + यह उपकरण आपके क्रमक द्वारा देखे गए सभी पुनःप्रसारकों को परखता है तथा आपके घटनाओं को सम्यक गन्तव्यों तक पुनःवितरण करता है : + आपके द्वारा लिखे गए सभी घटनाओं का अवरोहण करें तथा आपके निर्गतपेटिका पुनःप्रसारकों को भेजें। + आपका उल्लेख करनेवाले सभी घटनाओं का अवरोहण करें तथा आपके आगतपेटिका पुनःप्रसारकों को भेजें। + आपके लिए सम्बोधित सभी सीधेसन्देशों का अवरोहण करें तथा आपके सीधासन्देश पुनःप्रसारकों को भेजें। + ⚠ आप एक मात्रांकित अथवा चलनशील संयोजन पर होते हुए दिखते हैं। यह परिचालन बहुत बडी मात्रा में जानकारी भेज सकता है। वैफै॰ के साथ जुडें आरम्भ करने से पहले। + क्या चलनशील संयोजान का प्रयोग करें। + समचरणीकरण आरम्भ करें + आरम्भ करें (चलनशील संयोजन पर भी) + विराम + पुनःचालू + पुनः आरम्भ से + निरस्त करें + पुनःप्रसारक : %1$d / %2$d + घटनाएँ पुनःवितरित : %1$d नए %2$d भेजे गए तथा %3$d प्राप्त + समचरणीकरण विराम + सम्पन्न %2$d में से %1$d पुनःप्रसारक। %3$d घटनाएँ पुनःवितरित अब तक। पुनःचालू दबाएँ चलते रहने के लिए। + समचरणीकरण समाप्त + अग्रतःप्रेषित %2$d प्राप्त में से %1$d घटनाएँ गन्तव्य पुनःप्रसारकों तक। + %1$d घटनाएँ गन्तव्य पुनःप्रसारकों द्वारा नव्यतः स्वीकृत। + %1$d सेकण्ड में सम्पन्न। + समचरणीकरण दोष + इनको भेजा जाएगा + निर्गतपेटिका + आगतपेटिका + सीधेसन्देश + जाँच चालू (%1$d पुनःप्रसारक) + समाप्त (%1$d पुनःप्रसारक) + प्रेषित %1$s + प्राप्त %1$s + नव्य %1$s + कोई घटना नहीं द्व्यंकरूप्य समन्वेषक (ओटीएस॰) + घटनाएँ + सीधेसन्देश + परिचय + पुनःप्रसारक स्थापना विकल्प + पूर्व दृष्ट %1$s पहले + <%1$s + संयोजन किया जा रहा है + अवरोहण चालू + अपक्रम + सम्पन्न + + पढा हुआ चिह्नित करें + टीका कार्य + परिचय कार्य + अभिलेख कार्य + चालन + पोटली कार्य + सूची कार्य + स्मर्त्तव्यचिह्न कार्य + समूह कार्य + स्मर्त्तव्यचिह्न जोडें + सदस्य जोडें + सूची प्रबन्धन + योजक कार्य + निर्यात + साक्ष्यांकन + मान्य + अमान्य + स्वीकृत + अस्वीकृत + सत्यापन चालू + सत्यापित + निरस्त + %1$s से मान्य + %1$s तक मान्य + साक्ष्यांकन अनुरोध + घटना के लिए साक्ष्यांकन अनुरोध किया जा रहा है + साक्षी अनुशंसा + प्रकारों के लिए अनुशंसित : + साक्षी निपुणता + प्रकारों के सत्यापन में निपुण : %1$s + इस के लिए साक्षी + इस के लिए साक्ष्यांकन अनुरोध diff --git a/amethyst/src/main/res/values-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 8615b4bfb1..3d7172fb04 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -61,7 +61,7 @@ Az aláíró nem engedélyezte a művelet végrehajtásához szükséges visszafejtést. Aktiválja az NIP-44 visszafejtést az aláíró alkalmazásban, és próbálja meg újra Nem található az aláíró Az aláíró alkalmazás el lett távolítva? Ellenőrizze, hogy az aláíró telepítve van-e és rendelkezik-e ezzel a fiókkal. Jelentkezzen ki és jelentkezzen be újra, ha az aláíró alkalmazás megváltozott. - Zap + Zap-ek Megtekintések száma Megtolás megtolta @@ -241,11 +241,12 @@ Már van Nostr-fiókja? Új fiók létrehozása Új kulcs előállítása - Hírforrás betöltése… + Hírfolyam betöltése… Fiók betöltése… "Hiba a válaszok betöltésekor: " Próbálja újra Még nincsenek értesítések. + Szavazás megnyitása A hírfolyam üres. Frissítés létrehozva @@ -289,6 +290,7 @@ Nostr-cím soha most + másodperc ó p n @@ -446,6 +448,8 @@ Maximum Zap Együttműködés (0–100)% + Egyetlen lehetőség + Több lehetőség Szavazás lezárásának dátuma és ideje A szavazás lezárul %1$s múlva Szavazás lezárása @@ -638,6 +642,10 @@ %1$s satoshi Tőle: %1$s neki: %1$s + Válasz + Megjelölés olvasottként + Új üzenetek + Új zap-ek Értesítés: Csatlakozás a beszélgetéshez Felhasználó- vagy csoport-azonosító @@ -673,6 +681,7 @@ Írás az átjátszóra Az átjátszónak küldött bájt-mennyiség, beleértve a szűrőket és eseményeket is Az átjátszótól kapott bájt-mennyiség, beleértve a szűrőket és eseményeket is + Tárolt események Hiba lépett fel, amikor megpróbálta lekérni az átjátszó-információt innen: %1$s Tulajdonos Használat a következővel: @@ -765,7 +774,7 @@ Követett csomagok Ezek a listák azon felhasználók listái, akiket másoknak ajánlhat. Csak nyilvános felhasználók szerepelhetnek a listán. Olvasmányok - Hírforrás-algoritmus + Hírfolyam-algoritmus Piac Élő közvetítések Közösségek @@ -1033,6 +1042,12 @@ Nem sikerült előkészíteni a fejlécadatokat: %1$s Tömörítés visszavonva A tömörítés nem tudott visszaadni egy fájlt + Fájlok titkosítása + Az adatok védelme érdekében titkosítsa a fájlokat a feltöltés előtt. Egyes kiszolgálók az ingyenes fiókok esetében nem biztos, hogy elfogadják a titkosított fájlokat. + Titkosított feltöltés sikertelen + Sok kiszolgáló nem fogad el titkosított fájlokat ingyenes fiókok esetén. Próbálja újra titkosítás nélkül. + Újrapróbálás titkosítás nélkül + Figyelem: Titkosítás nélkül bárki láthatja a tartalmat, akinek megvan a fájlhoz tartozó hivatkozás. Média minősége Válassza az alacsony minőséget a média kisebb, de kevésbé jó minőségű fájlba tömörítéséhez, a magas minőséget a nagyobb, de jobb minőségű fájlba tömörítéshez, vagy a tömörítetlen fájlt a tömörítés nélküli feltöltéshez. Alacsony @@ -1041,6 +1056,13 @@ Tömörítetlen H.265/HEVC-kodek használata Jobb minőség kisebb fájlméret mellett, de nem minden eszköz támogatja a H.265 lejátszást. + Privát metaadatok törlése + Kísérletek a támogatott médiafájlokból a privát metaadatok eltávolítására a feltöltés előtt + A metaadatok nem távolíthatók el + Ez a fájlformátum nem támogatja a metaadatok eltávolítását. Lehet, hogy a fájl tartalmaz személyes adatokat, például hely- és eszközadatokat. Mindenképp fel akarja tölteni? + Feltöltés mindenképp + Nem sikerült eltávolítani a médiafájlokból a privát metaadatokat. Feltöltés megszakítva. + Feltölrés megszakítva Piszkozat szerkesztése Bejelentkezés QR-kóddal Útvonal @@ -1075,6 +1097,9 @@ Fogadott Elküldött Frissítés + Összes + Zap-ek + Nem-zap-ek Biztonsági szűrők Követettek importálása Új bejegyzés @@ -1082,6 +1107,13 @@ Új közösségi bejegyzés Új termék Új hely-exkluzív bejegyzés + Új cikk + Cím + Összefoglalás (nem kötelező) + Borítókép webcíme (nem kötelező) + Cikk írása markdownban… + Előnézet + Szerkesztés A bejegyzésre adott összes reakció kibontása A bejegyzésre adott összes reakció összecsukása Válasz @@ -1142,6 +1174,7 @@ Jó választási lehetőségek:\n - auth.nostr1.com (ingyenes)\n - inbox.nostr.wine (fizetős)\n - relay.0xchat.com (ingyenes) Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljanak. A bejövő privát üzenetek átjátszóinak el kell fogadniuk bármely üzenetet bárkitől, de azok letöltését csak Ön engedélyezheti. Beállítás most + Nem találhatók a beérkező közvetlen üzenetek átjátszói. Az üzenetek kézbesítése addig nem lehetséges, amíg be nem állítja az átjátszók listáját. Keresési átjátszók Keresési átjátszók beállítása A kifejezetten a kereséshez és a felhasználói címkézéshez tervezett átjátszólista létrehozása javítani fogja ezeket az eredményeket. @@ -1150,7 +1183,7 @@ Átjátszók a kimenő üzenetkhez Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. - Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért + Adjon meg 1–3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Átjátszók a bejövő üzenetkhez Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához @@ -1183,8 +1216,8 @@ Megbízható átjátszók Megbízható átjátszók Az átjátszóknak, amelyekben megbízik, nincs szükségük Tor kapcsolatra a következőhöz: - Kedvenc átjátszó-hírforrások - Kedvenc átjátszó-hírforrások + Kedvenc hírfolyam-átjátszók + Kedvenc hírfolyam-átjátszók Átjátszók, amelyeket gyakran felkeres a globális hírfolyamuk megtekintéséhez Proxyzott átjátszók Proxyzott átjátszók @@ -1218,6 +1251,7 @@ OTS: %1$s Időbélyeg-igazolás Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába. + Cikk szerkesztése Bejegyzés szerkesztése Javaslat egy bejegyzés javítására Változások összefoglalása @@ -1292,7 +1326,11 @@ Saját lista/gyüjtemény Saját listák Felhasználók - Lista kiválasztása a hírfolyam szűréséhez + Szempont kiválasztása a hírfolyam szűréséhez + Hírfolyamok + Hashtagek + Közösségek + Listák Kijelentkeztetés az eszköz zárolása esetén Privát üzenet Nyílvános üzenet @@ -1557,5 +1595,91 @@ Összes kijelölése Üzemidő: %1$d%% Namecoin-beállítások + Átjátszószinkronizálás + Átjátszószinkronizálás + Tegye közzé újra az eseményeit az összes ismert átjátszón, hogy a kimenő, beérkező és privát üzenetek átjátszói mindig naprakészek legyenek. Wi-Fi-kapcsolat szükséges – ez jelentős adatforgalmat eredményezhet. + Átjátszószinkronizálás megnyitása… + Hogyan működik + Ez az eszköz átvizsgálja az alkalmazás által eddig észlelt összes átjátszót, és az eseményeket a megfelelő célállomásokra továbbítja: + Töltse le az összes létrehozott eseményt, és küldje el őket a kimenő üzenetek átjátszóinak. + Töltse le az összes olyan eseményt, amely megemlíti Önt, és küldje el őket a beérkező üzenetek átjátszóinak. + Töltse le az összes Önnek címzett közvetlen üzenetet, és küldje el őket a közvetlen üzenetek átjátszóinak. + ⚠ Úgy tűnik, forgalmi díjas vagy mobilinternet-kapcsolatot használ. Ez a művelet nagyon nagy mennyiségű adatot képes átvinni. Indítás előtt csatlakozzon Wi-Fi-hez. + Mobiladat-forgalom használata? + Szinkronizálás indítása + Indítás mindenképpen (mobiladat-forgalom) + Szüneteltetés + Folytatás + Újrakezdés + Mégse + Átjátszók: %1$d / %2$d + Újraelosztott események: %1$d új / %2$d elküldött és %3$d fogadott + Szinkronizálás szüneteltetve + %2$d átjátszóból %1$d teljesítve — %3$d esemény újraelosztása eddig. A folytatáshoz koppintson a folytatás gombra. + Szinkronizálás kész + %1$d esemény továbbítva a célátjátszóknak a fogadott %2$d eseményből. + A célátjátszó által %1$d esemény újként elfogadva. + %1$d másodperc alatt elkészült. + Szinkronizálási hiba + Küldés ide: + Kimenő + Beérkező + Közvetlen üzenetek + Jelenleg ellenőrzés alatt (%1$d átjátszó) + Kész (%1$d átjátszó) + elküldve: %1$s + fogadott: %1$s + új: %1$s + nincsenek események Bitcoin felfedező (OTS) + események + Közvetlen üzenetek + profilok + átjátszóbeállítások + Utoljára %1$s ezelőtt látták + <%1$s + Kapcsolódás + Letöltés + Hiba + Kész + + Megjelölés olvasottként + Bejegyzésműveletek + Profilműveletek + Médiaműveletek + Lejátszás + Csomagműveletek + Listaműveletek + Könyvjelző-műveletek + Csoportműveletek + Könyvjelző hozzáadása + Tag hozzáadása + Listakezelés + Listaműveletek + Exportálás + Tanúsítás + Érvényes + Érvénytelen + Elfogadva + Elutasítva + Ellenőrzés + Ellenőrizve + Visszavonva + Érvényes ekkortól: %1$s + Érvényes eddig: %1$s + Tanúsításkérés + Tanúsításkérés egy eseményhez + Tanúsító ajánlása + Javasolt az alábbi típusokhoz: + Tanúsítói jártasság + Jártas a következő típusok igazolásában: %1$s + Tanúsítás ehhez: + Tanúsítás kérése ehhez: + Időszak + Ekkortól + Eddig + Most + Összes + Utoljára szinkronizálva: %1$s + Utolsó szinkronizálás óta diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 735d180cbe..4e5b60c383 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -512,4 +512,5 @@ Seharusnya %3$s Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index c84df0a1ac..01cc929ad8 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -433,4 +433,5 @@ Globale Cerca + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index a8b78fd9ae..3fd134573e 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -390,4 +390,5 @@ 自動的にURLプレビューを表示 画像読み込み + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index e35584eb3b..5d0dd475a7 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -115,7 +115,7 @@ Sekot saraksts - Ikona %1$s sarakstam + Ikona sarakstam Izveidot jaunu sarakstu Jaunais %1$s saraksts Kolekcijas nosaukums @@ -172,4 +172,5 @@ Lejupielādēt Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni. + diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index d79ecae4fb..3f51aeb8f0 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -1145,4 +1145,5 @@ Verwijder lijst Verwijder pakket + diff --git a/amethyst/src/main/res/values-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a10363d70b..e0790763fe 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -71,7 +71,7 @@ Zacytuj Sklonuj Zaproponuj zmianę - Nowa kwota w Satsach + Nowa kwota w satoszach Dodaj "odpowiadając do " " i " @@ -92,7 +92,7 @@ Lightning transfer Wiadomość dla odbiorcy Dziękuję bardzo! - Kwota w Satsach + Kwota w satoszach Wyślij Kreator tajnych emoji Dodaj emoji z ukrytą wiadomością do wpisu @@ -181,7 +181,7 @@ Neutralna Anonimowy Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy. - Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze "odpowiedz tutaj.. " Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr Kopiuj ID kanału (wpisu) do schowka @@ -246,6 +246,7 @@ "Błąd wczytywania odpowiedzi: " Spróbuj ponownie Brak powiadomień. + Ankieta Otwarta Brak zawartości. Odśwież stworzył (a) @@ -286,6 +287,7 @@ Adres Nostr nigdy teraz + sekund godz. m d @@ -430,7 +432,7 @@ Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa. Podłącz portfel Przejrzyj kanał transmitera - Kwota zobowiązania w Satach + Kwota zobowiązania w Satoszach Wyślij Ankietę Wymagane pola: Odbiorcy zap @@ -443,6 +445,8 @@ Maksymalny Zap Konsensus (0–100)% + Pojedynczy wybór + Wielokrotny wybór Data & godzina zakończenia ankiety Ankieta zostanie zamknięta za %1$s Zamknij po @@ -518,7 +522,7 @@ Obserwowane Wszystkie obserwowane Domyślna lista obserwowanych - Obserwuje przez proxy + Obserwowani przez proxy W pobliżu Wszystkie Szachy @@ -536,7 +540,7 @@ Nowa Dodaj autora do listy obserwowanych Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem. - Ikona dla listy %1$s + Ikona dla listy %1$s jest uczestnikiem publicznym %1$s jest uczestnikiem prywatnym Dodaj jako uczestnika publicznego @@ -632,9 +636,13 @@ Powiadamia Cię, gdy nadejdzie prywatna wiadomość Otrzymano Zapy Powiadamia Cię, gdy ktoś prześle ci zapy - %1$s Satsów + %1$s Satoszy Od %1$s dla %1$s + Odpowiedz + Przeczytane + Nowe wiadomości + Nowe zapy Powiadom: Dołącz do dyskusji ID Użytkownika lub Grupy @@ -663,13 +671,14 @@ Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić Zapraiser - Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn - Docelowa kwota w Satach - Zapraiser przy: %1$s. %2$s satach do celu + Dodaje docelową liczbę satoszy do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn + Docelowa kwota w Satoszach + Zapraiser przy: %1$s. %2$s satoszach do celu Odczytaj z Transmitera 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 @@ -872,7 +881,7 @@ Kopiuj do schowka Skopiuj nprofile do schowka Kopiuj npub do schowka - Udostępnij lub Zapisz + Udostępnij lub zapisz Kopiuj adres URL do schowka Kopiuj ID wpisu do schowka Dodaj pliki do Galerii @@ -910,7 +919,7 @@ Szukaj i dodaj użytkownika Nick lub Login Brakująca konfiguracja LN - Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze Procentowo 25 Podziel zapsy z @@ -925,6 +934,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 @@ -939,7 +950,7 @@ Mint dostarczył następujący komunikat błędu: %1$s Tokeny Cashu zostały już wydane. Cashu odebrano - %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów) + %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy) W systemie nie znaleziono kompatybilnego portfela Cashu Nie można pobrać faktury z serwerów odbiorcy Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s @@ -957,7 +968,7 @@ Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika - Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s + Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s Nie można utworzyć faktury. Wiadomość od %1$s: %2$s Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON. @@ -984,7 +995,7 @@ iPhone 13 Stan Kategoria - Cena (w Satach) + Cena (w Satoszach) 1000 Lokalizacja Miasto, Województwo, Kraj @@ -1028,6 +1039,12 @@ Nie można przygotować informacji nagłówkowych: %1$s Kompresja anulowana Kompresja nie powiodła się przy zwracaniu pliku + Szyfrowanie plików + W celu zapewnienia prywatności zaszyfruj pliki przed ich przesłaniem. Niektóre serwery mogą nie akceptować zaszyfrowanych plików na bezpłatnych kontach. + Nie udało się przesłać zaszyfrowanego pliku + Wiele serwerów nie akceptuje plików zaszyfrowanych na kontach bezpłatnych. Możesz spróbować ponownie bez szyfrowania. + Ponów bez szyfrowania + Ostrzeżenie: Bez szyfrowania każdy, kto posiada link do pliku, może zobaczyć jego zawartość. Jakość Załącznika Wybierz Niską jakość, aby skompresować media do mniejszego pliku o mniejszej jakości lub wybierz Wysoką jakość, aby skompresować do większego pliku o wyższej jakości. Niska @@ -1036,6 +1053,13 @@ Nieskompresowane Użyj kodeka H.265/HEVC Lepsza jakość przy mniejszych rozmiarach plików, lecz nie wszystkie urządzenia obsługują odtwarzanie H.265. + Usuń prywatne metadane + Próba usunięcia prywatnych metadanych z obsługiwanych plików multimedialnych przed przesłaniem + Metadane nie mogą zostać usunięte + Ten format pliku nie obsługuje usuwania metadanych. Plik może zawierać dane osobowe, takie jak informacje o lokalizacji i urządzeniu. Czy mimo to chcesz go przesłać? + Prześlij mimo wszystko + Nie udało się usunąć prywatnych metadanych z plików multimedialnych. Przesyłanie anulowane. + Anulowano przesyłanie Edytuj wersję roboczą Zaloguj się przy użyciu QR kodu Ścieżka @@ -1047,16 +1071,49 @@ Wszystkie Filmiki Szachy + Portfel + Saldo + Wyślij + Odbierz + Transakcje + Nie podłączono portfela + Aby korzystać z portfela, skonfiguruj połączenie Nostr Wallet Connect (NWC) w ustawieniach zap. + Skonfiguruj portfel + satosze + Wstaw fakturę BOLT-11 + Zapłać + Płatność udana + Wysyłanie zapłaty… + Kwota (satoszy) + Opis (opcjonalnie) + Utwórz fakturę + Tworzenie faktury… + Kopiuj fakturę + Brak dostępnych transakcji + Wczytywanie… + Otrzymano + Wysłano + Odśwież + Wszystkie + Zapy + Bez zapów Filtry bezpieczeństwa - Importuj Obserwujących + Import Obserwowanych Nowy post Nowe Króciaki: zdjęcia lub filmiki Nowy wpis w społeczności Nowy produkt Nowy GEO-ekskluzywny Wpis + Nowy artykuł + Tytuł + Podsumowanie (opcjonalnie) + Adres URL miniaturki (opcjonalnie) + Napisz artykuł w formacie markdown… + Podgląd + Edytuj Otwórz wszystkie odzewy na ten post Zamknij wszystkie odzewy na ten post - Odpowiedz + Odpowiedź Powtórz lub Zacytuj Lubię Zap @@ -1114,6 +1171,7 @@ Dobre opcje to:\n - auth.nostr1.com (darmowe)\n - inbox.nostr.wine (płatne)\n - relay.0xchat.com (darmowe) Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Transmitery DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Konfiguruj + Nie znaleziono transmiterów skrzynki odbiorczej prywatnych wiadomości. Wiadomości nie mogą zostać dostarczone, dopóki nie skonfigurują listy transmiterów. Transmitery wyszukujące Skonfiguruj transmitery wyszukujące Stworzenie listy transmiterów specjalnie przeznaczonych do wyszukiwania i tagowania użytkowników poprawi te wyniki. @@ -1190,6 +1248,7 @@ OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. + Redaguj artykuł Edytuj wpis Propozycja ulepszenia wpisu Podsumowanie zmian @@ -1265,6 +1324,10 @@ Moje listy Użytkownicy Wybierz listę profili, aby filtrować aktualności + Kanały + Hashtagi + Społeczności + Listy Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość @@ -1282,6 +1345,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 +1363,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 @@ -1343,7 +1408,7 @@ %1$d/%2$d Przekaz Przekaz %1$s - Liczba przekazów: %1$d... + Liczba przekazów: %1$d… Wysłanych przekazów: %1$d Niektóre akcje nie powiodły się Wszystkie akcje udane @@ -1391,7 +1456,13 @@ Spotkanie RSVP Spotkania Szachy + Autoryzacja szachów Ulubione Transmitery + Wyzwania w szachach + Akceptuj Szachy + Ruch szachów + Koniec gry + Propozycja remisu szachowego Określenie kanału Ukryta wiadomość kanału Lista kanałów @@ -1403,12 +1474,16 @@ Transmitery DM Ogłoszenia Komentarze + Def Społeczności Lista społeczności + Post społeczności Lista obserwowanych Usunięcia Wersje robocze Pakiety emoji Lista Pakietów Emoji + Czat efemeryczny + Efemeryczne pokoje czatowe Nagłówek pliku Galeria profilu Serwery Plików @@ -1425,20 +1500,33 @@ Cele Zap-a Obserwacja hashtagów Najważniejsze informacje + Autoryzacja http Indeks listy transmiterów + Wstęp do przygody + Scena Przygody + Lektura przygody Opisane zakładki + Czaty na żywo + Na żywo Zapy Zapytanie NWC Odpowiedź NWC Prywatne Zapy + Sugestia zapa Blogi Pokój spotkań + Stan pokoju Strefa spotkań Profil Lista zablokowanych NNS NIP Nostr Connect + Status DVM + Żądania treści DVM + Treść odpowiedzi DVM + Żądanie użytkownika DVM + Odpowiedź użytkownika DVM OTS Płatność dla Listy osób @@ -1483,16 +1571,111 @@ wyszukaj, npub1…, alicja@domena.pl Obsługuje npub, nprofile, NIP-05, hex, i namecoin (.bit, d/, id/) Sprawdź listę obserwowanych + Porada Znaleziono %1$d kont(a) Wybrano: %1$d + Rozwiązane przez Namecoin Teraz obserwujesz %1$d kont(a) Twój kanał jest gotowy. Pomiń + Szukaj innego + Obserwuj %1$d konta Pobierz więcej Kontynuuj Na razie pomiń + Rozwiązywanie %1$s… + Pobieranie listy obserwowanych… Nie znaleziono obserwowanych Obserwujesz %1$d kont(a)… "Wprowadź profil znajomego lub lidera społeczności. Możesz użyć ich npub-u, adresu NIP-05 lub nazwy jak alicja@domena.pl lub id/alicja dla zweryfikowanych przez blockchain tożsamości." Wybierz Wszystkie + Czas działania %1$d%% + Ustawienia Namecoin + Synchronizacja Transmitera + Synchronizacja Transmitera + Opublikuj ponownie swoje wpisy na wszystkich znanych transmiterach, aby zaktualizować transmitery odbiorcze, nadawcze i wiadomości prywatnych. Wymagane połączenie Wi-Fi — może to spowodować zużycie dużej ilości danych. + Otwórz transmiter synchronizacji… + Jak to działa + To narzędzie skanuje wszystkie transmitery, z którymi zetknęła się Twoja aplikacja, i przekierowuje zdarzenia do właściwych miejsc docelowych: + Pobierz wszystkie wydarzenia, których jesteś autorem, i wyślij je do transmiterów nadawczych. + Pobierz wszystkie wydarzenia, w których jesteś wymieniony, i prześlij je do swoich transmiterów odbiorczych. + Pobierz wszystkie bezpośrednie wiadomości adresowane do Ciebie i wyślij je do swoich transmiterów DM. + ⚠️ Wygląda na to, że korzystasz z połączenia komórkowego lub z limitem transferu danych. Ta operacja może wymagać przesłania bardzo dużej ilości danych. Przed rozpoczęciem podłącz się do sieci Wi-Fi. + Włączyć transmisje danych komórkowych? + Rozpocznij synchronizację + Uruchom mimo to (transmisja danych komórkowych) + Pauza + Wznów + Zacznij od początku + Anuluj + Transmitery: %1$d / %2$d + Wydarzenia rozdystrybuowane: %1$d nowych z %2$d wysłanych i %3$d otrzymanych + Synchronizacja wstrzymana + Ukończono %1$d z %2$d transmiterów — %3$d wydarzeń rozdystrybuowane to tej pory. Naciśnij „Wznów”, aby kontynuować. + Synchronizacja zakończona + Przesłano %1$d wydarzeń do transmiterów docelowych spośród %2$d. + %1$d wydarzeń zaakceptowanych jako nowe przez docelowe transmitery. + Zakończono w %1$d sek. + Błąd synchronizacji + Wysyłanie do + Skrzynka nadawcza + Skrzynka odbiorcza + DMs + Trwa sprawdzanie (%1$d transmiterów) + Ukończono (%1$d transmiterów) + wysłano %1$s + odebrano %1$s + nowa %1$s + brak wydarzeń + Eksplorator Bitcoin (OTS) + wydarzeń + DMs + profile + ustawienia transmiterów + Ostatnio widziano %1$s temu + <%1$s + Łączenie + Pobieranie + Błąd + Zakończone + + Oznacz jako Przeczytane + Akcje wpisu + Akcje profilu + Akcje multimediów + Odtwarzanie + Akcje pakietu + Akcje listy + Akcje zakładki + Akcje grupy + Dodaj do zakładek + Dodaj uczestnika + Zarządzanie listą + Akcje linku + Eksport + Certyfikat + Ważny + Nieważny + Zaakceptowany + Odrzucony + Weryfikacja + Zweryfikowany + Unieważniony + Ważny od %1$s + Ważny do %1$s + Wniosek o certyfikat + Wniosek o certyfikację wydarzenia + Rekomendacja certyfikatora + Zalecane dla typów: + Poziom biegłości certyfikatora + Biegłość w sprawdzaniu typów: %1$s + Certyfikat dla + Żądanie certyfikatu do + Przedział czasu + Od + Do + Teraz + Cały czas + Ostatnia synchronizacja %1$s + Od ostatniej synchronizacji diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3816f9dfbf..e22277af24 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -246,6 +246,7 @@ "Erro ao carregar respostas" Tente novamente Ainda não há notificações. + Enquete aberta Feed está vazio Atualizar criado @@ -289,6 +290,7 @@ Endereço Nostr nunca agora + segundos h m d @@ -535,7 +537,7 @@ Novo Adicionar autor à lista de seguidores Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário. - Ícone da lista %1$s + Ícone da lista %1$s é um membro público %1$s é um membro privado Adicionar como membro público @@ -634,6 +636,10 @@ %1$s sats De %1$s por %1$s + Responder + Marcar como lida + Novas mensagens + Novos zaps Notificar: Entrar na conversa ID do usuário ou grupo @@ -669,6 +675,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 @@ -1028,6 +1035,12 @@ Não foi possível preparar informações do cabeçalho: %1$s Compressão cancelada Compressão falhou ao retornar um arquivo + Criptografar arquivos + Criptografar arquivos antes do upload para privacidade. Alguns servidores podem não aceitar arquivos criptografados em contas gratuitas. + Falha no upload criptografado + Muitos servidores não aceitam arquivos criptografados em contas gratuitas. Você pode tentar novamente sem criptografia. + Tentar novamente sem criptografia + Aviso: Sem criptografia, qualquer pessoa com o link do arquivo pode ver o conteúdo. Qualidade de Mídia Selecione Baixa qualidade para comprimir sua mídia para um arquivo menor com menor qualidade, Alta qualidade para comprimir para um arquivo maior com maior qualidade ou \"Sem compressão\" para carregar a mídia sem compressão. Baixa @@ -1036,6 +1049,13 @@ Sem compressão Usar codec H.265/HEVC Melhor qualidade em arquivos menores, mas nem todos os dispositivos suportam reprodução em H.265. + Remover metadados privados + Tenta remover metadados privados de arquivos de mídia compatíveis antes do envio + Não foi possível remover os metadados + Este formato de arquivo não suporta a remoção de metadados. Informações privadas como localização e dados do dispositivo podem estar incluídas. Enviar mesmo assim? + Enviar mesmo assim + Não foi possível remover metadados privados da mídia. Envio cancelado. + Envio cancelado Editar rascunho Entrar com Código QR Rota @@ -1070,6 +1090,9 @@ Recebido Enviado Atualizar + Todos + Zaps + Outros Filtros de Segurança Importar Seguidos Novo Post @@ -1077,6 +1100,13 @@ Nova Nota da Comunidade Produto Novo Nova Postagem Geo-Exclusiva + Novo artigo + Título + Resumo (opcional) + URL da imagem de capa (opcional) + Escreva seu artigo em markdown… + Pré-visualização + Editar Abrir todas as reações a esta postagem Fechar todas as reações a esta postagem Responder @@ -1137,6 +1167,7 @@ Opções boas são:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (gratuito) Insira entre 1–3 relés para servir como sua caixa de entrada privada. Relés de Caixa de Entrada de DM devem aceitar qualquer mensagem de qualquer pessoa, mas permitir apenas você a baixá-las. Configurar agora + Relays de entrada de DM não encontrados. As mensagens não podem ser entregues até que a lista de relays seja configurada. Relés de Pesquisa Configurar seus relés de Pesquisa Criar uma lista de relés especificamente projetada para pesquisa e marcação de usuários melhorará esses resultados. @@ -1213,6 +1244,7 @@ OTS: %1$s Prova de Carimbo de data/hora Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. + Editar artigo Editar postagem Proposta para melhorar sua postagem Resumo das alterações @@ -1288,6 +1320,10 @@ Minhas listas Usuários Selecione uma lista para filtrar o feed + Feeds + Hashtags + Comunidades + Listas Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1552,5 +1588,91 @@ Selecionar tudo %1$d%% de disponibilidade Configurações do Namecoin + Sincronização de Relays + Sincronização de Relays + Republique seus eventos em todos os relays conhecidos para manter seus relays de saída, entrada e DM atualizados. Requer Wi-Fi — pode consumir muitos dados. + Abrir sincronização de relays… + O que isso faz + Esta ferramenta verifica todos os relays que seu app conhece e redistribui seus eventos para os destinos corretos: + Baixar todos os eventos que você criou e enviá-los para seus relays de saída. + Baixar todos os eventos que mencionam você e enviá-los para seus relays de entrada. + Baixar todas as mensagens diretas endereçadas a você e enviá-las para seus relays de DM. + ⚠ Você parece estar em uma conexão limitada ou móvel. Esta operação pode transferir uma quantidade muito grande de dados. Conecte-se ao Wi-Fi antes de iniciar. + Usar dados móveis? + Iniciar sincronização + Iniciar mesmo assim (dados móveis) + Pausar + Retomar + Recomeçar + Cancelar + Relés: %1$d / %2$d + Eventos redistribuídos: %1$d novos de %2$d enviados e %3$d recebidos + Sincronização pausada + %1$d de %2$d relays concluídos — %3$d eventos redistribuídos até agora. Toque em Retomar para continuar. + Sincronização concluída + %1$d eventos encaminhados para relays de destino de %2$d recebidos. + %1$d eventos aceitos como novos pelos relays de destino. + Concluído em %1$d segundos. + Erro de sincronização + Enviando para + Saída + Entrada + DMs + Verificando (%1$d relays) + Concluído (%1$d relays) + env. %1$s + receb. %1$s + novos %1$s + nenhum evento Explorador Bitcoin (OTS) + eventos + DMs + perfils + configurações de Relay + Visto pela última vez há %1$s + <%1$s + Conectando + Baixando + Erro + Concluído + + Marcar como lida + Ações da nota + Ações do perfil + Ações de mídia + Reprodução + Ações do pacote + Ações da lista + Ações de favorito + Ações do grupo + Adicionar favorito + Adicionar membro + Gerenciamento de lista + Ações do link + Exportar + Atestação + Válida + Inválida + Aceita + Rejeitada + Verificando + Verificada + Revogada + Válida a partir de %1$s + Válida até %1$s + Solicitação de atestação + Solicitando atestação para um evento + Recomendação do atestante + Recomendado para tipos: %1$s + Competência do atestante + Competente na verificação de tipos: %1$s + Atesta + Solicita atestação para + Intervalo de datas + De + Até + Agora + Todo o período + Última sincronização: %1$s + Desde a última sincronização diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index e461d5d2a0..347df81d93 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -3,10 +3,13 @@ Наведите на QR код Показать QR Фото профиля + Фото профиля Сканировать QR Показать + Данный пост был скрыт, потому что упоминает скрытых ваши пользователей Запись была помечена как неуместная запись не найдена + 👀 Фото канала Связанное событие не найдено Не удалось расшифровать сообщение @@ -15,6 +18,9 @@ Спам Выдача себя за другое лицо Незаконные действия + Другие + Домогательство + Насилие Неизвестно Иконка релея Неизвестный автор @@ -29,6 +35,8 @@ Сообщить о выдаче себя за другого Сообщить о запрещённом контенте Сообщить о незаконных действиях + Сообщить об вредоносных ПО + Вирус Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы ответить Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы продвигать записи Вы используете публичный ключ, они - только для чтения. Войдите с приватным ключом, чтобы лайкать посты @@ -38,10 +46,15 @@ Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность отписаться Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность скрыть слово или предложение Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность показать слово или предложение + Вы используете публичный ключ. Войдите приватным ключом, чтобы редактировать + Подпись не найдена Запы Просмотры Продвинуть + изменено + изменить #%1$s Цитата + Предложить изменение Новая сумма в sat Добавить "ответ на " @@ -63,42 +76,62 @@ Большое спасибо! Сумма в sat Отправить + Мое скрытое сообщение + Видимый префикс + Добавить к посту "Не удалось создать предпросмотр для %1$s : %2$s" "Предпросмотр для %1$s" + Статья Новый канал Название канала Моя новая группа URL фотографии Описание + Описание не найдено "О нас.. " Что нового? + Написать сообщение… Отправить Сохранить Создать + Переименовать Отменить Не удалось загрузить фото Адрес релея Записи Байт + Ошибка Ошибки Домашняя лента Лента личных сообщений Лента чатов Глобальная лента Поиск + Найти и добавить пользователя + Добавить пользователя Добавить релей + Имя + Мой @тег Отображаемое имя Моё отображаемое имя + Ostrich McAwesome + Добро пожаловать Ostrich! Никнейм Мой никнейм Обо мне URL фотографии URL баннера URL сайта + Местоимения LN адрес LN URL (устаревш.) + Сохранить в телефон + Сохранить в галерею Фото сохранено в галерею + Скачивание видео было запущено… + Загрузка вложений было запущено… Не удалось сохранить фото + Не удалось сохранить видео Загрузить фото Загрузка… Пользователь не установил Lightning адрес для получения чаевых @@ -415,4 +448,5 @@ Разное Другое + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index e4e1ee3bf7..da5af67392 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -74,8 +74,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ali je bila aplikacija za podpisovanje odstranjena? Preverite, ali je aplikacija za podpisovanje nameščena in ima dostop do tega računa. Odjavite se in ponovno prijavite, če se je aplikacija morda spremenila. Zapi Števec vpogledov - Posreduj - posredovano + Pošlji naprej + Poslano naprej posodobljeno uredi #%1$s original @@ -136,6 +136,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Bajti Napaka Napake + Delež uspešnih povezav z relejem Število napak pri povezovanju v tej seji Domače vsebine Vsebine privatnih sporočil @@ -161,6 +162,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zaimki Lightning naslov (LUD-16) Zastarel lightning naslov (LUD-06) + Shrani v telefon Shrani v galerijo Slika shranjena v foto galerijo telefona Prenos videa se je začel… @@ -169,6 +171,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Video shranjen v video galerijo telefona Neuspešno shranjevanje videa Naloži sliko + Naloži datoteko Zajemi sliko Posnemi video Posnemi sporočilo @@ -254,6 +257,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem "Napaka pri nalaganju odgovorov: " Poskusi ponovno Ni še obvestil. + Aktivna anketa Ni vsebin. Osveži ustvarjeno @@ -330,7 +334,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem "Slika nagradne značke za %1$s" \"Slika nagradne značke Prejel/a si novo nagradno značko - Nagradna zančka podeljena + Nagradna značka podeljena Tekst zapiska kopiran v odložišče Avtorjev @npub kopiran v odložišče Zapiskov ID (@note1) kopiran v odložišče @@ -432,6 +436,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Wallet Connect skrivnost Prikaži skrivni ključ nsec / hex privatni ključ + Povezan + Ni povezan + Napredno: ročni vnos podatkov o povezavi + Zneski za hitre zape + Prikaže se ob pritisku na gumb za zappe. Tapnite znesek, da ga odstranite. Če pustite prazno, se bo ob vsakem zappu odprlo okno za vnos poljubnega zneska. + Zap zasebnost + Določa, kako je prikazana vaša identiteta, ko pošljete zap. + Poveži denarnico + Pogled v vsebino releja Prispevaj vsoto v sat Pošlji anketo Zahtevana polja: @@ -446,7 +459,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Soglasje (0–100)% Datum in čas konca glasovanja - Glasovanje se zaključi %1$s + Anketa se zaključi %1$s Zapri po dni Nezmožen za glasovanje @@ -454,6 +467,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zap vsota Dovoljen je le en glas na uporabnika pri tem tipu ankete "Iskanje dogodka %1$s" + Pošlji zap Dodaj javno sporočilo Dodaj privatno sporočilo Dodaj sporočilo k fakturi @@ -548,7 +562,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zasebni profili član člani - ni članov + Ni članov Prazno %1$s ni v tem seznamu %1$s ni član @@ -636,6 +650,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$s sat Od %1$s za %1$s + Odgovori + Označi kot prebrano + Novo sporočilo + Novi zapi Obvesti: Pridruži se pogovoru ID uporabnika ali skupine @@ -644,6 +662,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pridruži se Danes Opozorilo o vsebini + Opozorilo: %1$s Ta objava vsebuje občutljivo vsebino, ki jo lahko nekateri smatrajo za žaljivo ali vznemirjajočo Vedno skrij občutljivo vsebino Vedno prikaži občutljivo vsebino @@ -670,8 +689,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Piši v rele Količina bajtov, ki je bila poslana temu releju, vključno s filtri in dogodki Količina bajtov, ki je bila prejeta od tega releja, vključno s filtri in dogodki + dogode shranjen Prišlo je do napake pri pridobivanju informacij o releju iz %1$s Lastnik + V uporabi pri Servisni ključ V teku %1$s V teku %1$s (%2$s) @@ -808,6 +829,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pošiljatelji nezaželjenih vsebin Utišano. Klikni za vklop zvoka Zvok je prižgan. Klikni da ga utišaš + Skoči nazaj za %d sekund + Skoči nazprej za %d sekund + Slika v sliki Išči po lokalnih in zunanjih arhivskih zapisih Nostr naslov je preverjen Preverjanje Nostr naslova ni uspelo @@ -827,6 +851,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nalaganje lokacije Brez dovoljenj za lokacijo Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo + Razlog (neobvezen) Nova funkcija Za aktivacijo tega načina mora Amethyst poslati sporočilo NIP-17 (GiftWrapped, šifrirana neposredna in skupinska sporočila). NIP-17 je nov in večina Nostr odjemalcev ga še ni implementirala. Prepričajte se, da prejemnik uporablja združljiv Nostr odjemalec. Aktiviraj @@ -865,6 +890,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kdaj naložiti slike Kopiraj Stack Kopiraj v odložišče + Kopiraj nprofil v odložišče Kopiraj npub v odložišče Deli ali shrani Kopiraj URL v odložišče @@ -919,6 +945,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prepričajte se, da je aplikacija za podpisovanje odobrila to transakcijo Denarnica za plačilo z \"lightning\" ni najdena (Napaka: %1$s). Prosimo, namestite \"lightning\" denarnico da boste lahko Zap-ali ostale Denarnica za plačilo z \"lightning\" ni najdena. Prosimo, namestite \"lightning\" denarnico da boste lahko Zap-ali ostale + Ne morem odpreti Blossom povezav + Aplikacije Blossom niso bile najdene. Za ogled te datoteke namestite lokalno aplikacijo Blossom. Skrite besede Skrij novo besedo ali stavek Slika profila @@ -1022,6 +1050,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ni bilo mogoče pripraviti informacij glave: %1$s Stiskanje preklicano Stiskanje ni vrnilo datoteke + Šifriraj datoteke + Za večjo zasebnost datoteke pred nalaganjem šifrirajte. Nekateri strežniki pri brezplačnih računih morda ne sprejemajo šifriranih datotek. + Šifrirano nalaganje ni uspelo + Številni strežniki pri brezplačnih računih ne sprejemajo šifriranih datotek. Poskusite lahko znova brez šifriranja. + Poskusite znova brez šifriranja. + Pozor: Če datoteka ni šifrirana, je vsebina dostopna vsem, ki imajo povezavo. Kakovost medija Izberite Nizko kakovost, da stisnete svoj medij v manjšo datoteko z nižjo kakovostjo, Visoko kakovost, da stisnete v večjo datoteko z višjo kakovostjo, ali Nestisnjeno, da naložite medij brez stiskanja. Nizka @@ -1030,6 +1064,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nestisnjeno Uporabi H.265/HEVC Codec Boljša kakovost pri manjših velikostih datotek, vendar predvajanja H.265 ne podpirajo vse naprave. + Odstrani zasebne metapodatke + Poskusi odstraniti zasebne metapodatke iz podprtih datotek pred nalaganjem. + Metapodatkov ni možno odstraniti + Ta oblika zapisa ne podpira odstranjevanja metapodatkov. Datoteka lahko vsebuje zasebne podatke, kot sta lokacija in podatki o napravi. Želite vseeno naložiti? + Vseeno naloži + Zasebnih metapodatkov ni bilo mogoče odstraniti. Nalaganje je preklicano. + Nalaganje je preklicano. Uredi osnutek Vpiši se z QR kodo Pot @@ -1041,12 +1082,46 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Globalno Kratki mediji Šah + Denarnica + Bilanca + Pošlji + Prejmi + Transakcije + Denarnica ni povezana + Za uporabo denarnice v nastavitvah zapov vzpostavite povezavo Nostr Wallet Connect (NWC). + Nastavi denarnico + sats + prilepi BOLT-11 fakturo + Plačaj + Plačilo uspešno + Pošiljam plačilo… + Vsota (sats) + Opis (Neobvezno) + Ustvari fakturo + Ustvarjam fakturo… + Kopiraj fakturo + Ni transakcij + Nalagam… + Prejeto + Poslano + Osveži + Vse + Zapi + Brez zapov Varnostni filtri + Uvozi sledilce Nova objava Novi kratki mediji: slike ali posnetki Novo zapisek skupnosti Nov produkt Nova geo-ekskluzivna objava + Nov članek + Naslov + Povzetek (neobvezno) + URL naslov naslovne slike (neobvezno) + Napiši članek v obliki Markdown… + Predogled + Uredi Odpri vse reakcije na to objavo Zapri vse reakcije na to objavo Odgovori @@ -1054,6 +1129,21 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Všečkaj Zap Spremeni hitre reakcije + Vrstica odzivnih ikon + Nastavite prikaza gumbov za odzive, njihov vrstni red in prikaz števcev. + Omogočeno + Prikaži števec + Prerazporedi + Odgovori + Odgovori na ta zapisek + Pošlji naprej + Ponovno objavi ali citiraj to objavo + Všečkaj + Odzovi se na to objavo z emojijem + Zap + Pošlji avtorju plačilo prek Lightninga + Deli + Deli to objavo z drugimi Slika profila %1$s Rele %1$s Razširi seznam relejev @@ -1072,7 +1162,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prekliči Zap razdelitev Dodaj opozorilo o vsebini Odstrani opozorilo o vsebini + Dodaj datum poteka + Odstrani datum poteka + Datum poteka + Nostr odjemalci bodo po tem datumu skrili objavo (NIP-40) + Izberite datum in čas poteka + Se izteče čez %1$s + Ura poteka Prikaži npub kot QR kodo + Prikaži nprofil kot QR kodo Neveljaven naslov Amethyst je prejel zahtevo da odpre URI, vendar je bil ta URI neveljaven: %1$s Releji predala zasebnih sporočil @@ -1085,6 +1183,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. Nastavi zdaj + Releji za prejeto pošto (ZS) niso bili najdeni. Sporočil ni mogoče dostaviti, dokler uporabnik ne nastavi seznama svojih relejev. Iskalni releji Nastavi iskalne releje Ustvarjanje seznama relejev, posebej zasnovanega za iskanje in označevanje uporabnikov, bo izboljšalo te rezultate. @@ -1141,6 +1240,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Blokirani releji Blokirani releji Amethyst se ne bo nikoli povezal na te releje + Izvozi nastavitve releja + Izvozi kot tekst + Izvozi kot ZIP (JSON) Zap-ni ustvarjalce! Vaša donacija nam pomaga narediti spremembo. Vsak satoši šteje! Doniraj sedaj @@ -1233,6 +1335,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Moji seznami Uporabniki Izberite seznam za filtriranje vsebin + Vsebina + Ključniki + Skupnosti + Seznami Odjava ob zaklepu naprave Zasebno sporočilo Javno sporočilo @@ -1246,6 +1352,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Išči ključnik: #%1$s Ne prevedi iz Tu prikazani jeziki ne bodo prevedeni. Izberite jezik, da ga odstranite in ga znova prevedete. + Prevedi v + Izberi jezik v katerega želite prevesti vsebino. + Nastavitve prikaza jezika + Za vsak preveden jezikovni par izberite, kateri jezik naj bo prikazan prvi. + %1$s → %2$s + Išči jezike + Dodaj jezik + Dodaj jazikovni par + Izvorni jezik + Ciljni jezik + Prikaži najprej %1$s + Prednostni prikaz jezikov še ni nastavljen. Nastavitve se ustvarijo samodejno ob prevajanju, lahko pa jih dodate ročno. + Izbriši nastavitev Premor Predvajaj Odpri spustni meni @@ -1255,6 +1374,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Najdeno je poročilo o napaki Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani. Pošlji + To sporočilo bo izginilo čez %1$s To sporočilo bo izginilo čez %1$d dni Izberi podpisnika Že na seznamu @@ -1275,15 +1395,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Novo ime paketa Opis paketa Nov opis paketa - Oddajaj seznam - Oddajaj paket + Razpošlji seznam + Razpošlji paket Izbriši seznam Izbriši paket Tipi Ponovni poskus ni uspel Kliknite za ogled podrobnosti %1$s poslano - Rezultat oddajanja + Razpošlji rezultate Razpošlji (%1$d) Strni Razširi @@ -1297,9 +1417,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ogled Zavrni %1$d/%2$d - Oddajanje + Oddajam Oddajam %1$s - Oddajam %1$d dogodkov... + Oddajam %1$d dogodkov… Poslano %1$d dogodkov Nekaterim dogodkom ni uspelo Vsem dogodkom je uspelo @@ -1310,7 +1430,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Končni čas Potrdi Naprej - Dodaj gumb za glasovanje + Dodaj gumb za anketo Nazaj Dovoli Povezano @@ -1328,4 +1448,246 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem in %1$d ostalih Odstrani %1$s + Izhodni releji + Aplikacije + Priporočila aplikacij + Nastavitve uporabnika + Zvočna glava + Zvočni zapis + Nagradne značke + Definicija značke + Značke profila + Blokirani releji + Blossom strežniki + Blossom avtentikacija + Oddajni releji + Seznam zaznamkov + Dnevni termin + Koledar + Sestanek + Potrditev termina + Šahovske igre + Šahovska avtentikacija + Priljubljeni releji + Šahovski izzivi + Sprejmi partijo + Šahovska poteza + Konec partije + Predlagaj remi + Definicija kanala + Skrij sporočilo v kanalu + Seznam kanalov + Sporočilo v kanalu + Metapodatki kanala + Utišani uporabniki kanala + Datoteka ZS + ZS sporočilo + Releji ZS + Oglasi + Komentarji + Opis skupnosti + Seznam skupnosti + Objava skupnosti + Seznam sledenih + Izbrisano + Osnutki + Zbirke emojijev + Pregled zbirk emojijev + Minljivi klepet + Minljive klepetalnice + Glave datotek + Galerija profila + Datotečni strežniki + Blob podatki + Blob glava + Zdravstveni podatki + Paketi za sledenje + Ponovno preposlani (16) + Sledeni geohash-i + GiftWraps + Git zadeva + Git popravek + Git repozitorij + Git odgovor + Ciljni znesek zapov + Sledeni ključniki + Osvetlitve + Http Auth + Indeksiraj seznam relejev + Pustolovski prolog + Pustolovska scena + Pustolovsko branje + Poimenovani zaznamki + Pogovori v živo + Prenosi v živo + Zapi + NWC zahtevek + NWC odgovor + Zasebni zapi + Zap zahtevek + Blogi + Sejna soba + Prisotnost v sobi + Srečevalnica + Profil + Seznam utišanih + NNS + NIP + Nostr Connect + DVM status + DVM zahtevek vsebine + DVM odgovor na vsebino + DVM zahtevek uporabnika + DVM odgovor uporabnika + OTS + Plačaj + Seznam oseb + Fotografije + Bucike + Zap sklad + Anketa + Odziv na anketo + NIP-04 ZS + Zasebni releji + Proxy releji + Javno sporočilo + Odzivne ikone + Kartica kontakta + Autorizacija releja + Odkrivanje relejev + Obvestilo nadzornika relejev + Nabor relejev + Prijave + Ponovne objave + Brisanje uporabnika + Pečat + Iskalni releji + Status uporabnika + Zapiski + Urejeni + Torenti + Komentarji torentov + Zaupanja vredni releji + Zaupanja vredni ponudniki + Videoposnetki (odgovor) + Kratki posnetki (odgovor) + Videoposnetki + Kratki videoposnetki + Zvočno sporočilo + Zvočni odgovori + Wiki + Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate. + Uvozi seznam sledenih + Izberite uporabnike, ki jim želite slediti + Profil, iz katerega želite uvoziti + Iskanje, npub1…, janez@primer.si + Podpira npub, nprofile, NIP-05, hex in namecoin (.bit, d/, id/) + Poišči seznam sledenih + Daj napitnino + Najdeno %1$d računov + Izbranih %1$d + Razrešeno prek Namecoina + Zdaj sledite %1$d računom + Vaš vsebina objav je pripravljen. + Preskoči + Poišči še koga + Sledi %1$d računom + Dodaten uvoz + Nadaljuj + Preskoči za zdaj + Razreševanje %1$s… + Pridobivanje seznama sledenih… + Ni najdenih sledilcev + Sledenje %1$d računom… + "Vnesite profil prijatelja ali vodje skupnosti. Uporabite lahko njihov npub, naslov NIP-05 ali ime Namecoin (npr. janez@primer.bit ali id/janez) za identitete, preverjene prek verige blokov." + Izberi vse + %1$d%% časa delovanja + Namecoin nastavitve + Sinhronizacija relejev + Sinhronizacija relejev + Ponovno objavite svoje dogodke na vseh znanih relejih, da posodobite svoje izhodne, vhodne in releje ZS. Zahtevana je povezava Wi-Fi — postopek lahko porabi veliko podatkov. + Odpri sinhronizacijo relejev… + Kaj to naredi + To orodje pregleda vse releje, ki jih je zaznala vaša aplikacija, in vaše dogodke prerazporedi na ustrezne cilje: + Prenesite vse dogodke katere ste ustvarili, in jih pošljite v vaše izhodne releje. + Prenesite vse dogodke, v katerih ste omenjeni, in jih pošljite v vaše vhodne releje. + Prenesite vsa zasebna sporočila, naslovljena na vas, in jih pošljite v svoje releje za zasebna sporočila. + ⚠ Kaže, da uporabljate omejeno ali mobilno povezavo. Ta postopek lahko prenese zelo veliko količino podatkov. Pred začetkom se povežite v omrežje Wi-Fi. + Uporabi mobilne podatke? + Začni sinhronizacijo + Vseeno začni (mobilni podatki) + Premor + Nadaljuj + Začni znova + Prekini + Releji: %1$d / %2$d + Prerazporejeni dogodki: %1$d novih od %2$d poslanih in %3$d prejetih + Premor sinhronizacije + Zaključenih %1$d od %2$d relejev — do zdaj prerazporejenih %3$d dogodkov. Dotaknite se »Nadaljuj« za nadaljevanje. + Sinhronizacija zaključena + Naprej posredovanih %1$d od %2$d prejetih dogodkov do ciljnih relejev. + Število dogodkov, ki jih je ciljni rele sprejel kot nove: %1$d. + Zaključeno v %1$d sekundah. + Napaka pri sinhronizaciji + Pošiljam k + Odhodni + Vhodni + ZS + Trenutno preverjam (%1$d relejev) + Zaključeno (%1$d relejev) + poslano %1$s + sprejeto %1$s + nov %1$s + ni dogodkov + Bitcoin Raziskovalec (OTS) + dogodki + ZS + profili + Nastavitve releja + Nazadnje viden pred %1$s + <%1$s + Povezujem + Prenašam + Napaka + Zaključeno + + Označi kot prebrano + Možnosti zapiska + Možnosti profila + Možnosti predstavnosti + Predvajaj + Možnosti paketa + Možnosti seznama + Možnosti zaznamkov + Možnosti skupine + Dodaj zaznamek + Dodaj člana + Urejanje seznamov + Možnosti povezave + Izvozi + Overitev + Veljaven + Neveljaven + Sprejeto + Zavrnjeno + Preverjam + Preverjeno + Preklicano + Veljaven od %1$s + Veljaven do %1$s + Prošnja za overitev + Pridobivanje overitve za dogodek + Priporočeni potrjevalci + Priporočeno za tipe: + Usposobljenost potrjevalca + Izurjen za preverjanje tipov (Kinds): %1$s + Overja + Zahteva overitev za + Časovno obdobje + Od + Do + Zdaj + Vse do zdaj + Zadnja sinhronizacija: %1$s + Od zadnje sinhronizacije diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 0f3a3ed066..c05bfb1e6e 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -34,4 +34,5 @@ Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове Нема подешавања износа Зап. Дуго притисните да бисте променили + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index d48eddc078..777f8fa18f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -246,6 +246,7 @@ "Det gick inte att läsa in svar: " Försök igen Inga aviseringar ännu. + Öppen omröstning Flöde är tomt. Uppdatera Skapad @@ -289,6 +290,7 @@ Nostr-adress aldrig nu + sekunder t m d @@ -535,7 +537,7 @@ Ny Lägg till författare i följelista Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare. - Ikon för %1$s-lista + Ikon för lista %1$s är en offentlig medlem %1$s är en privat medlem Lägg till som offentlig medlem @@ -633,6 +635,10 @@ %1$s sats Från %1$s till %1$s + Svara + Markera som läst + Nya meddelanden + Nya zaps Meddela: Gå med i konversation Användare eller grupp ID @@ -668,6 +674,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 @@ -1027,6 +1034,12 @@ Kunde inte förbereda header information: %1$s Komprimering avbruten Komprimering misslyckades att returnera en fil + Kryptera filer + Kryptera filer innan uppladdning för integritet. Vissa servrar kanske inte accepterar krypterade filer på gratiskonton. + Krypterad uppladdning misslyckades + Många servrar accepterar inte krypterade filer på gratiskonton. Du kan försöka igen utan kryptering. + Försök igen utan kryptering + Varning: Utan kryptering kan vem som helst med fillänken se innehållet. Mediakvalitet Välj Låg kvalitet för att komprimera ditt media till en mindre fil med lägre kvalitet, Hög kvalitet för att komprimera till en större fil med högre kvalitet eller Okomprimerad för att ladda upp media utan kompression. Låg @@ -1035,6 +1048,13 @@ Okomprimerad Använd H.265/HEVC-codec Bättre kvalitet med mindre filstorlek, men inte alla enheter stöder H.265-uppspelning. + Ta bort privat metadata + Försöker ta bort privat metadata från mediefiler som stöds innan uppladdning + Metadata kunde inte tas bort + Detta filformat stöder inte borttagning av metadata. Privat information som plats och enhetsinformation kan ingå. Ladda upp ändå? + Ladda upp ändå + Kunde inte ta bort privat metadata från media. Uppladdning avbruten. + Uppladdning avbruten Redigera utkast Logga in med QR-kod Rutt @@ -1069,6 +1089,9 @@ Mottagen Skickad Uppdatera + Alla + Zaps + Övriga Säkerhetsfilter Importera följare Nytt inlägg @@ -1076,6 +1099,13 @@ Nytt Community-meddelande Ny produkt Nytt Geo-Exklusivt inlägg + Ny artikel + Titel + Sammanfattning (valfritt) + URL för omslagsbild (valfritt) + Skriv din artikel i markdown… + Förhandsgranskning + Redigera Öppna alla reaktioner på detta inlägg Stäng alla reaktioner på detta inlägg Svara @@ -1136,6 +1166,7 @@ Bra alternativ är:\n - auth.nostr1.com (gratis)\n - inbox.nostr.wine (betalad)\n - relay.0xchat.com (gratis) Sätt in mellan 1–3 reläer för att fungera som din privata inkorg. DM Inkorg reläer bör acceptera alla meddelanden från vem som helst, men endast tillåta dig att ladda ner dem. Ställ in nu + DM-inkorgsreläer hittades inte. Meddelanden kan inte levereras förrän de konfigurerar sin relälista. Sökreläer Ställ in dina sökreläer Att skapa en relälista speciellt utformad för sökning och användartaggning kommer att förbättra dessa resultat. @@ -1212,6 +1243,7 @@ OTS: %1$s Tidsstämpel Bevis Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. + Redigera artikel Redigera inlägg Förslag till att förbättra ditt inlägg Sammanfattning av ändringar @@ -1287,6 +1319,10 @@ Mina listor Användare Välj en lista för att filtrera flödet + Flöden + Hashtaggar + Gemenskaper + Listor Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1551,5 +1587,91 @@ Välj alla %1$d%% drifttid Namecoin-inställningar + Relä-synkronisering + Relä-synkronisering + Publicera om dina händelser på alla kända reläer för att hålla dina utkorgs-, inkorgs- och DM-reläer uppdaterade. Kräver Wi-Fi — detta kan använda mycket data. + Öppna relä-synkronisering… + Vad detta gör + Det här verktyget söker igenom alla reläer som din app har sett och omfördelar dina händelser till rätt destinationer: + Ladda ner alla händelser du skapat och skicka dem till dina utkorgsreläer. + Ladda ner alla händelser som nämner dig och skicka dem till dina inkorgsreläer. + Ladda ner alla direktmeddelanden adresserade till dig och skicka dem till dina DM-reläer. + ⚠ Du verkar vara på en mätad eller mobil anslutning. Denna åtgärd kan överföra mycket stora mängder data. Anslut till Wi-Fi innan du startar. + Använda mobildata? + Starta synkronisering + Starta ändå (mobildata) + Pausa + Återuppta + Börja om + Avbryt + Reläer: %1$d / %2$d + Omfördelade händelser: %1$d nya av %2$d skickade och %3$d mottagna + Synkronisering pausad + %1$d av %2$d reläer klara — %3$d händelser omfördelade hittills. Tryck på Återuppta för att fortsätta. + Synkronisering klar + Vidarebefordrade %1$d händelser till destinationsreläer av %2$d mottagna. + %1$d händelser accepterade som nya av destinationsreläer. + Slutfört på %1$d sekunder. + Synkroniseringsfel + Skickar till + Utkorg + Inkorg + DM + Kontrollerar (%1$d reläer) + Slutfört (%1$d reläer) + skick. %1$s + mott. %1$s + nya %1$s + inga händelser Bitcoin Explorer (OTS) + händelser + DMs + profiler + relä inställningar + Senast sedd för %1$s sedan + <%1$s + Ansluter + Laddar ner + Fel + Slutfört + + Markera som läst + Anteckningsåtgärder + Profilåtgärder + Medieåtgärder + Uppspelning + Paketåtgärder + Liståtgärder + Bokmärkesåtgärder + Gruppåtgärder + Lägg till bokmärke + Lägg till medlem + Listhantering + Länkåtgärder + Exportera + Attestering + Giltig + Ogiltig + Accepterad + Avvisad + Verifierar + Verifierad + Återkallad + Giltig från %1$s + Giltig till %1$s + Attesteringsförfrågan + Begär attestering för en händelse + Attestantens rekommendation + Rekommenderad för typer: %1$s + Attestantens kompetens + Kompetent att verifiera typer: %1$s + Intygar + Begär attestering till + Datumintervall + Från + Till + Nu + All tid + Senaste synkronisering: %1$s + Sedan senaste synkronisering diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index eede00f35d..0e3f92eeef 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -444,4 +444,5 @@ Ulimwenguni Tafuta + diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index e79aecaf2c..090879e2a2 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -370,4 +370,5 @@ இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 337dec3eb9..562618425f 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -815,4 +815,5 @@ แลือกลิสต์เพื่อโชว์ ออกจากระบบ + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 7fde8f9e54..307dff61dd 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -168,4 +168,5 @@ Tümünü okundu olarak işaretle Hata + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 9e18b6bfcb..14d221f281 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -500,4 +500,5 @@ Не вдалося завантажити завантажені медіафайли з сервера Не вдалося підготувати локальний файл для завантаження: %1$s + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index ae64d7d1f1..b2f187423e 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -69,4 +69,5 @@ "bu kanalda: " Profil banneri + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 5b4876f370..fc34437331 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -12,4 +12,5 @@ Lưu ý cho người nhận Cảm ơn rất nhiều! + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 6d7eafd38e..a63640a6ff 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -246,6 +246,7 @@ "加载回复出错:" 重试 暂无通知 + 开放投票 信息流为空。 刷新 创建 @@ -289,6 +290,7 @@ Nostr 地址 从不 现在 + @@ -638,6 +640,10 @@ %1$s聪 来自 %1$s 为 %1$s + 回复 + 标记为已读 + 新信息 + 新打闪 通知: 加入对话 用户或群组 ID @@ -673,6 +679,7 @@ 写入到继电器 向该中继发送事件和过滤请求所使用的数据量 从该中继接收事件和过滤响应所使用的数据量 + 已保存的事件 尝试从 %1$s 获取中继器信息时出错 机主 使用者 @@ -1033,6 +1040,12 @@ 无法准备头部信息:%1$s 压缩已取消 压缩返回的文件失败 + 加密文件 + 上传前为了隐私加密文件。有些服务器可能不接受免费账户的加密文件。 + 加密上传失败 + 许多服务器不接受免费账户的加密文件。您可以不加密重试。 + 不加密重试 + 警告:没有加密,任何有文件链接的人都可以看到内容。 媒体质量 选择「低质量」来将你的媒体文件压缩到较小体积,或者选择「高质量」来将你的媒体文件压缩到较大体积。 低质量 @@ -1041,6 +1054,13 @@ 未压缩 使用 H.265/HEVC 编解码器 文件较小而质量更佳,但不是所有设备都支持 H.265 播放。 + 删除私密元数据 + 尝试在上传之前从支持的媒体文件中删除私密元数据 + 无法删除元数据 + 此文件格式不支持删除元数据。可以包含位置和设备信息等私人信息。仍然要上传吗? + 仍然上传 + 从媒体删除私密元数据失败。上传取消。 + 上传已取消 编辑草稿 使用二维码登录 路径 @@ -1052,6 +1072,32 @@ 全球 短篇 国际象棋 + 钱包 + 余额 + 发送 + 接收 + 交易 + 未连接钱包 + 在打闪设置中设置Nostr Wallet Connect (NWC) 连接以使用钱包。 + 设置钱包 + + 粘贴 BOLT-11 发票 + 付款 + 支付成功 + 正在发送付款… + 聪金额 + 描述(可选) + 创建发票 + 正在创建发票… + 复制发票 + 尚无交易 + 正在加载… + 已收到 + 已发送 + 刷新 + 全部 + 打闪 + 非打闪 安全滤镜 导入关注 新帖子 @@ -1059,6 +1105,13 @@ 新社区笔记 新产品 新建地理位置限定帖文 + 新文章 + 标题 + 摘要(选填) + 封面图片URL (可选) + 用 markdown 格式撰写文章… + 预览 + 编辑 展开对此帖子的所有回应 收起对此帖子的所有回应 回复 @@ -1099,6 +1152,13 @@ 取消打闪拆分 添加内容警告 移除内容警告 + 添加过期日期 + 删除过期日期 + 过期日期 + 帖子将在此日期后被客户端隐藏 (NIP-40) + 选择到期日期和时间 + 在 %1$s 过期 + 到期时间 将 npub 显示为二维码 以二维码显示 nprofile 地址无效 @@ -1112,6 +1172,7 @@ 示例:\n - auth.nostr1.com (免费)\n - inbox.nostr.wine (付费)\n - revisy.0xchat.com (免费) 设置 1 ~ 3 个私人收件箱中继。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。 立即设置 + 未找到私信收件箱中继。配置中继列表前无法传送消息。 搜索中继 设置你的搜索中继 通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。 @@ -1120,7 +1181,7 @@ 发件箱中继 设置您的公共发件箱中继来发布内容 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 - 插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 + 插入 1–3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social 收件箱中继 设置您的公共收件箱中继来接收通知 @@ -1168,6 +1229,9 @@ 中继黑名单 中继黑名单 应用永远不会连接的中继 + 导出中继设置 + 导出为文本 + 导出为 ZIP (JSON) 打闪开发人员! 你的捐赠帮助我们做出不同的贡献。每个聪都很重要! 立即捐款 @@ -1185,6 +1249,7 @@ OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 + 编辑文章 编辑帖子 提议改进帖子 变动摘要 @@ -1260,6 +1325,10 @@ 我的列表 用户 选择一个用于过滤订阅源的列表 + + 话题标签 + 社区 + 列表 当设备锁定时注销 私信 公开消息 @@ -1295,6 +1364,7 @@ 找到了崩溃报告 要用私信将最近的崩溃报告发送给 Amethyst 吗?不会分享个人信息 发送它 + 此消息将在 %1$s 后消失 此消息将在 %1$d 天内消失 选择签名者 已经在列表中 @@ -1523,4 +1593,91 @@ 全选 %1$d%% 运行时间 Namecoin 设置 + 中继同步 + 中继同步 + 在所有已知的中继重新发布您的事件,以保持您的发件箱、收件箱和私信中继是最新的。 需要 Wi-Fi - 这可能使用大量数据。 + 打开中继同步… + 这是做什么 + 此工具扫描您的应用已看到的每一个中继并将您的事件重新分发到正确的目的地: + 下载您编写的所有事件并发送到您的发件箱中继中。 + 下载所有提到您的事件并发送到您的收件箱中继中。 + 下载发送给您的所有私信并发送到您的私信中继。 + ⚠ 您似乎在按流量计费或移动网络连接上。此操作可能传输大量数据。启动前请连接到 Wi-Fi。 + 使用移动数据? + 开始同步 + 仍要启动(移动数据) + 暂停 + 恢复 + 重来 + 取消 + 中继: %1$d / %2$d + 已重新分发事件:%1$d 个新事件,共 %2$d 个已发送事件和 %3$d 个已接收事件 + 同步已暂停 + 完成了 %2$d 个中继中的 %1$d 个中继 — 迄今为止重新分发了 %3$d 个事件。轻按“恢复“继续。 + 同步完成 + 转发了 %1$d 个事件到目标中继,共接收到 %2$d 个事件。 + %1$d 个事件被目标中继接受为新事件。 + 在 %1$d 秒内完成。 + 同步错误 + 发送到 + 发件箱 + 收件箱 + 私信 + 当前正在检查 (%1$d 个中继) + 已完成 (%1$d 个中继) + 已发送 %1$s + 已收到 %1$s + 新增 %1$s + 没有事件 + 比特币资源管理器 (OTS) + 事件 + 私信 + 个人资料 + 中继设置 + 上次看见在 %1$s 秒前 + <%1$s + 连接中 + 下载中 + 错误 + 已完成 + + 标记为已读 + 笔记操作 + 配置文件操作 + 媒体操作 + 播放 + 包操作 + 列表操作 + 书签操作 + 群操作 + 添加书签 + 添加成员 + 列表管理 + 链接操作 + 导出 + 证明 + 有效 + 无效 + 已接受 + 已拒绝 + 验证中 + 已验证 + 已撤销 + 有效期从 %1$s + 有效期到 %1$s + 证明请求 + 请求某个事件的证明 + 证明人建议 + 推荐的类型: + 证明人熟练度 + 熟练验证类型:%1$s + 证明 + 请求证明 + 日期范围 + + + 刚刚 + 全部时间 + 上次同步: %1$s + 自上次同步后 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 9c99f2f57e..8dbf75e362 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -240,4 +240,5 @@ 聰數量 "“正在查找事件%1$s”" + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 57843dc08c..88bf85950e 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 913f33afd9..2e60173154 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -763,4 +763,5 @@ 正在等待 DVM 確認付款或返回結果 添加 NIP-96 服務器 + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d2ae0d7247..9652292077 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -258,6 +258,7 @@ "Error loading replies: " Try again No notifications yet. + Open Poll Feed is empty. Refresh created @@ -308,6 +309,7 @@ LNURL… never now + seconds h m d @@ -481,6 +483,8 @@ Zap maximum Consensus (0–100)% + Single choice + Multiple choice Poll Closing Date & Time Poll closes in %1$s Close after @@ -537,6 +541,10 @@ No trace in Nostr, only in Lightning + Anonymous + Post as a new throwaway identity. Your account will not be linked to this reply. + This reply will be posted from a new anonymous identity + File Server Choose a server to upload this file to @@ -732,6 +740,11 @@ From %1$s for %1$s + Reply + Mark Read + New messages + New zaps + Notify: Join Conversation @@ -1216,6 +1229,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 @@ -1224,6 +1244,14 @@ Uncompressed Use H.265/HEVC Codec Better quality at smaller file sizes but not all devices support H.265 playback. + Remove private metadata + Attempts to strip private metadata from supported media files before uploading + + Metadata could not be removed + This file format does not support metadata stripping. Private information such as location and device info may be included. Upload anyway? + Upload anyway + Failed to strip private metadata from media. Upload cancelled. + Upload cancelled Edit draft @@ -1260,6 +1288,9 @@ Received Sent Refresh + All + Zaps + Non-Zaps Security Filters Import Follows @@ -1268,6 +1299,14 @@ New Community Note New Product New Geo-Exclusive Post + New Article + + Title + Summary (optional) + Cover image URL (optional) + Write your article in markdown… + Preview + Edit Open all reactions to this post Close all reactions to this post @@ -1341,6 +1380,7 @@ Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free) Insert between 1–3 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them. Set up now + DM inbox relays not found. Messages cannot be delivered until they configure their relay list. Search Relays Set up your Search relays @@ -1434,6 +1474,7 @@ Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. + Edit Article Edit Post Proposal to improve a post Summary of changes @@ -1521,7 +1562,11 @@ My Lists/Sets My Lists Users - Select a list to filter the feed + Select an option to filter the feed + Feeds + Hashtags + Communities + Lists Log off on device lock Private Message @@ -1801,9 +1846,94 @@ Select All %1$d%% uptime Namecoin Settings + Relay Sync + Relay Sync + Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data. + Open Relay Sync… + What this does + This tool scans every relay your app has seen and redistributes your events to the correct destinations: + Download all events you authored and send them to your outbox relays. + Download all events that mention you and send them to your inbox relays. + Download all direct messages addressed to you and send them to your DM relays. + ⚠ You appear to be on a metered or mobile connection. This operation can transfer a very large amount of data. Connect to Wi-Fi before starting. + Use Mobile Data? + Start Sync + Start Anyway (mobile data) + Pause + Resume + Start Over + Cancel + Relays: %1$d / %2$d + Events redistributed: %1$d new out of %2$d sent and %3$d received + Sync Paused + Completed %1$d of %2$d relays — %3$d events redistributed so far. Tap Resume to continue. + Sync complete + Forwarded %1$d events to destination relays out of %2$d received. + %1$d events accepted as new by destination relays. + Completed in %1$d seconds. + Sync error + Sending To + Outbox + Inbox + DMs + Currently Checking (%1$d relays) + Finished (%1$d relays) + sent %1$s + recv %1$s + new %1$s + no events Bitcoin Explorer (OTS) events DMs profiles relay settings + Last seen %1$s ago + + <%1$s + Connecting + Downloading + Error + Completed + + + Mark as Read + Note Actions + Profile Actions + Media Actions + Playback + Pack Actions + List Actions + Bookmark Actions + Group Actions + Add Bookmark + Add Member + List Management + Link Actions + Export + Attestation + Valid + Invalid + Accepted + Rejected + Verifying + Verified + Revoked + Valid from %1$s + Valid to %1$s + Attestation Request + Requesting attestation for an event + Attestor Recommendation + Recommended for kinds: + Attestor Proficiency + Proficient in verifying kinds: %1$s + Attests to + Requests attestation to + + Date Range + Since + Until + Now + All time + Last sync: %1$s + Since Last Sync diff --git a/amethyst/src/test/java/android/util/Log.java b/amethyst/src/test/java/android/util/Log.java index af85d0b47e..245a440a12 100644 --- a/amethyst/src/test/java/android/util/Log.java +++ b/amethyst/src/test/java/android/util/Log.java @@ -1,6 +1,10 @@ package android.util; public class Log { + public static Boolean isLoggable(String tag, Integer msg) { + return true; + } + public static int d(String tag, String msg) { System.out.println("DEBUG: " + tag + ": " + msg); return 0; diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt index ff01ba1dc3..6694997de6 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt @@ -51,7 +51,7 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { benchmarkRule.measureRepeated { val db = runWithMeasurementDisabled { - EventStore(context, null) + EventStore(null) } firstThousandEvents.forEach { event -> try { @@ -81,7 +81,8 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { benchmarkRule.measureRepeated { val db = runWithMeasurementDisabled { - val db = EventStore(context, null) + val db = + EventStore(null) toBeDeletedEvents.forEach { event -> try { db.insert(event) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt index 26e2bc8d51..2a1764ddbd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt @@ -49,14 +49,14 @@ class LargeDBQueryingBenchmark : BaseLargeCacheBenchmark() { val allEvents = getEventDB().distinctBy { it.id }.sortedBy { it.createdAt } } - lateinit var db: EventStore + lateinit var db: com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore @Before fun setup() { val context = ApplicationProvider.getApplicationContext() context.deleteDatabase("allEvents.db") - db = EventStore(context, "allEvents.db") + db = EventStore("allEvents.db") allEvents.forEach { event -> try { db.insert(event) diff --git a/build.gradle b/build.gradle index d1d34260f7..e9bc0cb9b2 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,6 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) - alias(libs.plugins.stability.analyzer) apply false } allprojects { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt index 8c5a951fbe..0e39068cb3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt @@ -30,14 +30,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl */ object ChessConfig { /** - * The 3 main relays for chess events. - * These are used for both fetching and publishing chess events. + * Relays for chess events. + * Includes relays used by jester.nyo.dev (relay.damus.io, offchain.pub) + * and popular community relays for broader reach. */ val CHESS_RELAYS = listOf( "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", + "wss://offchain.pub", ) /** @@ -48,6 +50,7 @@ object ChessConfig { "relay.damus.io".normalizeRelayUrl(), "nos.lol".normalizeRelayUrl(), "relay.primal.net".normalizeRelayUrl(), + "offchain.pub".normalizeRelayUrl(), ) /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 3b58290799..87e7bd9ed1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -756,6 +756,9 @@ class ChessLobbyLogic( if (!result.liveState.isSpectator) { state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) + } else { + state.addSpectatingGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 2dba6f7601..c04a06f361 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -237,7 +237,7 @@ fun LiveChessGameScreen( ) { // Game info - use currentPosition.activeColor for turn display GameInfoHeader( - gameId = gameState.gameId, + gameId = gameState.startEventId, opponentName = opponentName, playerColor = gameState.playerColor, currentTurn = currentPosition.activeColor, @@ -463,7 +463,7 @@ private fun GameInfoHeader( // Show turn or game result when (gameStatus) { is GameStatus.Finished -> { - val result = (gameStatus as GameStatus.Finished).result + val result = gameStatus.result val resultText = when { result == GameResult.DRAW -> "Draw" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt index f9a1b51c3a..0f5cb96931 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt @@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material3.Icon @@ -82,7 +82,7 @@ fun MoveNavigator( enabled = currentMove > 0, ) { Icon( - Icons.Default.KeyboardArrowLeft, + Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous move", tint = if (currentMove > 0) { @@ -106,7 +106,7 @@ fun MoveNavigator( enabled = currentMove < totalMoves, ) { Icon( - Icons.Default.KeyboardArrowRight, + Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next move", tint = if (currentMove < totalMoves) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index a42ce31b01..d412167334 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -24,11 +24,12 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.DualCase @@ -108,6 +109,9 @@ interface IAccount { /** Send a NIP-17 gift-wrapped direct message */ suspend fun sendNip17PrivateMessage(template: EventTemplate) + /** Send a NIP-17 gift-wrapped encrypted file header */ + suspend fun sendNip17EncryptedFile(template: EventTemplate) + /** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */ suspend fun sendGiftWraps(wraps: List) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index a66d32babe..4edd15882f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -48,10 +48,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -90,8 +90,7 @@ class AddressableNote( override fun address() = address override fun createdAt(): Long? { - val currentEvent = event - if (currentEvent == null) return null + val currentEvent = event ?: return null if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt return currentEvent.createdAt } @@ -204,7 +203,7 @@ open class Note( is IsInPublicChatChannel -> { inGatherers?.forEach { - if (it is com.vitorpamplona.amethyst.commons.model.Channel) { + if (it is Channel) { it.relays().firstOrNull()?.let { return it } } } @@ -216,7 +215,7 @@ open class Note( is LiveActivitiesChatMessageEvent -> { inGatherers?.forEach { - if (it is com.vitorpamplona.amethyst.commons.model.Channel) { + if (it is Channel) { it.relays().firstOrNull()?.let { return it } } } @@ -230,14 +229,14 @@ open class Note( val currentOutbox = author?.outboxRelays()?.toSet() return if (relays.isNotEmpty()) { - if (currentOutbox != null && currentOutbox.isNotEmpty()) { + if (!currentOutbox.isNullOrEmpty()) { val relayMatchesOutbox = relays.firstOrNull { it in currentOutbox } if (relayMatchesOutbox != null) { return relayMatchesOutbox } } - return relays.firstOrNull() + relays.firstOrNull() } else { currentOutbox?.firstOrNull() ?: author?.mostUsedNonLocalRelay() } @@ -313,14 +312,14 @@ open class Note( zapPayments.keys + zapPayments.values.filterNotNull() - replies = listOf() - reactions = mapOf>() - boosts = listOf() - reports = mapOf>() - zaps = mapOf() - zapPayments = mapOf() + replies = listOf() + reactions = mapOf() + boosts = listOf() + reports = mapOf() + zaps = mapOf() + zapPayments = mapOf() zapsAmount = BigDecimal.ZERO - relays = listOf() + relays = listOf() if (repliesChanged) flowSet?.replies?.invalidateData() if (reactionsChanged) flowSet?.reactions?.invalidateData() @@ -990,11 +989,11 @@ class NoteState( fun List.eventIdSet() = mapNotNullTo(mutableSetOf()) { it.event?.id } -fun Array.events() = mapNotNull { it.note.event as? T } +inline fun Array.events() = mapNotNull { it.note.event as? T } -fun List.events() = mapNotNull { it.event as? T } +inline fun List.events() = mapNotNull { it.event as? T } -fun List.updateFlow(): Flow> = +inline fun List.updateFlow(): Flow> = if (this.isEmpty()) { MutableStateFlow(emptyList()) } else { @@ -1005,7 +1004,7 @@ fun List.updateFlow(): Flow> = } } -public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolean { +inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolean { if (this is Collection && isEmpty()) return false for (note in this) { val noteEvent = note.event as? T @@ -1014,7 +1013,7 @@ public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolea return false } -public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): List { +inline fun Iterable.filterEvents(predicate: (T) -> Boolean): List { if (this is Collection && isEmpty()) return emptyList() val dest = ArrayList() @@ -1027,7 +1026,7 @@ public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): Li return dest } -public inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { +inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { if (this is Collection && isEmpty()) return emptyList() val dest = ArrayList() @@ -1042,7 +1041,7 @@ public inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List< return dest } -public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { +inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { if (this is Collection && isEmpty()) return false for (note in this) { val noteEvent = note.event @@ -1051,7 +1050,7 @@ public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): return false } -fun List.latestByAuthor(): Map { +inline fun List.latestByAuthor(): Map { val oneResponsePerUser = mutableMapOf() forEach { note -> diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt index 9c6c6007ba..6356b2e372 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.startsWithAny import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import kotlinx.coroutines.CancellationException @Stable class Urls( @@ -77,30 +79,35 @@ class UrlParser { val blossom = mutableSetOf() urls.forEach { url -> - if (url.isValidTopLevelDomain()) { - if (url.wroteWithSchema()) { - if (url.originalUrl.startsWithAny(httpScheme)) { - // quick exit - completeUrls.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(nostrScheme)) { - bech32.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(websocketScheme)) { - relays.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(blossomScheme)) { - blossom.add(url.originalUrl) - } else { - completeUrls.add(url.originalUrl) - } - } else { - // emails are understood as urls from the detector. - if (url.isEmail()) { - Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { - emails.add(it.value) + try { + if (url.isValidTopLevelDomain()) { + if (url.wroteWithSchema()) { + if (url.originalUrl.startsWithAny(httpScheme)) { + // quick exit + completeUrls.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(nostrScheme)) { + bech32.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(websocketScheme)) { + relays.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(blossomScheme)) { + blossom.add(url.originalUrl) + } else { + completeUrls.add(url.originalUrl) } } else { - urlsWithoutScheme.add(url.originalUrl) + // emails are understood as urls from the detector. + if (url.isEmail()) { + Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { + emails.add(it.value) + } + } else { + urlsWithoutScheme.add(url.originalUrl) + } } } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UrlParser", "Trying to parse url `${url.originalUrl}` from `$content`", e) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt new file mode 100644 index 0000000000..88702439c3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.chess.RelaySyncState +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +enum class ChangeSource { + TEXT, + FORM, + INIT, +} + +@OptIn(FlowPreview::class) +class AdvancedSearchBarState( + private val scope: CoroutineScope, + private val debounceMs: Long = 300L, +) { + private val _query = MutableStateFlow(SearchQuery.EMPTY) + val query: StateFlow = _query.asStateFlow() + + private var _changeSource: ChangeSource = ChangeSource.INIT + val changeSource get() = _changeSource + + private val _rawText = MutableStateFlow("") + val rawText: StateFlow = _rawText.asStateFlow() + + val displayText: StateFlow = + combine(_query, _rawText) { query, raw -> + if (_changeSource == ChangeSource.TEXT) { + raw + } else { + QuerySerializer.serialize(query) + } + }.stateIn(scope, SharingStarted.Eagerly, "") + + val debouncedQuery: StateFlow = + _query + .debounce(debounceMs) + .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY) + + // People search results (from cache + relay) + private val _peopleResults = MutableStateFlow>(persistentListOf()) + val peopleResults: StateFlow> = _peopleResults.asStateFlow() + + // Note/event results (from relay subscriptions) + private val _noteResults = MutableStateFlow>(persistentListOf()) + val noteResults: StateFlow> = _noteResults.asStateFlow() + + // Sort orders + private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT) + val eventSortOrder: StateFlow = _eventSortOrder.asStateFlow() + + private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE) + val peopleSortOrder: StateFlow = _peopleSortOrder.asStateFlow() + + // Derived sorted results + val sortedNoteResults: StateFlow> = + combine(_noteResults, _eventSortOrder, _rawText) { notes, order, text -> + SearchResultSorter.sortEvents(notes, order, text).toImmutableList() + }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + val sortedPeopleResults: StateFlow> = + combine(_peopleResults, _peopleSortOrder) { people, order -> + SearchResultSorter.sortPeople(people, order).toImmutableList() + }.stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + private val activeSubIds = MutableStateFlow>(emptySet()) + val isSearching: StateFlow = + activeSubIds + .map { it.isNotEmpty() } + .stateIn(scope, SharingStarted.Eagerly, false) + + private val eventDeduplicator = EventDeduplicator() + + // Expanded panel state + private val _panelExpanded = MutableStateFlow(false) + val panelExpanded: StateFlow = _panelExpanded.asStateFlow() + + // Per-relay sync status + private val _relayStates = MutableStateFlow>(persistentListOf()) + val relayStates: StateFlow> = _relayStates.asStateFlow() + + // Text bar input + fun updateFromText(rawText: String) { + _changeSource = ChangeSource.TEXT + _rawText.value = rawText + _query.value = QueryParser.parse(rawText) + } + + // Form panel inputs + fun updateKinds(kinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(kinds = kinds.toImmutableList()) + } + + fun updatePseudoKinds(pseudoKinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(pseudoKinds = pseudoKinds.toImmutableList()) + } + + fun addAuthor(hexOrName: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + val hex = + com.vitorpamplona.quartz.nip19Bech32 + .decodePublicKeyAsHexOrNull(hexOrName) + if (hex != null) { + if (hex !in current.authors) { + _query.value = current.copy(authors = (current.authors + hex).toImmutableList()) + } + } else { + if (hexOrName !in current.authorNames) { + _query.value = current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList()) + } + } + } + + fun removeAuthor(hex: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + _query.value = + current.copy( + authors = current.authors.filter { it != hex }.toImmutableList(), + authorNames = current.authorNames.filter { it != hex }.toImmutableList(), + ) + } + + fun updateDateRange( + since: Long?, + until: Long?, + ) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(since = since, until = until) + } + + fun addHashtag(tag: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + val cleaned = tag.removePrefix("#") + if (cleaned !in current.hashtags) { + _query.value = current.copy(hashtags = (current.hashtags + cleaned).toImmutableList()) + } + } + + fun removeHashtag(tag: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + _query.value = current.copy(hashtags = current.hashtags.filter { it != tag }.toImmutableList()) + } + + fun addExcludeTerm(term: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + if (term !in current.excludeTerms) { + _query.value = current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList()) + } + } + + fun removeExcludeTerm(term: String) { + _changeSource = ChangeSource.FORM + val current = _query.value + _query.value = current.copy(excludeTerms = current.excludeTerms.filter { it != term }.toImmutableList()) + } + + fun updateLanguage(lang: String?) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(language = lang) + } + + fun initRelayStates(relays: Set) { + _relayStates.value = + relays + .map { + RelaySyncState( + url = it.url, + displayName = it.displayUrl(), + status = RelaySyncStatus.WAITING, + ) + }.toImmutableList() + } + + fun updateRelayState( + relayUrl: String, + status: RelaySyncStatus, + eventsDelta: Int = 0, + ) { + _relayStates.update { states -> + states + .map { + if (it.url == relayUrl) { + it.copy(status = status, eventsReceived = it.eventsReceived + eventsDelta) + } else { + it + } + }.toImmutableList() + } + } + + fun timeoutWaitingRelays() { + _relayStates.update { states -> + states + .map { + if (it.status == RelaySyncStatus.WAITING || it.status == RelaySyncStatus.CONNECTING) { + it.copy(status = RelaySyncStatus.FAILED) + } else { + it + } + }.toImmutableList() + } + activeSubIds.value = emptySet() + } + + fun togglePanel() { + _panelExpanded.value = !_panelExpanded.value + } + + fun updateEventSortOrder(order: SearchSortOrder) { + _eventSortOrder.value = order + } + + fun updatePeopleSortOrder(order: SearchSortOrder) { + _peopleSortOrder.value = order + } + + fun clearSearch() { + _changeSource = ChangeSource.INIT + _rawText.value = "" + _query.value = SearchQuery.EMPTY + _peopleResults.value = persistentListOf() + _noteResults.value = persistentListOf() + _relayStates.value = persistentListOf() + _eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT + _peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE + activeSubIds.value = emptySet() + eventDeduplicator.clear() + } + + // Results management (called from subscription callbacks) + fun startSearching(subId: String) { + activeSubIds.update { it + subId } + } + + fun stopSearching(subId: String) { + activeSubIds.update { it - subId } + } + + fun trackRelayEvent( + relayUrl: String, + eventId: String, + ): Boolean { + val isNew = eventDeduplicator.tryAdd(eventId) + if (isNew) { + updateRelayState(relayUrl, RelaySyncStatus.RECEIVING, eventsDelta = 1) + } + return isNew + } + + fun clearResults() { + _peopleResults.value = persistentListOf() + _noteResults.value = persistentListOf() + eventDeduplicator.clear() + } + + fun addPeopleResult(user: User) { + val current = _peopleResults.value + if (current.none { it.pubkeyHex == user.pubkeyHex }) { + _peopleResults.value = (current + user).toImmutableList() + } + } + + fun addNoteResults(events: List) { + if (events.isNotEmpty()) { + val current = _noteResults.value + _noteResults.value = (current + events).toImmutableList() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt new file mode 100644 index 0000000000..e7b1d053bf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +object DateUtils { + fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + + fun dateToUnix( + year: Int, + month: Int, + day: Int, + ): Long { + var totalDays = 0L + + for (y in 1970 until year) { + totalDays += if (isLeapYear(y)) 366 else 365 + } + + val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + if (isLeapYear(year)) daysInMonth[2] = 29 + for (m in 1 until month) { + totalDays += daysInMonth[m] + } + + totalDays += (day - 1) + + return totalDays * 86400L + } + + fun timestampToDate(timestamp: Long): String { + var remaining = timestamp + var year = 1970 + while (true) { + val daysInYear = if (isLeapYear(year)) 366L else 365L + val secondsInYear = daysInYear * 86400L + if (remaining < secondsInYear) break + remaining -= secondsInYear + year++ + } + + val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + if (isLeapYear(year)) daysInMonth[2] = 29 + + var dayOfYear = (remaining / 86400).toInt() + 1 + var month = 1 + while (month <= 12 && dayOfYear > daysInMonth[month]) { + dayOfYear -= daysInMonth[month] + month++ + } + + return "$year-${month.toString().padStart(2, '0')}-${dayOfYear.toString().padStart(2, '0')}" + } +} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt similarity index 72% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt index ad697c7f99..8a843955ae 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt @@ -18,14 +18,17 @@ * AN ACTION OF CONTRACT, TORT 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 +package com.vitorpamplona.amethyst.commons.search -import kotlinx.cinterop.ExperimentalForeignApi -import swiftbridge.UrlDetector +class EventDeduplicator { + private val lock = Any() + private val seenIds = mutableSetOf() -@Suppress("UNCHECKED_CAST") -@OptIn(ExperimentalForeignApi::class) -actual fun fastFindURLs(text: String): List { - val detectorInstance = UrlDetector() - return detectorInstance.findURLsWithText(text) as List + fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) } + + fun contains(id: String): Boolean = synchronized(lock) { id in seenIds } + + fun clear() = synchronized(lock) { seenIds.clear() } + + val size: Int get() = synchronized(lock) { seenIds.size } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt new file mode 100644 index 0000000000..e914be2941 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +data class ContentPreset( + val kinds: List = emptyList(), + val pseudoKind: String? = null, +) { + /** Check if this preset is active given current query state. */ + fun isSelected( + queryKinds: List, + queryPseudoKinds: List, + ): Boolean = + if (pseudoKind != null) { + pseudoKind in queryPseudoKinds + } else { + kinds.isNotEmpty() && queryKinds.containsAll(kinds) + } +} + +object KindRegistry { + val aliases: Map> = + mapOf( + "note" to listOf(TextNoteEvent.KIND), + "article" to listOf(LongTextNoteEvent.KIND), + "repost" to listOf(RepostEvent.KIND), + "profile" to listOf(MetadataEvent.KIND), + "channel" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), + "live" to listOf(LiveActivitiesEvent.KIND), + "community" to listOf(CommunityDefinitionEvent.KIND), + "wiki" to listOf(WikiNoteEvent.KIND), + "classified" to listOf(ClassifiedsEvent.KIND), + "highlight" to listOf(HighlightEvent.KIND), + ) + + val pseudoKinds: Set = setOf("reply", "media") + + val presets: Map = + mapOf( + "Notes" to ContentPreset(kinds = listOf(TextNoteEvent.KIND)), + "Articles" to ContentPreset(kinds = listOf(LongTextNoteEvent.KIND)), + "Media" to ContentPreset(pseudoKind = "media"), + "Channels" to ContentPreset(kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND)), + "Communities" to ContentPreset(kinds = listOf(CommunityDefinitionEvent.KIND)), + "Wiki" to ContentPreset(kinds = listOf(WikiNoteEvent.KIND)), + ) + + fun resolve(alias: String): List? = aliases[alias.lowercase()] + + fun isPseudoKind(alias: String): Boolean = alias.lowercase() in pseudoKinds + + fun nameFor(kind: Int): String? = aliases.entries.find { kind in it.value }?.key +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt new file mode 100644 index 0000000000..073e9cc41f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList + +sealed interface Token { + data class Operator( + val name: String, + val value: String, + val raw: String, + ) : Token + + data class Text( + val value: String, + ) : Token + + data object Or : Token + + data class Quoted( + val value: String, + val raw: String, + ) : Token + + data class Negation( + val term: String, + ) : Token + + data class Hashtag( + val tag: String, + ) : Token +} + +object QueryParser { + private val KNOWN_OPERATORS = setOf("from", "kind", "since", "until", "lang", "domain") + + fun parse(input: String): SearchQuery { + if (input.isBlank()) return SearchQuery.EMPTY + val tokens = tokenize(input) + return buildQuery(tokens) + } + + internal fun tokenize(input: String): List { + val tokens = mutableListOf() + var i = 0 + val len = input.length + + while (i < len) { + // Skip whitespace + if (input[i].isWhitespace()) { + i++ + continue + } + + // Quoted phrase + if (input[i] == '"') { + val start = i + i++ // skip opening quote + val sb = StringBuilder() + while (i < len && input[i] != '"') { + sb.append(input[i]) + i++ + } + if (i < len) i++ // skip closing quote + val value = sb.toString() + tokens.add(Token.Quoted(value, input.substring(start, i))) + continue + } + + // Negation + if (input[i] == '-' && i + 1 < len && !input[i + 1].isWhitespace()) { + i++ // skip - + val word = readWord(input, i) + i += word.length + if (word.isNotEmpty()) { + tokens.add(Token.Negation(word)) + } + continue + } + + // Hashtag + if (input[i] == '#' && i + 1 < len && !input[i + 1].isWhitespace()) { + i++ // skip # + val tag = readWord(input, i) + i += tag.length + if (tag.isNotEmpty()) { + tokens.add(Token.Hashtag(tag)) + } + continue + } + + // Read a word (may be operator:value, OR, or plain text) + val word = readWord(input, i) + i += word.length + + if (word.isEmpty()) { + i++ + continue + } + + // Check for OR keyword + if (word == "OR") { + tokens.add(Token.Or) + continue + } + + // Check for operator pattern (word:value) + val colonIdx = word.indexOf(':') + if (colonIdx > 0) { + val opName = word.substring(0, colonIdx).lowercase() + val opValue = word.substring(colonIdx + 1) + if (opName in KNOWN_OPERATORS && opValue.isNotEmpty()) { + tokens.add(Token.Operator(opName, opValue, word)) + continue + } + // Malformed operator (no value or unknown) → treat as text + } + + tokens.add(Token.Text(word)) + } + + return tokens + } + + private fun readWord( + input: String, + start: Int, + ): String { + var i = start + while (i < input.length && !input[i].isWhitespace()) { + i++ + } + return input.substring(start, i) + } + + private fun buildQuery(tokens: List): SearchQuery { + val authors = mutableListOf() + val authorNames = mutableListOf() + val kinds = mutableListOf() + val hashtags = mutableListOf() + val excludeTerms = mutableListOf() + val pseudoKinds = mutableListOf() + val textParts = mutableListOf() + val orTerms = mutableListOf() + var since: Long? = null + var until: Long? = null + var language: String? = null + var domain: String? = null + + // Collect OR groups: text terms separated by OR + var i = 0 + while (i < tokens.size) { + when (val token = tokens[i]) { + is Token.Operator -> { + when (token.name) { + "from" -> { + val hex = decodePublicKeyAsHexOrNull(token.value) + if (hex != null) { + authors.add(hex) + } else { + authorNames.add(token.value) + } + } + + "kind" -> { + if (KindRegistry.isPseudoKind(token.value)) { + pseudoKinds.add(token.value.lowercase()) + } else { + val resolved = KindRegistry.resolve(token.value) + if (resolved != null) { + kinds.addAll(resolved) + } else { + token.value.toIntOrNull()?.let { kinds.add(it) } + ?: textParts.add(token.raw) + } + } + } + + "since" -> { + val ts = parseDateToTimestamp(token.value) + if (ts != null) { + since = ts + } else { + textParts.add(token.raw) + } + } + + "until" -> { + val ts = parseDateToTimestamp(token.value) + if (ts != null) { + until = ts + } else { + textParts.add(token.raw) + } + } + + "lang" -> { + language = token.value.lowercase() + } + + "domain" -> { + domain = token.value.lowercase() + } + } + } + + is Token.Text -> { + // Check if this is part of an OR chain + if (i + 2 < tokens.size && tokens[i + 1] is Token.Or && tokens[i + 2] is Token.Text) { + // Start of OR chain: collect all terms + orTerms.add(token.value) + i++ // skip to OR + while (i < tokens.size && tokens[i] is Token.Or && i + 1 < tokens.size && tokens[i + 1] is Token.Text) { + i++ // skip OR + orTerms.add((tokens[i] as Token.Text).value) + i++ // skip text + } + continue + } else { + textParts.add(token.value) + } + } + + is Token.Quoted -> { + textParts.add(token.raw) + } + + is Token.Negation -> { + excludeTerms.add(token.term) + } + + is Token.Hashtag -> { + hashtags.add(token.tag) + } + + is Token.Or -> { + // Orphaned OR (no adjacent text terms) → treat as text + textParts.add("OR") + } + } + i++ + } + + // Cap OR terms at 3 + val cappedOrTerms = orTerms.take(3) + + return SearchQuery( + text = textParts.joinToString(" "), + authors = authors.distinct().toImmutableList(), + authorNames = authorNames.distinct().toImmutableList(), + kinds = kinds.distinct().toImmutableList(), + since = since, + until = until, + hashtags = hashtags.distinct().toImmutableList(), + excludeTerms = excludeTerms.distinct().toImmutableList(), + language = language, + domain = domain, + orTerms = cappedOrTerms.toPersistentList(), + pseudoKinds = pseudoKinds.distinct().toImmutableList(), + ) + } + + fun parseDateToTimestamp(dateStr: String): Long? { + // ISO 8601 formats: YYYY, YYYY-MM, YYYY-MM-DD + return try { + val parts = dateStr.split("-") + when (parts.size) { + 1 -> { + val year = parts[0].toIntOrNull() ?: return null + if (year < 1970 || year > 2100) return null + dateToUnix(year, 1, 1) + } + + 2 -> { + val year = parts[0].toIntOrNull() ?: return null + val month = parts[1].toIntOrNull() ?: return null + if (year < 1970 || year > 2100 || month < 1 || month > 12) return null + dateToUnix(year, month, 1) + } + + 3 -> { + val year = parts[0].toIntOrNull() ?: return null + val month = parts[1].toIntOrNull() ?: return null + val day = parts[2].toIntOrNull() ?: return null + if (year < 1970 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null + dateToUnix(year, month, day) + } + + else -> { + null + } + } + } catch (_: Exception) { + null + } + } + + private fun dateToUnix( + year: Int, + month: Int, + day: Int, + ): Long = DateUtils.dateToUnix(year, month, day) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt new file mode 100644 index 0000000000..3cd27a0178 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +object QuerySerializer { + fun serialize(query: SearchQuery): String { + if (query.isEmpty) return "" + + val parts = mutableListOf() + + // Operators first + query.authors.forEach { hex -> + val npub = + try { + NPub.create(hex) + } catch (_: Exception) { + null + } + parts.add("from:${npub ?: hex}") + } + query.authorNames.forEach { name -> + parts.add("from:$name") + } + query.kinds.forEach { kind -> + val name = KindRegistry.nameFor(kind) + parts.add("kind:${name ?: kind}") + } + query.pseudoKinds.forEach { pseudo -> + parts.add("kind:$pseudo") + } + query.since?.let { ts -> + parts.add("since:${timestampToDate(ts)}") + } + query.until?.let { ts -> + parts.add("until:${timestampToDate(ts)}") + } + query.language?.let { lang -> + parts.add("lang:$lang") + } + query.domain?.let { dom -> + parts.add("domain:$dom") + } + + // Hashtags + query.hashtags.forEach { tag -> + parts.add("#$tag") + } + + // Free text + if (query.text.isNotBlank()) { + parts.add(query.text) + } + + // OR terms + if (query.orTerms.isNotEmpty()) { + parts.add(query.orTerms.joinToString(" OR ")) + } + + // Exclusions last + query.excludeTerms.forEach { term -> + parts.add("-$term") + } + + return parts.joinToString(" ") + } + + fun timestampToDate(timestamp: Long): String = DateUtils.timestampToDate(timestamp) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt similarity index 86% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt index fe9785bb29..20302fba73 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt @@ -18,6 +18,11 @@ * AN ACTION OF CONTRACT, TORT 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 +package com.vitorpamplona.amethyst.commons.search -expect fun fastFindURLs(text: String): List +data class SavedSearch( + val id: String, + val label: String, + val query: SearchQuery, + val createdAt: Long, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt new file mode 100644 index 0000000000..fff17c1df5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class SearchQuery( + val text: String = "", + val authors: ImmutableList = persistentListOf(), + val authorNames: ImmutableList = persistentListOf(), + val kinds: ImmutableList = persistentListOf(), + val since: Long? = null, + val until: Long? = null, + val hashtags: ImmutableList = persistentListOf(), + val excludeTerms: ImmutableList = persistentListOf(), + val language: String? = null, + val domain: String? = null, + val orTerms: ImmutableList = persistentListOf(), + val pseudoKinds: ImmutableList = persistentListOf(), +) { + val isEmpty + get() = + text.isBlank() && + authors.isEmpty() && + authorNames.isEmpty() && + kinds.isEmpty() && + since == null && + until == null && + hashtags.isEmpty() && + orTerms.isEmpty() && + excludeTerms.isEmpty() && + pseudoKinds.isEmpty() && + language == null && + domain == null + + companion object { + val EMPTY = SearchQuery() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt index dc77eba392..21cd3a0a24 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.search -import com.vitorpamplona.amethyst.commons.model.User - /** * Represents a parsed search result from Bech32/hex input. * Shared between Android and Desktop for consistent search behavior. @@ -35,13 +33,6 @@ sealed class SearchResult { val displayId: String, ) : SearchResult() - /** - * User from local cache with full metadata. - */ - data class CachedUserResult( - val user: User, - ) : SearchResult() - /** * Note lookup from note1 or nevent. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt new file mode 100644 index 0000000000..6cff2d8cf4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.quartz.nip01Core.core.Event + +object SearchResultFilter { + fun filter( + events: List, + query: SearchQuery, + ): List { + var result = events + + // Dedup by event ID + result = result.distinctBy { it.id } + + // Exclusion terms (client-side) + if (query.excludeTerms.isNotEmpty()) { + result = + result.filter { event -> + query.excludeTerms.none { term -> + event.content.contains(term, ignoreCase = true) + } + } + } + + // Pseudo-kind: reply (kind 1 with e tag) + if ("reply" in query.pseudoKinds) { + result = result.filter { event -> isReply(event) } + } + + // Pseudo-kind: media (kind 1 with imeta tag or image URLs) + if ("media" in query.pseudoKinds) { + result = result.filter { event -> isMedia(event) } + } + + // Sort by createdAt descending + return result.sortedByDescending { it.createdAt } + } + + fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" } + + fun isMedia(event: Event): Boolean { + if (event.kind != 1) return false + // Check for imeta tag + if (event.tags.any { it.size >= 2 && it[0] == "imeta" }) return true + // Check for image/video URLs in content + return IMAGE_URL_PATTERN.containsMatchIn(event.content) + } + + private val IMAGE_URL_PATTERN = + Regex( + """https?://\S+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)""", + RegexOption.IGNORE_CASE, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt new file mode 100644 index 0000000000..bc1ea81770 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.currentTimeSeconds + +object SearchResultSorter { + fun sortEvents( + events: List, + order: SearchSortOrder, + searchText: String, + ): List = + when (order) { + SearchSortOrder.NEWEST -> { + events.sortedByDescending { it.createdAt } + } + + SearchSortOrder.OLDEST -> { + events.sortedBy { it.createdAt } + } + + SearchSortOrder.RELEVANCE -> { + if (searchText.isBlank()) { + events.sortedByDescending { it.createdAt } + } else { + events.sortedByDescending { scoreEvent(it, searchText) } + } + } + + else -> { + events + } + } + + fun sortPeople( + people: List, + order: SearchSortOrder, + ): List = + when (order) { + SearchSortOrder.NAME_AZ -> people.sortedBy { it.toBestDisplayName().lowercase() } + SearchSortOrder.NAME_ZA -> people.sortedByDescending { it.toBestDisplayName().lowercase() } + else -> people + } + + fun scoreEvent( + event: Event, + searchText: String, + ): Double { + val query = searchText.trim().lowercase() + if (query.isEmpty()) return event.createdAt.toDouble() + + var score = 0.0 + val content = event.content.lowercase() + val tokens = query.split("\\s+".toRegex()) + + // Exact phrase match in content + if (content.contains(query)) { + score += 10.0 + } + + // Per-token scoring + for (token in tokens) { + if (token.isEmpty()) continue + val wordBoundary = "\\b${Regex.escape(token)}\\b".toRegex() + if (wordBoundary.containsMatchIn(content)) { + score += 5.0 + } else if (content.contains(token)) { + score += 2.0 + } + } + + // Article title boost + if (event is LongTextNoteEvent) { + val title = event.title()?.lowercase() + if (title != null) { + if (title.contains(query)) { + score += 15.0 + } + for (token in tokens) { + if (token.isEmpty()) continue + if (title.contains(token)) { + score += 3.0 + } + } + } + } + + // Recency tiebreaker (normalized 0..1) + val now = currentTimeSeconds() + val age = (now - event.createdAt).coerceAtLeast(1) + val maxAge = 365L * 24 * 3600 // 1 year + score += (1.0 - (age.toDouble() / maxAge).coerceIn(0.0, 1.0)) + + return score + } +} diff --git a/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 0000000000..1b43507870 --- /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/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index 9b9cc7f066..f9b1086bb5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -28,13 +28,11 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -62,9 +60,6 @@ class ChatNewMessageState( private val _message = MutableStateFlow(TextFieldValue("")) val message: StateFlow = _message.asStateFlow() - private val _nip17 = MutableStateFlow(false) - val nip17: StateFlow = _nip17.asStateFlow() - private val _replyTo = MutableStateFlow(null) val replyTo: StateFlow = _replyTo.asStateFlow() @@ -74,38 +69,37 @@ class ChatNewMessageState( private val _room = MutableStateFlow(null) val room: StateFlow = _room.asStateFlow() - /** Whether NIP-17 is required (group chat with >1 recipient) */ - private val _requiresNip17 = MutableStateFlow(false) - val requiresNip17: StateFlow = _requiresNip17.asStateFlow() + /** Whether any recipients are missing DM relay lists, preventing message delivery */ + private val _recipientsMissingDmRelays = MutableStateFlow(false) + val recipientsMissingDmRelays: StateFlow = _recipientsMissingDmRelays.asStateFlow() - /** Whether a message can be sent (non-blank text + room set) */ + /** Whether a message can be sent (non-blank text + room set + all recipients have DM relays) */ val canSend: Boolean - get() = _message.value.text.isNotBlank() && _room.value != null + get() = _message.value.text.isNotBlank() && _room.value != null && !_recipientsMissingDmRelays.value /** - * Load a chatroom. Sets the room key, formats toUsers display, - * and auto-detects NIP-17 requirement (group chats require NIP-17). + * Load a chatroom. Sets the room key and checks recipient DM relay availability. */ fun load(roomKey: ChatroomKey) { _room.value = roomKey - updateNip17FromRoom() + updateRecipientRelayStatus() } /** - * Auto-detect NIP-17 based on room: - * - Group chats (>1 recipient) always require NIP-17 - * - Single recipient: NIP-17 off by default (can be toggled) + * Check if all recipients have DM relay lists. + * Messages can only be sent via NIP-17, so recipients must have + * either a DM inbox relay list (kind 10050) or NIP-65 inbox relays. */ - fun updateNip17FromRoom() { + fun updateRecipientRelayStatus() { val currentRoom = _room.value if (currentRoom != null) { - _requiresNip17.value = currentRoom.users.size > 1 - if (_requiresNip17.value) { - _nip17.value = true - } + _recipientsMissingDmRelays.value = + currentRoom.users.any { hexKey -> + val user = cache.getOrCreateUser(hexKey) as? User + user?.dmInboxRelays().isNullOrEmpty() + } } else { - _requiresNip17.value = false - _nip17.value = false + _recipientsMissingDmRelays.value = false } } @@ -126,27 +120,7 @@ class ChatNewMessageState( } /** - * Toggle NIP-04/NIP-17 mode. - * If NIP-17 is required (group chat), stays on NIP-17. - */ - fun toggleNip17() { - if (_requiresNip17.value) { - _nip17.value = true - } else { - _nip17.value = !_nip17.value - } - } - - /** - * Enable NIP-17 (e.g., when recipient has DM relay list). - */ - fun enableNip17() { - _nip17.value = true - } - - /** - * Send the current message. Builds the appropriate event template - * (NIP-04 or NIP-17) and delegates to IAccount for signing/broadcasting. + * Send the current message as NIP-17. NIP-04 is deprecated for sending. * * @return true if send was initiated, false if preconditions not met */ @@ -154,12 +128,9 @@ class ChatNewMessageState( val currentRoom = _room.value ?: return false val messageText = _message.value.text if (messageText.isBlank()) return false + if (_recipientsMissingDmRelays.value) return false - if (_nip17.value || currentRoom.users.size > 1 || _replyTo.value?.event is NIP17Group) { - sendNip17(currentRoom, messageText) - } else { - sendNip04(currentRoom, messageText) - } + sendNip17(currentRoom, messageText) return true } @@ -193,23 +164,6 @@ class ChatNewMessageState( account.sendNip17PrivateMessage(template) } - private suspend fun sendNip04( - room: ChatroomKey, - messageText: String, - ) { - val toUser = (cache.getOrCreateUser(room.users.first()) as? User)?.toPTag() ?: return - - val template = - PrivateDmEvent.build( - toUser = toUser, - message = messageText, - replyingTo = _replyTo.value?.toEventHint(), - signer = account.signer, - ) - - account.sendNip04PrivateMessage(template) - } - /** * Clear all composition state after sending or cancelling. */ diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index b9613c5cd7..600c0f27f0 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -281,6 +281,18 @@ class UrlParserTest { Urls(withScheme = emptySet()), ) + /** + * Regression test for PR #1907: parsing a note whose content is only the Japanese phrase + * "今北産業" (a common internet abbreviation) must not throw a StringIndexOutOfBoundsException + * from Url.getPart() and must produce no detected URLs. + */ + @Test + fun testImakitaSangyo() = + test( + "今北産業", + Urls(), + ) + @Test fun testHour() = test( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt new file mode 100644 index 0000000000..5a03ab61c8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KindRegistryTest { + @Test + fun resolveNote() { + assertEquals(listOf(1), KindRegistry.resolve("note")) + } + + @Test + fun resolveArticle() { + assertEquals(listOf(30023), KindRegistry.resolve("article")) + } + + @Test + fun resolveChannel() { + val kinds = KindRegistry.resolve("channel")!! + assertTrue(40 in kinds) + assertTrue(41 in kinds) + } + + @Test + fun resolveCaseInsensitive() { + assertEquals(KindRegistry.resolve("NOTE"), KindRegistry.resolve("note")) + } + + @Test + fun resolveUnknown() { + assertNull(KindRegistry.resolve("unknown")) + } + + @Test + fun isPseudoKindReply() { + assertTrue(KindRegistry.isPseudoKind("reply")) + assertTrue(KindRegistry.isPseudoKind("Reply")) + } + + @Test + fun isPseudoKindMedia() { + assertTrue(KindRegistry.isPseudoKind("media")) + } + + @Test + fun isNotPseudoKind() { + assertFalse(KindRegistry.isPseudoKind("note")) + assertFalse(KindRegistry.isPseudoKind("article")) + } + + @Test + fun nameForKind1() { + assertEquals("note", KindRegistry.nameFor(1)) + } + + @Test + fun nameForKind30023() { + assertEquals("article", KindRegistry.nameFor(30023)) + } + + @Test + fun nameForUnknownKind() { + assertNull(KindRegistry.nameFor(99999)) + } + + @Test + fun allAliasesResolve() { + KindRegistry.aliases.forEach { (alias, kinds) -> + assertEquals(kinds, KindRegistry.resolve(alias)) + } + } + + @Test + fun presetsContainExpectedEntries() { + assertTrue(KindRegistry.presets.containsKey("Notes")) + assertTrue(KindRegistry.presets.containsKey("Articles")) + assertTrue(KindRegistry.presets.containsKey("Media")) + assertTrue(KindRegistry.presets.containsKey("Channels")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt new file mode 100644 index 0000000000..df5c3af77c --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QueryParserTest { + @Test + fun emptyInput() { + val q = QueryParser.parse("") + assertTrue(q.isEmpty) + assertEquals(SearchQuery.EMPTY, q) + } + + @Test + fun whitespaceOnly() { + val q = QueryParser.parse(" ") + assertTrue(q.isEmpty) + } + + @Test + fun plainText() { + val q = QueryParser.parse("bitcoin lightning") + assertEquals("bitcoin lightning", q.text) + assertTrue(q.authors.isEmpty()) + assertTrue(q.kinds.isEmpty()) + } + + @Test + fun kindOperatorAlias() { + val q = QueryParser.parse("kind:note") + assertEquals(listOf(1), q.kinds.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun kindOperatorNumeric() { + val q = QueryParser.parse("kind:30023") + assertEquals(listOf(30023), q.kinds.toList()) + } + + @Test + fun kindOperatorArticle() { + val q = QueryParser.parse("kind:article") + assertEquals(listOf(30023), q.kinds.toList()) + } + + @Test + fun kindOperatorInvalid() { + val q = QueryParser.parse("kind:invalid") + // Unresolvable kind → treated as text + assertEquals("kind:invalid", q.text) + assertTrue(q.kinds.isEmpty()) + } + + @Test + fun pseudoKindReply() { + val q = QueryParser.parse("kind:reply") + assertTrue(q.kinds.isEmpty()) + assertEquals(listOf("reply"), q.pseudoKinds.toList()) + } + + @Test + fun pseudoKindMedia() { + val q = QueryParser.parse("kind:media") + assertTrue(q.kinds.isEmpty()) + assertEquals(listOf("media"), q.pseudoKinds.toList()) + } + + @Test + fun multipleKinds() { + val q = QueryParser.parse("kind:note kind:article") + assertEquals(listOf(1, 30023), q.kinds.toList()) + } + + @Test + fun sinceDate() { + val q = QueryParser.parse("since:2025-01-01") + // 2025-01-01 00:00:00 UTC + assertEquals(1735689600L, q.since) + } + + @Test + fun sinceDateYearOnly() { + val q = QueryParser.parse("since:2025") + // 2025-01-01 00:00:00 UTC + assertEquals(1735689600L, q.since) + } + + @Test + fun sinceDateYearMonth() { + val q = QueryParser.parse("since:2025-06") + // 2025-06-01 00:00:00 UTC + val q2 = QueryParser.parse("since:2025-06-01") + assertEquals(q2.since, q.since) + } + + @Test + fun sinceInvalidDate() { + val q = QueryParser.parse("since:not-a-date") + assertNull(q.since) + assertEquals("since:not-a-date", q.text) + } + + @Test + fun untilDate() { + val q = QueryParser.parse("until:2025-12-31") + assertNull(q.since) + assertTrue(q.until != null && q.until > 0) + } + + @Test + fun hashtag() { + val q = QueryParser.parse("#bitcoin") + assertEquals(listOf("bitcoin"), q.hashtags.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun multipleHashtags() { + val q = QueryParser.parse("#bitcoin #nostr") + assertEquals(listOf("bitcoin", "nostr"), q.hashtags.toList()) + } + + @Test + fun negationTerm() { + val q = QueryParser.parse("-spam") + assertEquals(listOf("spam"), q.excludeTerms.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun multipleNegations() { + val q = QueryParser.parse("-spam -scam") + assertEquals(listOf("spam", "scam"), q.excludeTerms.toList()) + } + + @Test + fun quotedPhrase() { + val q = QueryParser.parse("\"exact phrase\"") + assertEquals("\"exact phrase\"", q.text) + } + + @Test + fun orTerms() { + val q = QueryParser.parse("bitcoin OR lightning") + assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList()) + assertTrue(q.text.isBlank()) + } + + @Test + fun orTermsWithOperators() { + val q = QueryParser.parse("from:vitor bitcoin OR lightning kind:note") + assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList()) + assertEquals(listOf(1), q.kinds.toList()) + // from:vitor → authorNames since it's not a valid npub + assertEquals(listOf("vitor"), q.authorNames.toList()) + } + + @Test + fun orTermsCappedAtThree() { + val q = QueryParser.parse("a OR b OR c OR d OR e") + assertEquals(3, q.orTerms.size) + assertEquals(listOf("a", "b", "c"), q.orTerms.toList()) + } + + @Test + fun orphanedOr() { + val q = QueryParser.parse("OR") + assertEquals("OR", q.text) + assertTrue(q.orTerms.isEmpty()) + } + + @Test + fun languageOperator() { + val q = QueryParser.parse("lang:en bitcoin") + assertEquals("en", q.language) + assertEquals("bitcoin", q.text) + } + + @Test + fun domainOperator() { + val q = QueryParser.parse("domain:nostr.com bitcoin") + assertEquals("nostr.com", q.domain) + assertEquals("bitcoin", q.text) + } + + @Test + fun combinedQuery() { + val q = QueryParser.parse("kind:note since:2025-01-01 #bitcoin -spam lightning") + assertEquals(listOf(1), q.kinds.toList()) + assertEquals(1735689600L, q.since) + assertEquals(listOf("bitcoin"), q.hashtags.toList()) + assertEquals(listOf("spam"), q.excludeTerms.toList()) + assertEquals("lightning", q.text) + } + + @Test + fun caseInsensitiveOperators() { + val q = QueryParser.parse("FROM:vitor KIND:Note") + assertEquals(listOf("vitor"), q.authorNames.toList()) + assertEquals(listOf(1), q.kinds.toList()) + } + + @Test + fun operatorWithNoValue() { + val q = QueryParser.parse("from:") + // Malformed → treated as text + assertEquals("from:", q.text) + assertTrue(q.authors.isEmpty()) + } + + @Test + fun fromWithAuthorName() { + val q = QueryParser.parse("from:vitor") + assertEquals(listOf("vitor"), q.authorNames.toList()) + assertTrue(q.authors.isEmpty()) + } + + @Test + fun multipleFromAuthors() { + val q = QueryParser.parse("from:alice from:bob") + assertEquals(listOf("alice", "bob"), q.authorNames.toList()) + } + + @Test + fun duplicateAuthorsDeduped() { + val q = QueryParser.parse("from:vitor from:vitor") + assertEquals(1, q.authorNames.size) + } + + @Test + fun parseDateToTimestamp_validDates() { + // 1970-01-01 = 0 + assertEquals(0L, QueryParser.parseDateToTimestamp("1970-01-01")) + // 2000-01-01 + assertEquals(946684800L, QueryParser.parseDateToTimestamp("2000-01-01")) + } + + @Test + fun parseDateToTimestamp_invalidDates() { + assertNull(QueryParser.parseDateToTimestamp("not-a-date")) + assertNull(QueryParser.parseDateToTimestamp("1800-01-01")) + assertNull(QueryParser.parseDateToTimestamp("2025-13-01")) + assertNull(QueryParser.parseDateToTimestamp("2025-01-32")) + } + + @Test + fun unicodeInFreeText() { + val q = QueryParser.parse("bitcoin 日本語 🚀") + assertFalse(q.isEmpty) + assertTrue(q.text.contains("日本語")) + assertTrue(q.text.contains("🚀")) + } + + @Test + fun veryLongQuery() { + val longText = "word ".repeat(100).trim() + val q = QueryParser.parse(longText) + assertFalse(q.isEmpty) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt new file mode 100644 index 0000000000..0534e50138 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import kotlinx.collections.immutable.persistentListOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QuerySerializerTest { + @Test + fun emptyQuery() { + assertEquals("", QuerySerializer.serialize(SearchQuery.EMPTY)) + } + + @Test + fun textOnly() { + val q = SearchQuery(text = "bitcoin") + assertEquals("bitcoin", QuerySerializer.serialize(q)) + } + + @Test + fun kindOnly() { + val q = SearchQuery(kinds = persistentListOf(1)) + assertEquals("kind:note", QuerySerializer.serialize(q)) + } + + @Test + fun kindUnknown() { + val q = SearchQuery(kinds = persistentListOf(99999)) + assertEquals("kind:99999", QuerySerializer.serialize(q)) + } + + @Test + fun multipleKinds() { + val q = SearchQuery(kinds = persistentListOf(1, 30023)) + assertEquals("kind:note kind:article", QuerySerializer.serialize(q)) + } + + @Test + fun pseudoKinds() { + val q = SearchQuery(pseudoKinds = persistentListOf("reply", "media")) + assertEquals("kind:reply kind:media", QuerySerializer.serialize(q)) + } + + @Test + fun sinceDate() { + val q = SearchQuery(since = 1735689600L) // 2025-01-01 + assertEquals("since:2025-01-01", QuerySerializer.serialize(q)) + } + + @Test + fun untilDate() { + val q = SearchQuery(until = 1735689600L) + assertEquals("until:2025-01-01", QuerySerializer.serialize(q)) + } + + @Test + fun hashtags() { + val q = SearchQuery(hashtags = persistentListOf("bitcoin", "nostr")) + assertEquals("#bitcoin #nostr", QuerySerializer.serialize(q)) + } + + @Test + fun excludeTerms() { + val q = SearchQuery(excludeTerms = persistentListOf("spam", "scam")) + assertEquals("-spam -scam", QuerySerializer.serialize(q)) + } + + @Test + fun orTerms() { + val q = SearchQuery(orTerms = persistentListOf("bitcoin", "lightning")) + assertEquals("bitcoin OR lightning", QuerySerializer.serialize(q)) + } + + @Test + fun language() { + val q = SearchQuery(language = "en", text = "bitcoin") + assertEquals("lang:en bitcoin", QuerySerializer.serialize(q)) + } + + @Test + fun domain() { + val q = SearchQuery(domain = "nostr.com", text = "hello") + assertEquals("domain:nostr.com hello", QuerySerializer.serialize(q)) + } + + @Test + fun authorNames() { + val q = SearchQuery(authorNames = persistentListOf("vitor")) + assertEquals("from:vitor", QuerySerializer.serialize(q)) + } + + @Test + fun combinedQuery() { + val q = + SearchQuery( + authorNames = persistentListOf("vitor"), + kinds = persistentListOf(1), + since = 1735689600L, + hashtags = persistentListOf("bitcoin"), + text = "lightning", + excludeTerms = persistentListOf("spam"), + ) + val result = QuerySerializer.serialize(q) + assertTrue(result.contains("from:vitor")) + assertTrue(result.contains("kind:note")) + assertTrue(result.contains("since:2025-01-01")) + assertTrue(result.contains("#bitcoin")) + assertTrue(result.contains("lightning")) + assertTrue(result.contains("-spam")) + } + + @Test + fun orderingOperatorsFirst() { + val q = + SearchQuery( + authorNames = persistentListOf("alice"), + kinds = persistentListOf(1), + hashtags = persistentListOf("nostr"), + text = "hello", + excludeTerms = persistentListOf("bad"), + ) + val result = QuerySerializer.serialize(q) + val fromIdx = result.indexOf("from:") + val kindIdx = result.indexOf("kind:") + val hashIdx = result.indexOf("#nostr") + val textIdx = result.indexOf("hello") + val excludeIdx = result.indexOf("-bad") + assertTrue(fromIdx < kindIdx) + assertTrue(kindIdx < hashIdx) + assertTrue(hashIdx < textIdx) + assertTrue(textIdx < excludeIdx) + } + + @Test + fun timestampToDateEpoch() { + assertEquals("1970-01-01", QuerySerializer.timestampToDate(0L)) + } + + @Test + fun timestampToDate2025() { + assertEquals("2025-01-01", QuerySerializer.timestampToDate(1735689600L)) + } + + @Test + fun roundtrip() { + // Parse a complex query, serialize, parse again — should produce equivalent SearchQuery + val input = "kind:note since:2025-01-01 #bitcoin lightning -spam" + val q1 = QueryParser.parse(input) + val serialized = QuerySerializer.serialize(q1) + val q2 = QueryParser.parse(serialized) + assertEquals(q1.kinds, q2.kinds) + assertEquals(q1.since, q2.since) + assertEquals(q1.hashtags, q2.hashtags) + assertEquals(q1.excludeTerms, q2.excludeTerms) + assertEquals(q1.text, q2.text) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt new file mode 100644 index 0000000000..bdf3b3cf6e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SearchResultSorterTest { + private fun event( + id: String, + createdAt: Long, + content: String = "", + kind: Int = 1, + ) = Event( + id = id, + pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", + createdAt = createdAt, + kind = kind, + tags = emptyArray(), + content = content, + sig = "sig", + ) + + private fun article( + id: String, + createdAt: Long, + content: String = "", + title: String? = null, + ): LongTextNoteEvent { + val tags = + if (title != null) { + arrayOf(arrayOf("title", title)) + } else { + emptyArray() + } + return LongTextNoteEvent( + id = id, + pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", + createdAt = createdAt, + tags = tags, + content = content, + sig = "sig", + ) + } + + private fun user( + hex: String, + displayName: String, + ): User { + val u = User(hex, Note("r1-$hex"), Note("r2-$hex")) + val meta = UserMetadata().apply { this.displayName = displayName } + val metaEvent = + MetadataEvent( + id = "meta-$hex", + pubKey = hex, + createdAt = 0L, + tags = emptyArray(), + content = "{}", + sig = "sig", + ) + u.updateUserInfo(meta, metaEvent) + return u + } + + // --- Event sorting --- + + @Test + fun newestSortsDescending() { + val events = listOf(event("a", 100), event("b", 300), event("c", 200)) + val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.NEWEST, "") + assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) + } + + @Test + fun oldestSortsAscending() { + val events = listOf(event("a", 300), event("b", 100), event("c", 200)) + val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.OLDEST, "") + assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) + } + + @Test + fun relevanceEmptyQueryFallsBackToRecency() { + val events = listOf(event("a", 100), event("b", 300), event("c", 200)) + val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.RELEVANCE, "") + assertEquals(listOf("b", "c", "a"), sorted.map { it.id }) + } + + @Test + fun relevanceExactMatchBeatsPartial() { + val exact = event("exact", 100, content = "bitcoin is great") + val partial = event("partial", 100, content = "bit of something") + val sorted = SearchResultSorter.sortEvents(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin") + assertEquals("exact", sorted.first().id) + } + + @Test + fun relevanceWordBoundaryBeatsSubstring() { + val boundary = event("boundary", 100, content = "I love bitcoin and lightning") + val substring = event("substr", 100, content = "bitcoinery is not a word") + val sorted = SearchResultSorter.sortEvents(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin") + assertEquals("boundary", sorted.first().id) + } + + @Test + fun relevanceArticleTitleBoost() { + val withTitle = article("titled", 100, content = "some content", title = "Bitcoin Guide") + val withoutTitle = event("notitle", 100, content = "bitcoin bitcoin bitcoin") + val sorted = SearchResultSorter.sortEvents(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin") + assertEquals("titled", sorted.first().id) + } + + @Test + fun relevanceMultipleTokensAddUp() { + val multi = event("multi", 100, content = "bitcoin and lightning network") + val single = event("single", 100, content = "bitcoin only here") + val sorted = + SearchResultSorter.sortEvents( + listOf(single, multi), + SearchSortOrder.RELEVANCE, + "bitcoin lightning", + ) + assertEquals("multi", sorted.first().id) + } + + // --- People sorting --- + + @Test + fun nameAzSortsAlphabetically() { + val people = + listOf( + user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"), + user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"), + user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"), + ) + val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ) + assertEquals(listOf("Alice", "Bob", "Charlie"), sorted.map { it.toBestDisplayName() }) + } + + @Test + fun nameZaSortsReverseAlphabetically() { + val people = + listOf( + user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"), + user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"), + user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"), + ) + val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_ZA) + assertEquals(listOf("Charlie", "Bob", "Alice"), sorted.map { it.toBestDisplayName() }) + } + + @Test + fun nameSortIsCaseInsensitive() { + val people = + listOf( + user("aa00000000000000000000000000000000000000000000000000000000000000", "alice"), + user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"), + ) + val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ) + assertEquals("alice", sorted.first().toBestDisplayName()) + } + + // --- Score function --- + + @Test + fun scoreExactPhraseHigherThanTokens() { + val exactEvent = event("e", 100, content = "bitcoin lightning network") + val tokenEvent = event("t", 100, content = "lightning and bitcoin elsewhere network") + val exactScore = SearchResultSorter.scoreEvent(exactEvent, "bitcoin lightning") + val tokenScore = SearchResultSorter.scoreEvent(tokenEvent, "bitcoin lightning") + assertTrue(exactScore > tokenScore) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt index 96233860b6..a133912b3a 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.services.nwc import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import java.util.concurrent.ConcurrentHashMap /** diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 1a728e1716..eab43327e5 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) + id("ir.mahozad.vlc-setup") version "0.1.0" } sourceSets { @@ -46,11 +47,23 @@ dependencies { // JSON implementation(libs.jackson.module.kotlin) + // Image loading (Coil3 — explicit because commons uses implementation, not api) + implementation(libs.coil.compose) + implementation(libs.coil.okhttp) + implementation(libs.coil.svg) + + // Video playback + implementation(libs.vlcj) + + // EXIF stripping (lossless) + implementation(libs.commons.imaging) + // Collections implementation(libs.kotlinx.collections.immutable) + implementation(libs.androidx.collection) // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps - implementation("org.slf4j:slf4j-nop:2.0.16") + implementation(libs.slf4j.nop) // QR code generation (ZXing core) implementation(libs.zxing) @@ -65,8 +78,10 @@ dependencies { compose.desktop { application { mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED" nativeDistributions { + appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Amethyst" @@ -91,3 +106,16 @@ compose.desktop { } } } + +vlcSetup { + vlcVersion.set("3.0.21") + shouldCompressVlcFiles.set(true) + shouldIncludeAllVlcFiles.set(true) + pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc")) + pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc")) + pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc")) +} + +tasks.named("spotlessKotlin") { + inputs.files(tasks.named("vlcSetup")) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 86b7e9d134..f06acf2e71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -68,4 +68,17 @@ object DesktopPreferences { set(value) { prefs.put(KEY_LAYOUT_MODE, value) } + + private const val KEY_BLOSSOM_SERVERS = "blossom_servers" + private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" + + var blossomServers: List + get() { + val raw = prefs.get(KEY_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVER) + return if (raw.isBlank()) emptyList() else raw.split(",") + } + set(value) = prefs.put(KEY_BLOSSOM_SERVERS, value.joinToString(",")) + + val preferredBlossomServer: String + get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 30470359f5..3d106a9e4e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -48,6 +50,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -74,6 +77,8 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup +import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen @@ -87,8 +92,12 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout +import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow +import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen +import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard +import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope @@ -137,7 +146,17 @@ sealed class DesktopScreen { data object Settings : DesktopScreen() } -fun main() = +fun main() { + DesktopImageLoaderSetup.setup() + Runtime.getRuntime().addShutdownHook( + Thread { + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .shutdown() + VlcjPlayerPool.shutdown() + }, + ) + // Pre-init VLC on background thread so first play is fast + Thread { VlcjPlayerPool.init() }.start() application { val windowState = rememberWindowState( @@ -363,27 +382,35 @@ fun main() = } } - App( - layoutMode = layoutMode, - deckState = deckState, - accountManager = accountManager, - showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, - onShowComposeDialog = { showComposeDialog = true }, - onShowReplyDialog = { event -> - replyToNote = event - showComposeDialog = true - }, - onDismissComposeDialog = { - showComposeDialog = false - replyToNote = null - }, - onDismissAddColumnDialog = { showAddColumnDialog = false }, - onShowAddColumnDialog = { showAddColumnDialog = true }, - replyToNote = replyToNote, - ) + val immersiveFullscreenState = remember { mutableStateOf(false) } + CompositionLocalProvider( + LocalWindowState provides windowState, + LocalAwtWindow provides window, + LocalIsImmersiveFullscreen provides immersiveFullscreenState, + ) { + App( + layoutMode = layoutMode, + deckState = deckState, + accountManager = accountManager, + showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, + onShowComposeDialog = { showComposeDialog = true }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + onDismissComposeDialog = { + showComposeDialog = false + replyToNote = null + }, + onDismissAddColumnDialog = { showAddColumnDialog = false }, + onShowAddColumnDialog = { showAddColumnDialog = true }, + replyToNote = replyToNote, + ) + } } } +} @Composable fun App( @@ -622,22 +649,31 @@ fun MainContent( ) } + is com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -> { + val innerNote = localCache.getOrCreateNote(innerEvent.id) + val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (innerNote.event == null) { + innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + } + iAccount.chatroomList.addMessage( + innerEvent.chatroomKey(iAccount.pubKey), + innerNote, + ) + } + is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> { val reactionNote = localCache.getOrCreateNote(innerEvent.id) val reactionAuthor = localCache.getOrCreateUser(innerEvent.pubKey) if (reactionNote.event == null) { reactionNote.loadEvent(innerEvent, reactionAuthor, emptyList()) } - // Attach reaction to the target message note innerEvent.originalPost().forEach { targetId -> val targetNote = localCache.getNoteIfExists(targetId) targetNote?.addReaction(reactionNote) } } - else -> { - println("Unhandled NIP-17 inner event: ${innerEvent.kind}") - } + else -> {} } } } @@ -665,60 +701,76 @@ fun MainContent( } } + val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current + Box(Modifier.fillMaxSize()) { - Row(Modifier.fillMaxSize()) { - when (layoutMode) { - LayoutMode.SINGLE_PANE -> { - SinglePaneLayout( - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - modifier = Modifier.weight(1f), - ) + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxSize().weight(1f)) { + when (layoutMode) { + LayoutMode.SINGLE_PANE -> { + SinglePaneLayout( + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.weight(1f), + ) + } + + LayoutMode.DECK -> { + if (!isImmersive) { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + ) + + VerticalDivider() + } + + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } } + } // end Row - LayoutMode.DECK -> { - DeckSidebar( - onAddColumn = onShowAddColumnDialog, - onOpenSettings = { - if (deckState.hasColumnOfType(DeckColumnType.Settings)) { - deckState.focusExistingColumn(DeckColumnType.Settings) - } else { - deckState.addColumn(DeckColumnType.Settings) - } - }, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - ) + // Persistent media control bar + com.vitorpamplona.amethyst.desktop.ui.media + .NowPlayingBar() + } // end Column - VerticalDivider() - - DeckLayout( - deckState = deckState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - modifier = Modifier.weight(1f), - ) - } - } - } + // Global fullscreen video overlay + com.vitorpamplona.amethyst.desktop.ui.media + .GlobalFullscreenOverlay() // Snackbar for zap feedback SnackbarHost( @@ -781,7 +833,9 @@ fun RelaySettingsScreen( accountManager.loadNwcConnection() } - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { Text( "Settings", style = MaterialTheme.typography.headlineMedium, @@ -872,6 +926,15 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) + // Media Server Settings + MediaServerSettings( + initialServers = DesktopPreferences.blossomServers, + onServersChanged = { DesktopPreferences.blossomServers = it }, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + // Developer Settings Section (only in debug mode) if (DebugConfig.isDebugMode) { com.vitorpamplona.amethyst.desktop.ui diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt new file mode 100644 index 0000000000..9c3e3b01ad --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +import com.vitorpamplona.amethyst.commons.search.QueryParser +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.commons.search.SavedSearch +import com.vitorpamplona.amethyst.commons.search.SearchQuery +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +object SearchHistoryStore { + private val prefs: Preferences = Preferences.userNodeForPackage(SearchHistoryStore::class.java) + + private const val KEY_HISTORY = "search_history" + private const val KEY_SAVED = "saved_searches" + private const val SEPARATOR = "\n" + private const val SAVED_SEPARATOR = "\t" + private const val MAX_HISTORY = 20 + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + + private val _savedSearches = MutableStateFlow>(emptyList()) + val savedSearches: StateFlow> = _savedSearches.asStateFlow() + + init { + _history.value = loadHistory() + _savedSearches.value = loadSaved() + } + + fun addToHistory(query: SearchQuery) { + if (query.isEmpty) return + val serialized = QuerySerializer.serialize(query) + val current = _history.value.toMutableList() + current.removeAll { QuerySerializer.serialize(it) == serialized } + current.add(0, query) + if (current.size > MAX_HISTORY) { + current.subList(MAX_HISTORY, current.size).clear() + } + _history.value = current.toList() + persistHistory(current) + } + + fun clearHistory() { + _history.value = emptyList() + prefs.remove(KEY_HISTORY) + } + + fun saveSearch( + query: SearchQuery, + label: String, + ) { + if (query.isEmpty) return + val saved = + SavedSearch( + id = System.currentTimeMillis().toString(), + label = label, + query = query, + createdAt = System.currentTimeMillis() / 1000, + ) + val current = _savedSearches.value + saved + _savedSearches.value = current + persistSaved(current) + } + + fun deleteSavedSearch(id: String) { + val current = _savedSearches.value.filter { it.id != id } + _savedSearches.value = current + persistSaved(current) + } + + private fun loadHistory(): List { + val raw = prefs.get(KEY_HISTORY, "") + if (raw.isBlank()) return emptyList() + return raw + .split(SEPARATOR) + .filter { it.isNotBlank() } + .mapNotNull { line -> + val parsed = QueryParser.parse(line) + if (parsed.isEmpty) null else parsed + } + } + + private fun persistHistory(queries: List) { + val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) } + prefs.put(KEY_HISTORY, raw) + } + + private fun loadSaved(): List { + val raw = prefs.get(KEY_SAVED, "") + if (raw.isBlank()) return emptyList() + return raw + .split(SEPARATOR) + .filter { it.isNotBlank() } + .mapNotNull { line -> + val parts = line.split(SAVED_SEPARATOR) + if (parts.size < 4) return@mapNotNull null + val id = parts[0] + val label = parts[1] + val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null + val queryText = parts[3] + val query = QueryParser.parse(queryText) + if (query.isEmpty) return@mapNotNull null + SavedSearch(id = id, label = label, query = query, createdAt = createdAt) + } + } + + private fun persistSaved(searches: List) { + val raw = + searches.joinToString(SEPARATOR) { s -> + listOf(s.id, s.label, s.createdAt.toString(), QuerySerializer.serialize(s.query)) + .joinToString(SAVED_SEPARATOR) + } + prefs.put(KEY_SAVED, raw) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index c51526c62b..b41bda8de6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -33,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index daf10b553a..2668756b86 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add @@ -183,7 +184,7 @@ fun ChessScreen( Row(verticalAlignment = Alignment.CenterVertically) { if (selectedGameId != null) { IconButton(onClick = { viewModel.selectGame(null) }) { - Icon(Icons.Default.ArrowBack, "Back to list") + Icon(Icons.AutoMirrored.Default.ArrowBack, "Back to list") } Spacer(Modifier.width(8.dp)) } @@ -344,8 +345,11 @@ private fun ChessLobby( listState: LazyListState = rememberLazyListState(), ) { val hasContent = - activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || - publicGames.isNotEmpty() || challenges.isNotEmpty() || completedGames.isNotEmpty() + activeGames.isNotEmpty() || + spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || + challenges.isNotEmpty() || + completedGames.isNotEmpty() if (!hasContent) { // Empty state diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e0f5086d1b..5e3108b2ef 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -33,11 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -86,9 +87,9 @@ class DesktopIAccount( override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = object : IPrivateZapsDecryptionCache { - override fun cachedPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + override fun cachedPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null - override suspend fun decryptPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + override suspend fun decryptPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null } override fun userProfile(): User = localCache.getOrCreateUser(pubKey) @@ -108,7 +109,7 @@ class DesktopIAccount( override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { if (!isWriteable()) return - val signedEvent = signer.sign(eventTemplate) + val signedEvent = signer.sign(eventTemplate) val recipient = signedEvent.verifiedRecipientPubKey() // Optimistic local add so the message appears immediately @@ -156,6 +157,37 @@ class DesktopIAccount( scope.launch { dmSendTracker.sendBatch(batch) } } + override suspend fun sendNip17EncryptedFile(template: EventTemplate) { + if (!isWriteable()) return + + val result = NIP17Factory().createEncryptedFileNIP17(template, signer) + + // Optimistic local add + val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent + addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey)) + + // Collect wraps with target relays and send + val batch = + result.wraps.map { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = + if (recipientKey != null) { + val dmRelays = + localCache + .getOrCreateUser(recipientKey) + .dmInboxRelays() + ?.toSet() + dmRelays?.ifEmpty { null } + ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + wrap to targetRelays + } + + scope.launch { dmSendTracker.sendBatch(batch) } + } + override suspend fun sendGiftWraps(wraps: List) { val batch = wraps.map { wrap -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt index 5266f9621a..e31000e7e4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt @@ -45,6 +45,7 @@ object DefaultRelays { "wss://nos.lol", "wss://relay.snort.social", "wss://nostr.wine", + "wss://relay.noswhere.com", "wss://relay.primal.net", ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt index fc9119db86..d535cd7ae2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt @@ -27,12 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt new file mode 100644 index 0000000000..6e534d0683 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.Uri +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage +import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage +import com.vitorpamplona.amethyst.commons.richtext.Base64Image +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import java.awt.image.BufferedImage + +@Stable +class DesktopBase64Fetcher( + private val data: Uri, +) : Fetcher { + override suspend fun fetch(): FetchResult? = + runCatching { + val platformImage = Base64Image.toPlatformImage(data.toString()) + val bufferedImage = platformImage.toBufferedImage() + val bitmap = bufferedImageToSkiaBitmap(bufferedImage) + ImageFetchResult( + image = bitmap.asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + }.getOrNull() + + object Factory : Fetcher.Factory { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher? = + if (data.scheme == "data") { + DesktopBase64Fetcher(data) + } else { + null + } + } + + object BKeyer : Keyer { + override fun key( + data: Uri, + options: Options, + ): String? = + if (data.scheme == "data") { + sha256(data.toString().toByteArray()).toHexKey() + } else { + null + } + } +} + +internal fun bufferedImageToSkiaBitmap(bi: BufferedImage): Bitmap { + val w = bi.width + val h = bi.height + val pixels = IntArray(w * h) + bi.getRGB(0, 0, w, h, pixels, 0, w) + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL)) + bitmap.installPixels(convertArgbToBgra(pixels)) + bitmap.setImmutable() + return bitmap +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt new file mode 100644 index 0000000000..1e47288996 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder +import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage + +data class BlurhashWrapper( + val blurhash: String, +) + +@Stable +class DesktopBlurHashFetcher( + private val data: BlurhashWrapper, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val hash = data.blurhash + val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null + val bufferedImage = platformImage.toBufferedImage() + val bitmap = bufferedImageToSkiaBitmap(bufferedImage) + + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: BlurhashWrapper, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = DesktopBlurHashFetcher(data) + } + + object BKeyer : Keyer { + override fun key( + data: BlurhashWrapper, + options: Options, + ): String = data.blurhash + } +} + +internal fun convertArgbToBgra(pixels: IntArray): ByteArray { + val bytes = ByteArray(pixels.size * 4) + for (i in pixels.indices) { + val argb = pixels[i] + val a = (argb shr 24) and 0xFF + val r = (argb shr 16) and 0xFF + val g = (argb shr 8) and 0xFF + val b = argb and 0xFF + val offset = i * 4 + bytes[offset] = b.toByte() + bytes[offset + 1] = g.toByte() + bytes[offset + 2] = r.toByte() + bytes[offset + 3] = a.toByte() + } + return bytes +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt new file mode 100644 index 0000000000..ebaad3df6e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.size.Precision +import coil3.svg.SvgDecoder +import okio.Path.Companion.toOkioPath +import java.io.File + +object DesktopImageLoaderSetup { + @OptIn(DelicateCoilApi::class) + fun setup() { + SingletonImageLoader.setUnsafe(createImageLoader()) + } + + fun createImageLoader(): ImageLoader = + ImageLoader + .Builder(PlatformContext.INSTANCE) + .memoryCache { newMemoryCache() } + .diskCache { newDiskCache() } + .precision(Precision.INEXACT) + .components { + add(SvgDecoder.Factory()) + add(SkiaGifDecoder.Factory()) + add(DesktopBase64Fetcher.Factory) + add(DesktopBlurHashFetcher.Factory) + add(DesktopBase64Fetcher.BKeyer) + add(DesktopBlurHashFetcher.BKeyer) + }.build() + + private fun newMemoryCache(): MemoryCache { + val maxMemory = Runtime.getRuntime().maxMemory() + val cacheSize = (maxMemory * 0.15).toLong().coerceAtMost(256L * 1024 * 1024) + return MemoryCache + .Builder() + .maxSizeBytes(cacheSize) + .strongReferencesEnabled(false) + .build() + } + + private fun newDiskCache(): DiskCache = + DiskCache + .Builder() + .directory(cacheDir().resolve("AmethystDesktop/image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) + .build() + + private fun cacheDir(): File { + val os = System.getProperty("os.name").lowercase() + return when { + "mac" in os -> { + File(System.getProperty("user.home"), "Library/Caches") + } + + "win" in os -> { + File( + System.getenv("LOCALAPPDATA") + ?: System.getProperty("user.home"), + ) + } + + else -> { + File( + System.getenv("XDG_CACHE_HOME") + ?: "${System.getProperty("user.home")}/.cache", + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt new file mode 100644 index 0000000000..185b0731a4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.images + +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DecodeResult +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Codec +import org.jetbrains.skia.Data + +class SkiaGifDecoder( + private val source: ImageSource, +) : Decoder { + override suspend fun decode(): DecodeResult { + val bytes = source.source().use { it.readByteArray() } + val data = Data.makeFromBytes(bytes) + val codec = Codec.makeFromData(data) + val bitmap = Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, 0) + bitmap.setImmutable() + return DecodeResult( + image = bitmap.asImage(), + isSampled = false, + ) + } + + class Factory : Decoder.Factory { + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader, + ): Decoder? { + val mimeType = result.mimeType ?: return null + if (mimeType != "image/gif") return null + return SkiaGifDecoder(result.source) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt new file mode 100644 index 0000000000..63765f0dd4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy +import java.io.File + +/** + * Discovers bundled VLC libraries on Windows and Linux. + * Reads the Compose application resources directory and looks for a vlc/ subdirectory. + */ +class BundledVlcDiscoverer : NativeDiscoveryStrategy { + override fun supported(): Boolean { + val os = System.getProperty("os.name").lowercase() + return "mac" !in os + } + + override fun discover(): String { + val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return "" + val vlcDir = File(resourcesDir, "vlc") + return if (vlcDir.isDirectory) vlcDir.absolutePath else "" + } + + override fun onFound(path: String): Boolean = true + + override fun onSetPluginPath(path: String): Boolean = true +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt new file mode 100644 index 0000000000..4365bd881d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles decryption of media files for NIP-17 DMs. + * Uses AESGCM cipher from quartz (commonMain). + * Caches decrypted bytes in memory to avoid re-downloading on recomposition. + */ +object EncryptedMediaService { + private val httpClient = OkHttpClient() + private val cache = ConcurrentHashMap() + private const val MAX_CACHE_ENTRIES = 20 + + /** + * Download and decrypt an encrypted file from a URL. + * Results are cached by URL to avoid re-downloading on scroll/recomposition. + * Returns the decrypted bytes. + */ + suspend fun downloadAndDecrypt( + url: String, + keyBytes: ByteArray, + nonce: ByteArray, + ): ByteArray { + cache[url]?.let { return it } + + return withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + val encryptedBytes = + response.use { + if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}") + it.body.bytes() + } + + val cipher = AESGCM(keyBytes, nonce) + val decrypted = cipher.decrypt(encryptedBytes) + + // Evict oldest entries if cache is full + if (cache.size >= MAX_CACHE_ENTRIES) { + cache.keys.firstOrNull()?.let { cache.remove(it) } + } + cache[url] = decrypted + + decrypted + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt new file mode 100644 index 0000000000..dc59b159cd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -0,0 +1,497 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import com.vitorpamplona.amethyst.desktop.ui.media.MediaType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import org.jetbrains.skia.Image as SkiaImage + +data class MediaPlaybackState( + val url: String? = null, + val type: MediaType = MediaType.VIDEO, + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val position: Float = 0f, + val duration: Long = 0L, + val currentTime: Long = 0L, + val aspectRatio: Float = 16f / 9f, + val volume: Int = 100, + val isMuted: Boolean = false, +) + +object GlobalMediaPlayer { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Video state + private val _videoFrame = MutableStateFlow(null) + val videoFrame: StateFlow = _videoFrame.asStateFlow() + + private val _videoState = MutableStateFlow(MediaPlaybackState()) + val videoState: StateFlow = _videoState.asStateFlow() + + // Audio state + private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO)) + val audioState: StateFlow = _audioState.asStateFlow() + + // Fullscreen + private val _isFullscreen = MutableStateFlow(false) + val isFullscreen: StateFlow = _isFullscreen.asStateFlow() + + // VLC players — kept alive between plays + private var videoPlayer: EmbeddedMediaPlayer? = null + private var audioPlayer: MediaPlayer? = null + + // Skia bitmap for video rendering + private var skBitmap: Bitmap? = null + private var pixelBytes: ByteArray? = null + + // Position polling job + private var videoPollingJob: Job? = null + private var audioPollingJob: Job? = null + + fun playVideo( + url: String, + seekPosition: Float = 0f, + ) { + // If already playing this URL, just seek + val current = _videoState.value + if (current.url == url && videoPlayer != null) { + if (seekPosition > 0f) { + videoPlayer?.controls()?.setPosition(seekPosition) + } + if (!current.isPlaying) { + videoPlayer?.controls()?.play() + } + return + } + + // Stop current video if different URL + if (current.url != null && current.url != url) { + videoPlayer?.controls()?.stop() + } + + _videoState.value = + MediaPlaybackState( + url = url, + type = MediaType.VIDEO, + isBuffering = true, + ) + + scope.launch(Dispatchers.IO) { + if (!VlcjPlayerPool.init()) { + _videoState.value = _videoState.value.copy(isBuffering = false) + return@launch + } + + val player = + videoPlayer ?: VlcjPlayerPool.acquire() ?: run { + _videoState.value = _videoState.value.copy(isBuffering = false) + return@launch + } + + // Only set up surface on first acquisition + if (videoPlayer == null) { + setupVideoSurface(player) + setupVideoEventListener(player) + videoPlayer = player + } + + var didSeek = seekPosition <= 0f + + // Temporary listener for initial seek + if (!didSeek) { + val seekListener = + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + if (!didSeek) { + didSeek = true + mediaPlayer.controls().setPosition(seekPosition) + mediaPlayer.events().removeMediaPlayerEventListener(this) + } + } + } + player.events().addMediaPlayerEventListener(seekListener) + } + + val vol = _videoState.value.volume + player.media().play(url, ":start-volume=$vol") + startVideoPolling() + } + } + + fun playAudio(url: String) { + val current = _audioState.value + if (current.url == url && audioPlayer != null) { + if (!current.isPlaying) { + audioPlayer?.controls()?.play() + } + return + } + + if (current.url != null && current.url != url) { + audioPlayer?.controls()?.stop() + } + + _audioState.value = + MediaPlaybackState( + url = url, + type = MediaType.AUDIO, + isBuffering = true, + ) + + scope.launch(Dispatchers.IO) { + val player = + audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run { + _audioState.value = _audioState.value.copy(isBuffering = false) + return@launch + } + + if (audioPlayer == null) { + setupAudioEventListener(player) + audioPlayer = player + } + + val vol = _audioState.value.volume + player.media().play(url, ":start-volume=$vol") + startAudioPolling() + } + } + + fun toggleVideoPlayPause() { + val player = videoPlayer ?: return + val state = _videoState.value + if (state.url == null) return + + if (state.isPlaying) { + player.controls().pause() + } else { + if (state.position <= 0f && !player.status().isPlaying) { + state.url.let { player.media().play(it) } + } else { + player.controls().play() + } + } + } + + fun toggleAudioPlayPause() { + val player = audioPlayer ?: return + val state = _audioState.value + if (state.url == null) return + + if (state.isPlaying) { + player.controls().pause() + } else { + if (state.position <= 0f && !player.status().isPlaying) { + state.url.let { player.media().play(it) } + } else { + player.controls().play() + } + } + } + + fun seekVideo(position: Float) { + videoPlayer?.controls()?.setPosition(position) + } + + fun seekAudio(position: Float) { + audioPlayer?.controls()?.setPosition(position) + } + + fun setVideoVolume(volume: Int) { + videoPlayer?.audio()?.setVolume(volume) + _videoState.value = _videoState.value.copy(volume = volume) + } + + fun setAudioVolume(volume: Int) { + audioPlayer?.audio()?.setVolume(volume) + _audioState.value = _audioState.value.copy(volume = volume) + } + + fun toggleVideoMute() { + val muted = !_videoState.value.isMuted + videoPlayer?.audio()?.isMute = muted + _videoState.value = _videoState.value.copy(isMuted = muted) + } + + fun toggleAudioMute() { + val muted = !_audioState.value.isMuted + audioPlayer?.audio()?.isMute = muted + _audioState.value = _audioState.value.copy(isMuted = muted) + } + + fun stopVideo() { + videoPollingJob?.cancel() + videoPollingJob = null + videoPlayer?.controls()?.stop() + _videoState.value = MediaPlaybackState() + _videoFrame.value = null + _isFullscreen.value = false + } + + fun stopAudio() { + audioPollingJob?.cancel() + audioPollingJob = null + audioPlayer?.controls()?.stop() + _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) + } + + fun toggleFullscreen() { + _isFullscreen.value = !_isFullscreen.value + } + + fun exitFullscreen() { + _isFullscreen.value = false + } + + fun shutdown() { + videoPollingJob?.cancel() + audioPollingJob?.cancel() + + videoPlayer?.let { p -> + try { + p.controls().stop() + } catch (_: Exception) { + } + VlcjPlayerPool.release(p) + } + videoPlayer = null + + audioPlayer?.let { p -> + try { + p.controls().stop() + } catch (_: Exception) { + } + VlcjPlayerPool.releaseAudioPlayer(p) + } + audioPlayer = null + + _videoState.value = MediaPlaybackState() + _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) + _videoFrame.value = null + _isFullscreen.value = false + + scope.cancel() + } + + private fun setupVideoSurface(player: EmbeddedMediaPlayer) { + val bufferFormatCallback = + object : BufferFormatCallback { + override fun getBufferFormat( + sourceWidth: Int, + sourceHeight: Int, + ): BufferFormat { + if (sourceHeight > 0) { + _videoState.value = + _videoState.value.copy( + aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(), + ) + } + val bmp = Bitmap() + bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL)) + skBitmap = bmp + pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) {} + } + + val renderCallback = + RenderCallback { _, nativeBuffers, _ -> + val bmp = skBitmap ?: return@RenderCallback + val bytes = pixelBytes ?: return@RenderCallback + val buffer = nativeBuffers[0] + buffer.rewind() + buffer.get(bytes) + bmp.installPixels(bytes) + _videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() + } + + val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) + player.videoSurface().set(surface) + } + + private fun setupVideoEventListener(player: EmbeddedMediaPlayer) { + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + val state = _videoState.value + _videoState.value = + state.copy( + isPlaying = true, + isBuffering = false, + duration = mediaPlayer.status().length(), + ) + } + + override fun paused(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isPlaying = false) + } + + override fun stopped(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false) + } + + override fun buffering( + mediaPlayer: MediaPlayer, + newCache: Float, + ) { + _videoState.value = _videoState.value.copy(isBuffering = newCache < 100f) + } + + override fun positionChanged( + mediaPlayer: MediaPlayer, + newPosition: Float, + ) { + _videoState.value = + _videoState.value.copy( + position = newPosition, + currentTime = (newPosition * _videoState.value.duration).toLong(), + ) + } + + override fun finished(mediaPlayer: MediaPlayer) { + _videoState.value = + _videoState.value.copy( + isPlaying = false, + isBuffering = false, + position = 0f, + currentTime = 0L, + ) + } + + override fun error(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isBuffering = false) + println("VLC: playback error for ${_videoState.value.url}") + } + }, + ) + } + + private fun setupAudioEventListener(player: MediaPlayer) { + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + _audioState.value = + _audioState.value.copy( + isPlaying = true, + isBuffering = false, + duration = mediaPlayer.status().length(), + ) + } + + override fun paused(mediaPlayer: MediaPlayer) { + _audioState.value = _audioState.value.copy(isPlaying = false) + } + + override fun stopped(mediaPlayer: MediaPlayer) { + _audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false) + } + + override fun positionChanged( + mediaPlayer: MediaPlayer, + newPosition: Float, + ) { + _audioState.value = + _audioState.value.copy( + position = newPosition, + currentTime = (newPosition * _audioState.value.duration).toLong(), + ) + } + + override fun finished(mediaPlayer: MediaPlayer) { + _audioState.value = + _audioState.value.copy( + isPlaying = false, + position = 0f, + currentTime = 0L, + ) + } + }, + ) + } + + private fun startVideoPolling() { + videoPollingJob?.cancel() + videoPollingJob = + scope.launch { + while (true) { + delay(500) + val player = videoPlayer ?: break + val state = _videoState.value + if (state.isPlaying) { + try { + _videoState.value = + state.copy( + position = player.status().position(), + currentTime = player.status().time(), + ) + } catch (_: Exception) { + } + } + } + } + } + + private fun startAudioPolling() { + audioPollingJob?.cancel() + audioPollingJob = + scope.launch { + while (true) { + delay(500) + val player = audioPlayer ?: break + val state = _audioState.value + if (state.isPlaying) { + try { + _audioState.value = + state.copy( + position = player.status().position(), + currentTime = player.status().time(), + ) + } catch (_: Exception) { + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt new file mode 100644 index 0000000000..8e92dc92ee --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import com.sun.jna.NativeLibrary +import uk.co.caprica.vlcj.binding.lib.LibC +import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil +import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy +import java.io.File + +/** + * Discovers bundled VLC libraries on macOS. + * Must force-load libvlccore before libvlc to avoid link errors. + */ +class MacOsVlcDiscoverer : + BaseNativeDiscoveryStrategy( + arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"), + arrayOf("%s/plugins"), + ) { + override fun supported(): Boolean { + val os = System.getProperty("os.name").lowercase() + return "mac" in os + } + + override fun discoveryDirectories(): List { + val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return emptyList() + val vlcDir = File(resourcesDir, "vlc") + return if (vlcDir.isDirectory) listOf(vlcDir.absolutePath) else emptyList() + } + + override fun onFound(path: String): Boolean { + NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path) + NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName()) + return true + } + + override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt new file mode 100644 index 0000000000..bfa1b32954 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +object ServerHealthCheck { + private val httpClient = + OkHttpClient + .Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + + enum class ServerStatus { + ONLINE, + OFFLINE, + UNKNOWN, + } + + /** + * Check if a Blossom server is reachable via HEAD request. + */ + suspend fun check(serverUrl: String): ServerStatus = + withContext(Dispatchers.IO) { + try { + val url = serverUrl.removeSuffix("/") + val request = + Request + .Builder() + .url(url) + .head() + .build() + val response = httpClient.newCall(request).execute() + response.use { + if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE + } + } catch (_: Exception) { + ServerStatus.OFFLINE + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt new file mode 100644 index 0000000000..6ecaabe158 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.jetbrains.skia.Image as SkiaImage + +object VideoThumbnailCache { + private val cache = ConcurrentHashMap() + private val pending = ConcurrentHashMap() + + fun getCached(url: String): ImageBitmap? = cache[url] + + suspend fun getThumbnail(url: String): ImageBitmap? { + cache[url]?.let { return it } + if (pending.putIfAbsent(url, true) != null) return null + + return withContext(Dispatchers.IO) { + try { + extractFirstFrame(url)?.also { cache[url] = it } + } finally { + pending.remove(url) + } + } + } + + private fun extractFirstFrame(url: String): ImageBitmap? { + if (!VlcjPlayerPool.init()) { + println("VLC thumbnail: init failed for $url") + return null + } + val player = VlcjPlayerPool.acquireForThumbnail() + if (player == null) { + println("VLC thumbnail: pool exhausted for $url") + return null + } + + var result: ImageBitmap? = null + val latch = CountDownLatch(1) + + val bufferFormatCallback = + object : BufferFormatCallback { + override fun getBufferFormat( + sourceWidth: Int, + sourceHeight: Int, + ): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight) + + override fun allocatedBuffers(buffers: Array) {} + } + + val renderCallback = + RenderCallback { _, nativeBuffers, bufferFormat -> + if (result != null) return@RenderCallback + try { + if (nativeBuffers.isEmpty()) return@RenderCallback + val w = bufferFormat.width + val h = bufferFormat.height + if (w <= 0 || h <= 0) return@RenderCallback + val bmp = Bitmap() + bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL)) + val bytes = ByteArray(w * h * 4) + val buffer = nativeBuffers[0] + buffer.rewind() + buffer.get(bytes) + bmp.installPixels(bytes) + result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() + latch.countDown() + } catch (e: Exception) { + println("VLC thumbnail: render error for $url — ${e.message}") + latch.countDown() + } + } + + val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) + if (surface == null) { + println("VLC thumbnail: surface creation failed for $url") + VlcjPlayerPool.release(player) + return null + } + + player.videoSurface().set(surface) + player.audio().setVolume(0) + player.audio().isMute = true + + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun error(mediaPlayer: MediaPlayer) { + println("VLC thumbnail: playback error for $url") + latch.countDown() + } + }, + ) + + player.media().play(url) + + // Wait up to 8 seconds for first frame (network videos can be slow) + latch.await(8, TimeUnit.SECONDS) + + if (result == null) { + println("VLC thumbnail: timed out or failed for $url") + } + + VlcjPlayerPool.release(player) + return result + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt new file mode 100644 index 0000000000..18e08c306d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import uk.co.caprica.vlcj.factory.MediaPlayerFactory +import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Manages a pool of VLCJ media players to avoid costly create/destroy cycles. + * Keeps strong references to prevent GC crashes from native callbacks. + * + * IMPORTANT: Never let player instances be garbage collected while native + * callbacks are active — this causes JVM segfaults. + */ +object VlcjPlayerPool { + private val available = AtomicBoolean(false) + private val initAttempted = AtomicBoolean(false) + private val initLatch = CountDownLatch(1) + private var factory: MediaPlayerFactory? = null + + // Video player pool (for actual playback) + private val allPlayers = mutableListOf() + private val idlePlayers = ConcurrentLinkedQueue() + private const val MAX_POOL_SIZE = 1 + + // Thumbnail player pool (separate so thumbnails don't compete with playback) + private val allThumbPlayers = mutableListOf() + private val idleThumbPlayers = ConcurrentLinkedQueue() + private const val MAX_THUMB_POOL_SIZE = 2 + + // Audio player pool (shared factory with --no-video) + private var audioFactory: MediaPlayerFactory? = null + private val allAudioPlayers = mutableListOf() + private val idleAudioPlayers = ConcurrentLinkedQueue() + private const val MAX_AUDIO_POOL_SIZE = 1 + + /** + * Initialize the pool. Thread-safe — only runs once. + * Returns false if VLC is not installed. + */ + fun init(): Boolean { + if (available.get()) return true + + // Only one thread performs init; others wait + if (!initAttempted.compareAndSet(false, true)) { + initLatch.await(10, TimeUnit.SECONDS) + return available.get() + } + + return try { + // Try bundled VLC first, then fall through to system VLC + val discovery = + try { + val nd = + NativeDiscovery( + BundledVlcDiscoverer(), + MacOsVlcDiscoverer(), + ) + val found = nd.discover() + if (found) { + println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}") + } else { + println("VLC: bundled discovery failed, falling back to system VLC") + } + found + } catch (e: Throwable) { + println("VLC: bundled discovery threw ${e.message}") + false + } + if (!discovery) { + // Try default system discovery + val systemDiscovery = NativeDiscovery().discover() + println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}") + } + val f = MediaPlayerFactory("--no-xlib") + factory = f + available.set(true) + println("VLC: MediaPlayerFactory created successfully") + true + } catch (e: Throwable) { + println("VLC: init failed — ${e.message}") + available.set(false) + false + } finally { + initLatch.countDown() + } + } + + fun isAvailable(): Boolean = available.get() + + /** + * Create a callback video surface using the factory's API. + */ + fun createVideoSurface( + bufferFormatCallback: BufferFormatCallback, + renderCallback: RenderCallback, + ): VideoSurface? { + val f = factory ?: return null + return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true) + } + + /** + * Acquire a video player from the pool or create a new one. + * Returns null if VLC is not available or pool is at capacity. + */ + fun acquire(): EmbeddedMediaPlayer? { + if (!available.get()) return null + val f = factory ?: return null + + synchronized(allPlayers) { + idlePlayers.poll()?.let { return it } + if (allPlayers.size >= MAX_POOL_SIZE) return null + return try { + val player = f.mediaPlayers().newEmbeddedMediaPlayer() + allPlayers.add(player) + player + } catch (_: Exception) { + null + } + } + } + + /** + * Acquire a player dedicated to thumbnail extraction. + * Separate pool so thumbnails don't compete with playback. + */ + fun acquireForThumbnail(): EmbeddedMediaPlayer? { + if (!available.get()) return null + val f = factory ?: return null + + synchronized(allThumbPlayers) { + idleThumbPlayers.poll()?.let { return it } + if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) { + // Fall back to main pool if thumb pool is full + return acquire() + } + return try { + val player = f.mediaPlayers().newEmbeddedMediaPlayer() + allThumbPlayers.add(player) + player + } catch (_: Exception) { + null + } + } + } + + /** + * Acquire an audio-only player from the pool. + * Uses a separate factory with --no-video for efficiency. + */ + fun acquireAudioPlayer(): MediaPlayer? { + if (!init()) return null + + synchronized(allAudioPlayers) { + idleAudioPlayers.poll()?.let { return it } + if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null + + val af = + audioFactory ?: try { + MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it } + } catch (_: Throwable) { + return null + } + + return try { + val player = af.mediaPlayers().newMediaPlayer() + allAudioPlayers.add(player) + player + } catch (_: Exception) { + null + } + } + } + + /** + * Return a video player to the pool for reuse. + */ + fun release(player: EmbeddedMediaPlayer) { + try { + player.controls().stop() + // Return to correct pool + synchronized(allThumbPlayers) { + if (player in allThumbPlayers) { + idleThumbPlayers.offer(player) + return + } + } + idlePlayers.offer(player) + } catch (_: Exception) { + // Player may already be disposed + } + } + + /** + * Return an audio player to the pool for reuse. + */ + fun releaseAudioPlayer(player: MediaPlayer) { + try { + player.controls().stop() + idleAudioPlayers.offer(player) + } catch (_: Exception) { + // Player may already be disposed + } + } + + /** + * Shut down the entire pool. Call on app exit. + */ + fun shutdown() { + synchronized(allPlayers) { + idlePlayers.clear() + for (player in allPlayers) { + try { + player.controls().stop() + player.release() + } catch (_: Exception) { + // Ignore + } + } + allPlayers.clear() + } + synchronized(allThumbPlayers) { + idleThumbPlayers.clear() + for (player in allThumbPlayers) { + try { + player.controls().stop() + player.release() + } catch (_: Exception) { + // Ignore + } + } + allThumbPlayers.clear() + } + synchronized(allAudioPlayers) { + idleAudioPlayers.clear() + for (player in allAudioPlayers) { + try { + player.controls().stop() + player.release() + } catch (_: Exception) { + // Ignore + } + } + allAudioPlayers.clear() + } + try { + factory?.release() + } catch (_: Exception) { + // Ignore + } + try { + audioFactory?.release() + } catch (_: Exception) { + // Ignore + } + factory = null + audioFactory = null + available.set(false) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt new file mode 100644 index 0000000000..b932144972 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import java.util.Base64 + +object DesktopBlossomAuth { + suspend fun createUploadAuth( + hash: HexKey, + size: Long, + alt: String, + signer: NostrSigner, + ): String { + val event = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) + return encodeAuthHeader(event) + } + + fun encodeAuthHeader(event: BlossomAuthorizationEvent): String { + val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) + return "Nostr $b64" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt new file mode 100644 index 0000000000..43842b38af --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSink +import okio.source +import java.io.File + +class DesktopBlossomClient( + private val okHttpClient: OkHttpClient = OkHttpClient(), +) { + suspend fun upload( + file: File, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = + withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBody = + object : RequestBody() { + override fun contentType() = contentType.toMediaType() + + override fun contentLength() = file.length() + + override fun writeTo(sink: BufferedSink) { + file.inputStream().source().use(sink::writeAll) + } + } + + val requestBuilder = + Request + .Builder() + .url(apiUrl) + .put(requestBody) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } + } + + /** + * Upload raw bytes (e.g. encrypted blobs) to a Blossom server. + */ + suspend fun upload( + bytes: ByteArray, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = + withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + + val requestBuilder = + Request + .Builder() + .url(apiUrl) + .put(requestBody) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt new file mode 100644 index 0000000000..c197441d78 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import org.apache.commons.imaging.Imaging +import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter +import java.io.ByteArrayOutputStream +import java.io.File + +object DesktopMediaCompressor { + fun stripExif(file: File): File { + if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) { + return file + } + + return try { + val bytes = file.readBytes() + // Check if it has EXIF data + val metadata = Imaging.getMetadata(bytes) + if (metadata == null) return file + + val baos = ByteArrayOutputStream() + ExifRewriter().removeExifMetadata(bytes, baos) + val stripped = File.createTempFile("stripped_", ".jpg") + stripped.writeBytes(baos.toByteArray()) + stripped.deleteOnExit() + stripped + } catch (_: Exception) { + file + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt new file mode 100644 index 0000000000..d43b3cfd36 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File +import javax.imageio.ImageIO + +data class MediaMetadata( + val sha256: String, + val size: Long, + val mimeType: String, + val width: Int? = null, + val height: Int? = null, + val blurhash: String? = null, +) + +object DesktopMediaMetadata { + fun compute(file: File): MediaMetadata { + val bytes = file.readBytes() + val hash = sha256(bytes).toHexKey() + val mimeType = guessMimeType(file) + var width: Int? = null + var height: Int? = null + var blurhash: String? = null + + if (mimeType.startsWith("image/")) { + try { + val image = ImageIO.read(file) + if (image != null) { + width = image.width + height = image.height + blurhash = image.toPlatformImage().toBlurhash() + } + } catch (_: Exception) { + } + } + + return MediaMetadata( + sha256 = hash, + size = bytes.size.toLong(), + mimeType = mimeType, + width = width, + height = height, + blurhash = blurhash, + ) + } + + fun guessMimeType(file: File): String { + val ext = file.extension.lowercase() + return when (ext) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "svg" -> "image/svg+xml" + "avif" -> "image/avif" + "mp4" -> "video/mp4" + "webm" -> "video/webm" + "mov" -> "video/quicktime" + "mp3" -> "audio/mpeg" + "ogg" -> "audio/ogg" + "wav" -> "audio/wav" + "flac" -> "audio/flac" + else -> "application/octet-stream" + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt new file mode 100644 index 0000000000..e22f00abc0 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File + +data class UploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, +) + +data class EncryptedUploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, + val encryptedHash: String, + val encryptedSize: Int, +) + +class DesktopUploadOrchestrator( + private val client: DesktopBlossomClient = DesktopBlossomClient(), +) { + suspend fun upload( + file: File, + alt: String?, + serverBaseUrl: String, + signer: NostrSigner, + stripExif: Boolean = true, + ): UploadResult { + // 1. Strip EXIF if requested (JPEG only) + val processedFile = + if (stripExif) { + DesktopMediaCompressor.stripExif(file) + } else { + file + } + + // 2. Compute metadata (hash, dimensions, blurhash) + val metadata = DesktopMediaMetadata.compute(processedFile) + + // 3. Create auth header + val authHeader = + DesktopBlossomAuth.createUploadAuth( + hash = metadata.sha256, + size = metadata.size, + alt = alt ?: "Uploading ${file.name}", + signer = signer, + ) + + // 4. Upload + val result = + client.upload( + file = processedFile, + contentType = metadata.mimeType, + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + // 5. Clean up temp file if we stripped EXIF + if (processedFile != file) { + processedFile.delete() + } + + return UploadResult(blossom = result, metadata = metadata) + } + + /** + * Upload a file encrypted with AES-GCM for NIP-17 DM file sharing. + * Computes pre-encryption metadata (dimensions, blurhash), encrypts bytes, + * then uploads the encrypted blob to Blossom. + */ + suspend fun uploadEncrypted( + file: File, + cipher: AESGCM, + serverBaseUrl: String, + signer: NostrSigner, + ): EncryptedUploadResult { + // 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) + val metadata = DesktopMediaMetadata.compute(file) + + // 2. Read file bytes and encrypt + val plaintext = file.readBytes() + val encrypted = cipher.encrypt(plaintext) + + // 3. Compute SHA256 of ENCRYPTED blob (not plaintext) + val encryptedHash = sha256(encrypted).toHexKey() + val encryptedSize = encrypted.size + + // 4. Create Blossom auth with encrypted hash and size + val authHeader = + DesktopBlossomAuth.createUploadAuth( + hash = encryptedHash, + size = encryptedSize.toLong(), + alt = "Encrypted upload", + signer = signer, + ) + + // 5. Upload encrypted blob as opaque binary + val result = + client.upload( + bytes = encrypted, + contentType = "application/octet-stream", + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + return EncryptedUploadResult( + blossom = result, + metadata = metadata, + encryptedHash = encryptedHash, + encryptedSize = encryptedSize, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt new file mode 100644 index 0000000000..e79244ecf5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class UploadState( + val isUploading: Boolean = false, + val fileName: String? = null, + val error: String? = null, + val result: UploadResult? = null, +) + +class DesktopUploadTracker { + private val _state = MutableStateFlow(UploadState()) + val state = _state.asStateFlow() + + fun startUpload(fileName: String) { + _state.value = UploadState(isUploading = true, fileName = fileName) + } + + fun onSuccess(result: UploadResult) { + _state.value = UploadState(isUploading = false, result = result) + } + + fun onError(error: String) { + _state.value = UploadState(isUploading = false, error = error) + } + + fun reset() { + _state.value = UploadState() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index d4d3bac207..a7d4a633a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -145,6 +145,7 @@ fun createSearchPeopleSubscription( limit: Int = 50, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, + onClosed: (NormalizedRelayUrl, String, List?) -> Unit = { _, _, _ -> }, ): SubscriptionConfig? { if (searchQuery.isBlank()) return null @@ -154,6 +155,7 @@ fun createSearchPeopleSubscription( relays = relays, onEvent = onEvent, onEose = onEose, + onClosed = onClosed, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt new file mode 100644 index 0000000000..c918ea775d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.subscriptions + +import com.vitorpamplona.amethyst.commons.search.SearchQuery +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +object SearchFilterFactory { + // Default kind groups (ported from Android SearchPostsByText) + private val defaultKindGroup1 = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + ZapPollEvent.KIND, + ChannelCreateEvent.KIND, + ) + + private val defaultKindGroup2 = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PublicMessageEvent.KIND, + NNSEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + + private val defaultKindGroup3 = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + FollowListEvent.KIND, + NipTextEvent.KIND, + PollEvent.KIND, + PollResponseEvent.KIND, + ) + + fun createFilters( + query: SearchQuery, + limit: Int = 100, + ): List { + if (query.isEmpty) return emptyList() + + val searchString = buildSearchString(query) + val tags = buildTags(query) + val authors = query.authors.takeIf { it.isNotEmpty() } + + if (query.kinds.isNotEmpty()) { + // User specified kinds — single filter (no group splitting needed) + return listOf( + Filter( + kinds = query.kinds.toList(), + search = searchString, + authors = authors, + tags = tags, + since = query.since, + until = query.until, + limit = limit, + ), + ) + } + + // No kinds specified — use default 3-group search (Android parity) + return listOf(defaultKindGroup1, defaultKindGroup2, defaultKindGroup3).map { kindGroup -> + Filter( + kinds = kindGroup, + search = searchString, + authors = authors, + tags = tags, + since = query.since, + until = query.until, + limit = limit, + ) + } + } + + private fun buildSearchString(query: SearchQuery): String? { + val parts = mutableListOf() + + // Free text (exclude negation terms — those are client-side only) + if (query.text.isNotBlank()) { + parts.add(query.text) + } + + // NIP-50 inline extensions + query.language?.let { parts.add("language:$it") } + query.domain?.let { parts.add("domain:$it") } + + return parts.joinToString(" ").takeIf { it.isNotBlank() } + } + + private fun buildTags(query: SearchQuery): Map>? { + if (query.hashtags.isEmpty()) return null + return mapOf("t" to query.hashtags.toList()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt index 8aecc2f2d0..f7ce8727d5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt @@ -46,6 +46,7 @@ data class SubscriptionConfig( val relays: Set, val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, val onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, + val onClosed: (NormalizedRelayUrl, String, List?) -> Unit = { _, _, _ -> }, ) /** @@ -95,6 +96,14 @@ fun rememberSubscription( ) { cfg.onEose(relay, forFilters) } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + cfg.onClosed(relay, message, forFilters) + } }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index c4f1570c6d..f08682e569 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -289,7 +289,9 @@ fun BookmarksScreen( ) { NoteCard( note = event.toNoteDisplayData(localCache), + localCache = localCache, onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, ) NoteActionsRow( event = event, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index dcf903fa77..0c955558ee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme @@ -35,36 +38,117 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction +import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator +import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker +import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult +import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler +import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker +import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DnDConstants +import java.awt.dnd.DropTargetDropEvent +import java.io.File +private val MEDIA_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") + +private val IMAGE_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif") + +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ComposeNoteDialog( onDismiss: () -> Unit, relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, - replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null, + replyTo: Event? = null, ) { var content by remember { mutableStateOf("") } var isPosting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val attachedFiles = remember { mutableStateListOf() } + val uploadTracker = remember { DesktopUploadTracker() } + val uploadState by uploadTracker.state.collectAsState() + val orchestrator = remember { DesktopUploadOrchestrator() } + var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } + var postAsPicture by remember { mutableStateOf(false) } + + // Drag-and-drop state + var isDragOver by remember { mutableStateOf(false) } + val dropTarget = + remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + isDragOver = false + val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false + dropEvent.acceptDrop(DnDConstants.ACTION_COPY) + val transferable = dropEvent.transferable + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + @Suppress("UNCHECKED_CAST") + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + attachedFiles.addAll(files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }) + dropEvent.dropComplete(true) + return true + } + dropEvent.dropComplete(false) + return false + } + + override fun onStarted(event: DragAndDropEvent) { + isDragOver = true + } + + override fun onEnded(event: DragAndDropEvent) { + isDragOver = false + } + } + } Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) { Card( - modifier = Modifier.width(600.dp).padding(16.dp), + modifier = + Modifier + .width(600.dp) + .padding(16.dp) + .dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget) + .then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + } else { + Modifier + }, + ), ) { Column(modifier = Modifier.padding(24.dp)) { Text( @@ -85,20 +169,67 @@ fun ComposeNoteDialog( Spacer(Modifier.height(16.dp)) OutlinedTextField( - value = content, + value = if (postAsPicture) "" else content, onValueChange = { content = it errorMessage = null }, - modifier = Modifier.fillMaxWidth().height(200.dp), - label = { Text("What's on your mind?") }, - placeholder = { Text("Write your note...") }, - enabled = !isPosting, - maxLines = 10, + modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp), + label = { + Text( + if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?", + ) + }, + placeholder = { Text(if (postAsPicture) "" else "Write your note...") }, + enabled = !isPosting && !postAsPicture, + maxLines = if (postAsPicture) 1 else 10, ) Spacer(Modifier.height(8.dp)) + MediaAttachmentRow( + attachedFiles = attachedFiles, + isUploading = uploadState.isUploading, + onAttach = { + val files = DesktopFilePicker.pickMediaFiles() + attachedFiles.addAll(files) + }, + onPaste = { + val files = ClipboardPasteHandler.getClipboardFiles() + attachedFiles.addAll(files) + }, + onRemove = { attachedFiles.remove(it) }, + ) + + // Server selector + post type — shown when files are attached + if (attachedFiles.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + ServerSelector( + selectedServer = selectedServer, + onServerSelected = { selectedServer = it }, + ) + + // Post type toggle — only when images are attached + val hasImages = + attachedFiles.any { + it.extension.lowercase() in IMAGE_EXTENSIONS + } + if (hasImages) { + PostTypeSelector( + isPicture = postAsPicture, + onToggle = { postAsPicture = it }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + // Character count Text( "${content.length} characters", @@ -115,6 +246,15 @@ fun ComposeNoteDialog( ) } + uploadState.error?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + "Upload error: $error", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Spacer(Modifier.height(16.dp)) Row( @@ -132,7 +272,7 @@ fun ComposeNoteDialog( Button( onClick = { - if (content.isBlank()) { + if (content.isBlank() && attachedFiles.isEmpty()) { errorMessage = "Note cannot be empty" return@Button } @@ -142,21 +282,61 @@ fun ComposeNoteDialog( errorMessage = null try { - publishNote( - content = content, - account = account, - relayManager = relayManager, - replyTo = replyTo, - ) + // Upload attached files and collect results + val uploadResults = mutableListOf() + for (file in attachedFiles) { + uploadTracker.startUpload(file.name) + val result = + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + ) + uploadTracker.onSuccess(result) + uploadResults.add(result) + } + + // Append uploaded URLs to content + val finalContent = + buildString { + append(content) + for (result in uploadResults) { + result.blossom.url?.let { url -> + if (isNotBlank()) append("\n") + append(url) + } + } + } + + if (postAsPicture) { + val pictureMetas = buildPictureMetas(uploadResults) + publishPicture( + description = content, + images = pictureMetas, + account = account, + relayManager = relayManager, + ) + } else { + val imetaTags = buildIMetaTags(uploadResults) + publishNote( + content = finalContent, + account = account, + relayManager = relayManager, + replyTo = replyTo, + imetaTags = imetaTags, + ) + } onDismiss() } catch (e: Exception) { - errorMessage = "Failed to publish: ${e.message}" + errorMessage = "Failed: ${e.message}" + uploadTracker.onError(e.message ?: "Unknown error") } finally { isPosting = false } } }, - enabled = !isPosting && content.isNotBlank(), + enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()), ) { Text(if (isPosting) "Publishing..." else "Publish") } @@ -166,23 +346,176 @@ fun ComposeNoteDialog( } } -/** - * Publishes a text note to relays. - * Uses the Account's key to sign the event. - */ -private suspend fun publishNote( - content: String, +private fun buildIMetaTags(results: List): List = + results.mapNotNull { result -> + val url = result.blossom.url ?: return@mapNotNull null + val meta = result.metadata + val props = mutableMapOf>() + props["m"] = listOf(meta.mimeType) + props["x"] = listOf(meta.sha256) + props["size"] = listOf(meta.size.toString()) + if (meta.width != null && meta.height != null) { + props["dim"] = listOf("${meta.width}x${meta.height}") + } + meta.blurhash?.let { props["blurhash"] = listOf(it) } + IMetaTag(url = url, properties = props) + } + +@Composable +private fun ServerSelector( + selectedServer: String, + onServerSelected: (String) -> Unit, +) { + val servers = DesktopPreferences.blossomServers + if (servers.size <= 1) { + // Only one server — just show label, no dropdown + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + "Upload to: ", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + selectedServer, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + return + } + + var expanded by remember { mutableStateOf(false) } + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + "Upload to: ", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.foundation.layout.Box { + androidx.compose.material3.TextButton(onClick = { expanded = true }) { + Text( + selectedServer, + style = MaterialTheme.typography.labelSmall, + ) + } + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + servers.forEach { server -> + androidx.compose.material3.DropdownMenuItem( + text = { Text(server, style = MaterialTheme.typography.bodySmall) }, + onClick = { + onServerSelected(server) + expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun PostTypeSelector( + isPicture: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "Post as:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.FilterChip( + selected = !isPicture, + onClick = { onToggle(false) }, + label = { Text("Note", style = MaterialTheme.typography.labelSmall) }, + ) + androidx.compose.material3.FilterChip( + selected = isPicture, + onClick = { onToggle(true) }, + label = { Text("Picture", style = MaterialTheme.typography.labelSmall) }, + ) + } +} + +private fun buildPictureMetas(results: List): List = + results.mapNotNull { result -> + val url = result.blossom.url ?: return@mapNotNull null + val meta = result.metadata + com.vitorpamplona.quartz.nip68Picture.PictureMeta( + url = url, + mimeType = meta.mimeType, + blurhash = meta.blurhash, + dimension = + if (meta.width != null && meta.height != null) { + com.vitorpamplona.quartz.nip94FileMetadata.tags + .DimensionTag(meta.width, meta.height) + } else { + null + }, + hash = meta.sha256, + size = meta.size.toInt(), + ) + } + +private suspend fun publishPicture( + description: String, + images: List, account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, - replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { withContext(Dispatchers.IO) { if (account.isReadOnly) { throw IllegalStateException("Cannot post in read-only mode") } - val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo) + val template = + com.vitorpamplona.quartz.nip68Picture.PictureEvent.build( + images = images, + description = description, + ) { + hashtags(findHashtags(description)) + } + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } +} + +private suspend fun publishNote( + content: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + replyTo: Event?, + imetaTags: List = emptyList(), +) { + withContext(Dispatchers.IO) { + if (account.isReadOnly) { + throw IllegalStateException("Cannot post in read-only mode") + } + + val template = + TextNoteEvent.build(content) { + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + hashtags(findHashtags(content)) + references(findURLs(content)) + for (imeta in imetaTags) { + add(imeta.toTagArray()) + } + } + + val signedEvent = account.signer.sign(template) relayManager.broadcastToAll(signedEvent) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index e539b15109..b8fee13443 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -55,6 +55,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -75,7 +76,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -86,6 +89,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +data class LightboxState( + val urls: List, + val index: Int, + val seekPosition: Float = 0f, + val fullscreen: Boolean = false, +) + /** * Note card with action buttons. */ @@ -100,6 +110,8 @@ fun FeedNoteCard( onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, zapReceipts: List = emptyList(), reactionCount: Int = 0, replyCount: Int = 0, @@ -110,15 +122,15 @@ fun FeedNoteCard( ) { val zapAmountSats = zapReceipts.sumOf { it.amountSats } - Column( - modifier = - Modifier.clickable { - onNavigateToThread(event.id) - }, - ) { + Column { NoteCard( note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, ) // Action buttons (only if logged in) @@ -180,6 +192,7 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } + var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } @@ -434,30 +447,40 @@ fun FeedScreen( ) } - // Subscribe to metadata for note authors (to enable zaps and populate search cache) + // Subscribe to metadata for note authors + mentioned users val authorPubkeys = events.map { it.pubKey }.distinct() + val mentionedPubkeys = + remember(events) { + val parser = UrlParser() + events + .flatMap { event -> + val urls = parser.parseValidUrls(event.content) + extractMentionedPubkeys(urls.bech32s) + }.distinct() + } + val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() } // Use coordinator for rate-limited metadata loading (preferred) - LaunchedEffect(authorPubkeys, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys) + LaunchedEffect(allPubkeys, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys) } } // Fallback subscription if coordinator not available - rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { + rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) { // Skip if using coordinator if (subscriptionsCoordinator != null) { return@rememberSubscription null } - if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) { + if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) { return@rememberSubscription null } // Only fetch metadata for users we don't have yet val missingPubkeys = - authorPubkeys.filter { pubkey -> + allPubkeys.filter { pubkey -> localCache .getUserIfExists(pubkey) ?.metadataOrNull() @@ -480,147 +503,156 @@ fun FeedScreen( } @OptIn(ExperimentalLayoutApi::class) - Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button — wraps on narrow columns - FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // Header with compose button — wraps on narrow columns + FlowRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { + FlowRow( + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) - // Feed mode selector - if (account != null) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - FilterChip( - selected = feedMode == FeedMode.GLOBAL, - onClick = { - feedMode = FeedMode.GLOBAL - DesktopPreferences.feedMode = FeedMode.GLOBAL - }, - label = { Text("Global") }, + // Feed mode selector + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { + feedMode = FeedMode.GLOBAL + DesktopPreferences.feedMode = FeedMode.GLOBAL + }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { + feedMode = FeedMode.FOLLOWING + DesktopPreferences.feedMode = FeedMode.FOLLOWING + }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (feedMode == FeedMode.FOLLOWING) { + Text( + " • ${followedUsers.size} followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - FilterChip( - selected = feedMode == FeedMode.FOLLOWING, - onClick = { - feedMode = FeedMode.FOLLOWING - DesktopPreferences.feedMode = FeedMode.FOLLOWING - }, - label = { Text("Following") }, + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), ) } } } - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${connectedRelays.size} relays connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (feedMode == FeedMode.FOLLOWING) { - Text( - " • ${followedUsers.size} followed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // New Post button (primary action) + Button( + onClick = onCompose, + enabled = account != null && !account.isReadOnly, + ) { + Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { relayManager.connect() }, - modifier = Modifier.size(24.dp), - ) { - Icon( - Icons.Default.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), + Text("New Post") + } + } + + Spacer(Modifier.height(8.dp)) + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { + LoadingState("Loading followed users...") + } else if (events.isEmpty() && !initialLoadComplete) { + LoadingState("Loading notes...") + } else if (events.isEmpty() && initialLoadComplete) { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" + }, + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" + }, + onRefresh = { relayManager.connect() }, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Use distinctBy to prevent duplicate key crashes from events with same ID + items(events.distinctBy { it.id }, key = { it.id }) { event -> + FeedNoteCard( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { replyToEvent = event }, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + zapReceipts = zapsByEvent[event.id] ?: emptyList(), + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } } - - // New Post button (primary action) - Button( - onClick = onCompose, - enabled = account != null && !account.isReadOnly, - ) { - Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("New Post") - } - } - - Spacer(Modifier.height(8.dp)) - - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { - LoadingState("Loading followed users...") - } else if (events.isEmpty() && !initialLoadComplete) { - LoadingState("Loading notes...") - } else if (events.isEmpty() && initialLoadComplete) { - EmptyState( - title = - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users" - } else { - "No notes found" - }, - description = - if (feedMode == FeedMode.FOLLOWING) { - "Notes from people you follow will appear here" - } else { - "Notes from the network will appear here" - }, - onRefresh = { relayManager.connect() }, - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Use distinctBy to prevent duplicate key crashes from events with same ID - items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = { replyToEvent = event }, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - zapReceipts = zapsByEvent[event.id] ?: emptyList(), - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds - }, - ) - } - } - } + } // end Column // Reply dialog if (replyToEvent != null && account != null) { @@ -631,5 +663,16 @@ fun FeedScreen( replyTo = replyToEvent, ) } - } + + // Lightbox overlay + lightboxState?.let { state -> + LightboxOverlay( + urls = state.urls, + initialIndex = state.index, + initialSeekPosition = state.seekPosition, + initialFullscreen = state.fullscreen, + onDismiss = { lightboxState = null }, + ) + } + } // end Box } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 1caa34d945..c866b0ba5b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,39 +42,68 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.commons.search.SavedSearch +import com.vitorpamplona.amethyst.commons.search.SearchQuery import com.vitorpamplona.amethyst.commons.search.SearchResult -import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard -import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState +import com.vitorpamplona.amethyst.commons.search.SearchResultFilter +import com.vitorpamplona.amethyst.commons.search.parseSearchInput +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel +import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList +import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -85,61 +119,140 @@ fun SearchScreen( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() - val searchState = remember { SearchBarState(localCache, scope) } + val state = remember { AdvancedSearchBarState(scope) } val focusRequester = remember { FocusRequester() } - // Pre-fill initial query (e.g., hashtag column) + // Pre-fill initial query LaunchedEffect(initialQuery) { if (initialQuery.isNotBlank()) { - searchState.updateSearchText(initialQuery) + state.updateFromText(initialQuery) } } + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + val displayText by state.displayText.collectAsState() + // Track TextFieldValue locally to preserve cursor position + var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) } + // Sync from flow only when text changes externally (form-driven updates) + LaunchedEffect(displayText) { + if (textFieldValue.text != displayText) { + textFieldValue = TextFieldValue(text = displayText, selection = TextRange(displayText.length)) + } + } + val query by state.query.collectAsState() + val debouncedQuery by state.debouncedQuery.collectAsState() + val panelExpanded by state.panelExpanded.collectAsState() + val isSearching by state.isSearching.collectAsState() + val peopleResults by state.peopleResults.collectAsState() + val noteResults by state.noteResults.collectAsState() + val relayStates by state.relayStates.collectAsState() - // Collect state from SearchBarState - val searchText by searchState.searchText.collectAsState() - val bech32Results by searchState.bech32Results.collectAsState() - val cachedUserResults by searchState.cachedUserResults.collectAsState() - val relaySearchResults by searchState.relaySearchResults.collectAsState() - val isSearchingRelays by searchState.isSearchingRelays.collectAsState() + // Bech32 parsing (immediate, no debounce) + val bech32Results = remember(displayText) { parseSearchInput(displayText) } - // NIP-50 relay search when local cache has few/no results - rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) { - if (connectedRelays.isEmpty()) return@rememberSubscription null + // Skip people search when query specifies kinds that don't include profile (kind 0) + val shouldSearchPeople = + (debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) || + debouncedQuery.kinds.contains(MetadataEvent.KIND) - // Only search relays if we have a real query and limited local results - if (searchState.shouldSearchRelays) { - searchState.startRelaySearch() - createSearchPeopleSubscription( - relays = connectedRelays, - searchQuery = searchText, - limit = 20, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - val user = localCache.getUserIfExists(event.pubKey) - if (user != null) { - searchState.addRelaySearchResult(user) - } - } - }, - onEose = { _, _ -> - searchState.endRelaySearch() - }, - ) - } else { - null + // Clear results and start loading when query changes + LaunchedEffect(debouncedQuery) { + if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) { + state.clearResults() + state.initRelayStates(allRelayUrls) + if (shouldSearchPeople) { + state.startSearching("people-search") + } + state.startSearching("adv-search") + // Timeout relays that silently ignore NIP-50 (e.g. strfry) + kotlinx.coroutines.delay(10_000L) + state.timeoutWaitingRelays() } } - // Subscribe to metadata for searched users (to populate cache) - rememberSubscription(connectedRelays, searchText, relayManager = relayManager) { - if (connectedRelays.isEmpty() || searchText.length < 2) { + // NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect) + rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + if (bech32Results.isNotEmpty()) return@rememberSubscription null + if (!shouldSearchPeople) { + state.stopSearching("people-search") return@rememberSubscription null } - // If it's a specific pubkey search, fetch that user's metadata - val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) + createSearchPeopleSubscription( + relays = allRelayUrls, + searchQuery = + debouncedQuery.text.ifBlank { + QuerySerializer.serialize(debouncedQuery) + }, + limit = 20, + onEvent = { event, _, relay, _ -> + if (state.trackRelayEvent(relay.url, event.id)) { + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + @Suppress("UNCHECKED_CAST") + val user = localCache.getUserIfExists(event.pubKey) as? User + if (user != null) { + state.addPeopleResult(user) + } + } + } + }, + onEose = { relay, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + state.stopSearching("people-search") + }, + onClosed = { relay, _, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.FAILED) + state.stopSearching("people-search") + }, + ) + } + + // NIP-50 advanced note search subscription (use allRelayUrls) + rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + if (bech32Results.isNotEmpty()) return@rememberSubscription null + + val filters = SearchFilterFactory.createFilters(debouncedQuery) + if (filters.isEmpty()) return@rememberSubscription null + + SubscriptionConfig( + subId = generateSubId("adv-search"), + filters = filters, + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig + if (state.trackRelayEvent(relay.url, event.id)) { + val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) + if (filtered.isNotEmpty()) { + state.addNoteResults(filtered) + } + } + }, + onEose = { relay, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + state.stopSearching("adv-search") + }, + onClosed = { relay, _, _ -> + state.updateRelayState(relay.url, RelaySyncStatus.FAILED) + state.stopSearching("adv-search") + }, + ) + } + + // Metadata subscription for bech32 pubkey lookups + rememberSubscription(connectedRelays, displayText, relayManager = relayManager) { + if (connectedRelays.isEmpty() || displayText.length < 2) { + return@rememberSubscription null + } + val pubkeyHex = decodePublicKeyAsHexOrNull(displayText) if (pubkeyHex != null) { createMetadataSubscription( relays = connectedRelays, @@ -155,14 +268,67 @@ fun SearchScreen( } } - // Auto-focus the search field + // Save to history when search completes (snapshotFlow avoids LaunchedEffect race) + LaunchedEffect(Unit) { + snapshotFlow { isSearching to debouncedQuery } + .collect { (searching, query) -> + if (!searching && !query.isEmpty) { + SearchHistoryStore.addToHistory(query) + } + } + } + + // History state + val historyItems by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + + // Auto-focus LaunchedEffect(Unit) { focusRequester.requestFocus() } Column( - modifier = modifier.fillMaxSize(), + modifier = + modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (panelExpanded) { + state.togglePanel() + } else if (displayText.isNotEmpty()) { + state.clearSearch() + } + true + } + + else -> { + false + } + } + }, ) { + // Progress bar at very top + AnimatedVisibility( + visible = isSearching, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + // Relay status banner + SearchSyncBanner( + relayStates = relayStates, + isSearching = isSearching, + ) + + // Title row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -182,180 +348,266 @@ fun SearchScreen( Spacer(Modifier.height(16.dp)) - // Search input field - OutlinedTextField( - value = searchText, - onValueChange = { searchState.updateSearchText(it) }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - placeholder = { Text("Search by name, npub, nevent, or #hashtag") }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailingIcon = { - if (searchText.isNotEmpty()) { - IconButton(onClick = { searchState.clearSearch() }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Search bar with advanced toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + state.updateFromText(it.text) + }, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + placeholder = { Text("Search notes, people, tags... or use operators") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (displayText.isNotEmpty()) { + IconButton(onClick = { state.clearSearch() }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + IconButton(onClick = { state.togglePanel() }) { + Icon( + Icons.Default.Tune, + contentDescription = "Advanced Search", + tint = + if (panelExpanded) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + // Expandable advanced panel + AnimatedVisibility( + visible = panelExpanded, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + AdvancedSearchPanel( + query = query, + onKindsChanged = { state.updateKinds(it) }, + onPseudoKindsChanged = { state.updatePseudoKinds(it) }, + onAuthorAdded = { state.addAuthor(it) }, + onAuthorRemoved = { state.removeAuthor(it) }, + onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, + onHashtagAdded = { state.addHashtag(it) }, + onHashtagRemoved = { state.removeHashtag(it) }, + onExcludeAdded = { state.addExcludeTerm(it) }, + onExcludeRemoved = { state.removeExcludeTerm(it) }, + onLanguageChanged = { state.updateLanguage(it) }, + onClear = { state.clearSearch() }, + modifier = Modifier.padding(top = 8.dp), + ) + } Spacer(Modifier.height(16.dp)) // Results - val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty() + val hasAnyResults = + bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() - if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) { + if (bech32Results.isNotEmpty()) { + // Show bech32 results (exact lookup) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Direct lookup", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + bech32Results.forEach { result -> + SearchResultCard( + result = result, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onNavigateToHashtag = onNavigateToHashtag, + ) + } + } + } else if (hasAnyResults) { + SearchResultsList( + state = state, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + localCache = localCache, + ) + } else if (!debouncedQuery.isEmpty && !isSearching) { Text( - "No matches found. Try a name, npub, nevent, or #hashtag.", + "No results found. Try broader terms or fewer filters.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) - } else if (isSearchingRelays && !hasResults) { - Text( - "Searching relays...", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + } else if (!isSearching) { + // Empty state: show history + saved searches + operator hints + SearchEmptyState( + historyItems = historyItems, + savedSearches = savedSearches, + onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, + onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, + onClearHistory = { SearchHistoryStore.clearHistory() }, ) - } else if (hasResults) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Bech32/hex results first - if (bech32Results.isNotEmpty()) { - item { - Text( - "Direct lookup", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - items(bech32Results) { result -> - SearchResultCard( - result = result, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, - ) - } - } - - // Cached user results - if (cachedUserResults.isNotEmpty()) { - if (bech32Results.isNotEmpty()) { - item { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - } - } - item { - Text( - "Cached users (${cachedUserResults.size})", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user -> - UserSearchCard( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) - } - } - - // Relay search results (NIP-50) - if (relaySearchResults.isNotEmpty()) { - if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) { - item { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - } - } - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "From relays (${relaySearchResults.size})", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), - ) - if (isSearchingRelays) { - Text( - "searching...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user -> - UserSearchCard( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) - } - } else if (isSearchingRelays && cachedUserResults.isEmpty()) { - item { - Text( - "Searching relays...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - } - } - } else { - // Empty state - Column( - modifier = Modifier.fillMaxWidth().padding(top = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Search for users or notes", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - Text( - "Enter a name or Nostr identifier:", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(16.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - SearchHint("vitor", "Search by name") - SearchHint("npub1...", "User profile") - SearchHint("note1...", "Single note") - SearchHint("nevent1...", "Note with metadata") - SearchHint("#hashtag", "Hashtag search") - } - } } } } +@Composable +private fun SearchEmptyState( + historyItems: List, + savedSearches: List, + onLoadQuery: (SearchQuery) -> Unit, + onDeleteSaved: (String) -> Unit, + onClearHistory: () -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Saved searches + if (savedSearches.isNotEmpty()) { + item { + Text( + "Saved Searches", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + items(savedSearches, key = { "saved-${it.id}" }) { saved -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onLoadQuery(saved.query) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + saved.label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + QuerySerializer.serialize(saved.query), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + } + IconButton(onClick = { onDeleteSaved(saved.id) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + } + + // Recent history + if (historyItems.isNotEmpty()) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Recent", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onClearHistory) { + Text("Clear", style = MaterialTheme.typography.labelSmall) + } + } + } + items(historyItems.take(10), key = { "history-${QuerySerializer.serialize(it)}" }) { query -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onLoadQuery(query) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.History, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + QuerySerializer.serialize(query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + } + } + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + } + + // Operator hints + item { + Column( + modifier = Modifier.fillMaxWidth().padding(top = if (historyItems.isEmpty() && savedSearches.isEmpty()) 32.dp else 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Search operators", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + } + } + item { SearchHint("from:npub1...", "Filter by author") } + item { SearchHint("kind:article", "Long-form content") } + item { SearchHint("since:2025-01", "After January 2025") } + item { SearchHint("#bitcoin", "Hashtag search") } + item { SearchHint("\"exact phrase\"", "Exact match") } + item { SearchHint("bitcoin OR nostr", "Either term") } + item { SearchHint("-spam", "Exclude term") } + item { SearchHint("lang:en", "Language filter") } + } +} + @Composable private fun SearchHint( - identifier: String, + example: String, description: String, ) { Row( @@ -363,7 +615,7 @@ private fun SearchHint( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - identifier, + example, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.primary, @@ -390,25 +642,10 @@ private fun SearchResultCard( .fillMaxWidth() .clickable { when (result) { - is SearchResult.UserResult -> { - onNavigateToProfile(result.pubKeyHex) - } - - is SearchResult.CachedUserResult -> { - onNavigateToProfile(result.user.pubkeyHex) - } - - is SearchResult.NoteResult -> { - onNavigateToThread(result.noteIdHex) - } - - is SearchResult.AddressResult -> { - onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") - } - - is SearchResult.HashtagResult -> { - onNavigateToHashtag(result.hashtag) - } + is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex) + is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex) + is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") + is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag) } }, colors = @@ -425,7 +662,6 @@ private fun SearchResultCard( imageVector = when (result) { is SearchResult.UserResult -> Icons.Default.Person - is SearchResult.CachedUserResult -> Icons.Default.Person is SearchResult.NoteResult -> Icons.Default.Description is SearchResult.AddressResult -> Icons.Default.Description is SearchResult.HashtagResult -> Icons.Default.Tag @@ -439,7 +675,6 @@ private fun SearchResultCard( Text( when (result) { is SearchResult.UserResult -> "User Profile" - is SearchResult.CachedUserResult -> result.user.toBestDisplayName() is SearchResult.NoteResult -> "Note" is SearchResult.AddressResult -> "Event (kind ${result.kind})" is SearchResult.HashtagResult -> "#${result.hashtag}" @@ -450,7 +685,6 @@ private fun SearchResultCard( Text( when (result) { is SearchResult.UserResult -> result.displayId - is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex() is SearchResult.NoteResult -> result.displayId is SearchResult.AddressResult -> result.displayId is SearchResult.HashtagResult -> "Search posts with this hashtag" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ca79ca553b..d4368f9846 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -51,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -68,7 +70,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscriptio import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -135,12 +139,25 @@ fun ThreadScreen( var bookmarkList by remember { mutableStateOf(null) } var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } - // Load metadata for thread authors via coordinator + // Lightbox state + var lightboxState by remember { mutableStateOf(null) } + + // Load metadata for thread authors + mentioned users via coordinator LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { if (subscriptionsCoordinator != null) { val pubkeys = mutableListOf() rootNote?.let { pubkeys.add(it.pubKey) } pubkeys.addAll(replyEvents.map { it.pubKey }) + + // Also load metadata for users mentioned in note content + val parser = UrlParser() + val allEvents = listOfNotNull(rootNote) + replyEvents + val mentionedPubkeys = + allEvents.flatMap { event -> + extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s) + } + pubkeys.addAll(mentionedPubkeys) + if (pubkeys.isNotEmpty()) { subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct()) } @@ -331,173 +348,200 @@ fun ThreadScreen( return level } - Column(modifier = Modifier.fillMaxSize()) { - // Header with back button - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(24.dp), + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // Header with back button + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Thread", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, ) } - Spacer(Modifier.width(8.dp)) - Text( - "Thread", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (rootNote == null && !rootNoteEoseReceived) { - LoadingState("Loading thread...") - } else if (rootNote == null && rootNoteEoseReceived) { - EmptyState( - title = "Note not found", - description = "This note may have been deleted or is not available from connected relays", - onRefresh = onBack, - refreshLabel = "Go back", - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - // Root note (no reply level indicator) - item(key = noteId) { - Column( - modifier = - Modifier.clickable { - // Already viewing this thread, no-op - }, - ) { - NoteCard( - note = rootNote!!.toNoteDisplayData(localCache), - onAuthorClick = onNavigateToProfile, - ) - if (account != null) { - val rootZaps = zapsByEvent[noteId] ?: emptyList() - NoteActionsRow( - event = rootNote!!, - relayManager = relayManager, + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (rootNote == null && !rootNoteEoseReceived) { + LoadingState("Loading thread...") + } else if (rootNote == null && rootNoteEoseReceived) { + EmptyState( + title = "Note not found", + description = "This note may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + // Root note (no reply level indicator) + item(key = noteId) { + Column { + NoteCard( + note = rootNote!!.toNoteDisplayData(localCache), localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = { onReply(rootNote!!) }, - onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = rootZaps.size, - zapAmountSats = rootZaps.sumOf { it.amountSats }, - zapReceipts = rootZaps, - reactionCount = reactionsByEvent[noteId] ?: 0, - replyCount = repliesByEvent[noteId] ?: 0, - repostCount = repostsByEvent[noteId] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(noteId), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) + if (account != null) { + val rootZaps = zapsByEvent[noteId] ?: emptyList() + NoteActionsRow( + event = rootNote!!, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onReply(rootNote!!) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = rootZaps.size, + zapAmountSats = rootZaps.sumOf { it.amountSats }, + zapReceipts = rootZaps, + reactionCount = reactionsByEvent[noteId] ?: 0, + replyCount = repliesByEvent[noteId] ?: 0, + repostCount = repostsByEvent[noteId] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(noteId), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, + ) + } } + HorizontalDivider(thickness = 1.dp) } - HorizontalDivider(thickness = 1.dp) - } - // Reply notes with level indicators - items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> - val level = calculateLevel(event) + // Reply notes with level indicators + items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> + val level = calculateLevel(event) - Column( - modifier = - Modifier - .drawReplyLevel( - level = level, - color = MaterialTheme.colorScheme.outlineVariant, - selected = - if (event.id == noteId) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant - }, - ).clickable { - onNavigateToThread(event.id) - }, - ) { - NoteCard( - note = event.toNoteDisplayData(localCache), - onAuthorClick = onNavigateToProfile, - ) - if (account != null) { - val eventZaps = zapsByEvent[event.id] ?: emptyList() - NoteActionsRow( - event = event, - relayManager = relayManager, + Column( + modifier = + Modifier + .drawReplyLevel( + level = level, + color = MaterialTheme.colorScheme.outlineVariant, + selected = + if (event.id == noteId) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + ).clickable { + onNavigateToThread(event.id) + }, + ) { + NoteCard( + note = event.toNoteDisplayData(localCache), localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = { onReply(event) }, - onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = eventZaps.size, - zapAmountSats = eventZaps.sumOf { it.amountSats }, - zapReceipts = eventZaps, - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) + if (account != null) { + val eventZaps = zapsByEvent[event.id] ?: emptyList() + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onReply(event) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = eventZaps.size, + zapAmountSats = eventZaps.sumOf { it.amountSats }, + zapReceipts = eventZaps, + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, + ) + } } + HorizontalDivider(thickness = 1.dp) } - HorizontalDivider(thickness = 1.dp) - } - // Empty state for no replies - if (replyEvents.isEmpty() && repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "No replies yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } - } else if (replyEvents.isEmpty() && !repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "Loading replies...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) + // Empty state for no replies + if (replyEvents.isEmpty() && repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "No replies yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } else if (replyEvents.isEmpty() && !repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "Loading replies...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } } } } + } // end Column + + // Lightbox overlay + val lb = lightboxState + if (lb != null) { + LightboxOverlay( + urls = lb.urls, + initialIndex = lb.index, + initialSeekPosition = lb.seekPosition, + initialFullscreen = lb.fullscreen, + onDismiss = { lightboxState = null }, + ) } - } + } // end Box } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index eed9c760f3..15f0a0de6b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +37,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check @@ -49,12 +54,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,12 +87,16 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay +import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -141,6 +153,11 @@ fun UserProfileScreen( var postsError by remember { mutableStateOf(null) } var retryTrigger by remember { mutableStateOf(0) } + // Tab and gallery state + var selectedTab by remember { mutableStateOf(0) } + var lightboxState by remember { mutableStateOf(null) } + val pictureEvents = remember { mutableStateListOf() } + // Follow state val followState = remember(account) { @@ -214,7 +231,7 @@ fun UserProfileScreen( latestMetadataEvent = event } } - } catch (e: Exception) { + } catch (_: Exception) { // Ignore parse errors } } @@ -300,343 +317,472 @@ fun UserProfileScreen( } } - Column(modifier = Modifier.fillMaxSize()) { - // Broadcast banner for profile updates - ProfileBroadcastBanner( - status = broadcastStatus, - onTap = { - // Clear banner on tap (could add retry logic for failed) - if (broadcastStatus is ProfileBroadcastStatus.Success || - broadcastStatus is ProfileBroadcastStatus.Failed - ) { - broadcastStatus = ProfileBroadcastStatus.Idle - } - }, - ) - - // Header with back button - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") - } - Spacer(Modifier.width(8.dp)) - Text( - "Profile", - style = MaterialTheme.typography.headlineMedium, - ) - } - - // Edit button for own profile - if (isOwnProfile && account?.isReadOnly == false) { - OutlinedButton( - onClick = { - editingDisplayName = displayName ?: "" - showEditDialog = true - }, - ) { - Icon( - Icons.Default.Edit, - contentDescription = "Edit profile", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text("Edit Profile") - } - } - - // Follow/Unfollow button for other profiles - if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { - Column(horizontalAlignment = Alignment.End) { - Button( - onClick = { - scope.launch { - val currentStatus = followState.currentStatusOrNull() - - followState.setFollowLoading() - try { - val updatedEvent = - if (currentStatus?.isFollowing == true) { - unfollowUser(pubKeyHex, account, relayManager, myContactList) - } else { - followUser(pubKeyHex, account, relayManager, myContactList) - } - - // Update both stored contact list and followState - myContactList = updatedEvent - followState.setFollowSuccess(updatedEvent, pubKeyHex) - } catch (e: Exception) { - e.printStackTrace() - followState.setFollowError(e.message ?: "Failed to update follow status", e) - } - } - }, - enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, - ) { - val state = followState.state.collectAsState().value - val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false - val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading - - when { - !contactListLoaded -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text("Loading...") - } - - isLoading -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollowing..." else "Following...") - } - - else -> { - Icon( - if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd, - contentDescription = if (isFollowing) "Unfollow" else "Follow", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollow" else "Follow") - } - } + // Subscribe to picture events (kind 20) for gallery tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + pictureEvents.clear() + SubscriptionConfig( + subId = generateSubId("pics-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(PictureEvent.KIND), + limit = 100, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is PictureEvent && pictureEvents.none { it.id == event.id }) { + pictureEvents.add(event) } - - val errorMessage = - followState.state - .collectAsState() - .value - .errorOrNull() - errorMessage?.let { error -> - Spacer(Modifier.height(4.dp)) - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } + }, + onEose = { _, _ -> }, + ) + } else { + null } + } + // Scroll state for detecting scroll direction + val listState = rememberLazyListState() + var showFloatingHeader by remember { mutableStateOf(false) } + var previousFirstVisibleItemIndex by remember { mutableStateOf(0) } + var previousFirstVisibleItemScrollOffset by remember { mutableStateOf(0) } + + // Show floating header when scrolling up and header is scrolled out of view + LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { + val currentIndex = listState.firstVisibleItemIndex + val currentOffset = listState.firstVisibleItemScrollOffset + val scrollingUp = + currentIndex < previousFirstVisibleItemIndex || + (currentIndex == previousFirstVisibleItemIndex && currentOffset < previousFirstVisibleItemScrollOffset) + + // Header items are indices 0-3, so if first visible >= 3, header is out of view + showFloatingHeader = scrollingUp && currentIndex >= 3 + if (!scrollingUp && currentIndex < 3) showFloatingHeader = false + + previousFirstVisibleItemIndex = currentIndex + previousFirstVisibleItemScrollOffset = currentOffset + } + + Box(modifier = Modifier.fillMaxSize()) { if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") } else { - // Profile card - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), ) { - Column(modifier = Modifier.padding(16.dp)) { + // Broadcast banner + item(key = "broadcast") { + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + } + + // Header with back button + item(key = "header") { Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - // Profile picture with robohash fallback - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 56.dp, - contentDescription = "Profile picture", - ) - - Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Spacer(Modifier.width(8.dp)) Text( - displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + "Profile", + style = MaterialTheme.typography.headlineMedium, ) - Spacer(Modifier.height(4.dp)) - val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() - var copied by remember { mutableStateOf(false) } + } - // Reset copied state after delay - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false + // Edit button for own profile + if (isOwnProfile && account.isReadOnly == false) { + OutlinedButton( + onClick = { + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Edit Profile") + } + } + + // Follow/Unfollow button for other profiles + if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { + Column(horizontalAlignment = Alignment.End) { + Button( + onClick = { + scope.launch { + val currentStatus = followState.currentStatusOrNull() + + followState.setFollowLoading() + try { + val updatedEvent = + if (currentStatus?.isFollowing == true) { + unfollowUser(pubKeyHex, account, relayManager, myContactList) + } else { + followUser(pubKeyHex, account, relayManager, myContactList) + } + + // Update both stored contact list and followState + myContactList = updatedEvent + followState.setFollowSuccess(updatedEvent, pubKeyHex) + } catch (e: Exception) { + e.printStackTrace() + followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + } + }, + enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, + ) { + val state = followState.state.collectAsState().value + val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false + val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading + + when { + !contactListLoaded -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Loading...") + } + + isLoading -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollowing..." else "Following...") + } + + else -> { + Icon( + if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd, + contentDescription = if (isFollowing) "Unfollow" else "Follow", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollow" else "Follow") + } + } + } + + val errorMessage = + followState.state + .collectAsState() + .value + .errorOrNull() + errorMessage?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + + // Profile card + item(key = "profile-card") { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 56.dp, + contentDescription = "Profile picture", + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (npub != null) { + IconButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(npub), null) + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + if (copied) Icons.Default.Check else Icons.Default.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy npub", + modifier = Modifier.size(14.dp), + tint = + if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + if (about != null) { + Spacer(Modifier.height(12.dp)) Text( - (npub?.take(32) ?: pubKeyHex.take(32)) + "...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + about!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) - if (npub != null) { - IconButton( - onClick = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(npub), null) - copied = true - }, - modifier = Modifier.size(20.dp), + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column { + Text( + "$followersCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Followers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column { + Text( + "$followingCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Following", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + // Tabs + item(key = "tabs") { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text("Notes", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text("Gallery", modifier = Modifier.padding(12.dp)) + } + } + } + + // Tab content + when (selectedTab) { + 0 -> { + when { + postsError != null -> { + item(key = "error") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, ) { - Icon( - if (copied) Icons.Default.Check else Icons.Default.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy npub", - modifier = Modifier.size(14.dp), - tint = - if (copied) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Failed to load posts", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + postsError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } + } + } + } + + postsLoading -> { + item(key = "loading") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading posts...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + events.isEmpty() -> { + item(key = "empty") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No posts yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } - } - } - if (about != null) { - Spacer(Modifier.height(12.dp)) - Text( - about!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column { - Text( - "$followersCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Followers", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Column { - Text( - "$followingCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Following", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - Spacer(Modifier.height(16.dp)) - - // User's posts - Text( - "Posts", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - - when { - postsError != null -> { - // Error state with retry - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "Failed to load posts", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(8.dp)) - Text( - postsError!!, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { retryTrigger++ }) { - Text("Retry") + else -> { + items(events.distinctBy { it.id }, key = { it.id }) { event -> + FeedNoteCard( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } } } } - } - postsLoading -> { - // Loading state - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - androidx.compose.material3.CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - Text( - "Loading posts...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - events.isEmpty() -> { - // Empty state (loaded but no posts) - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No posts yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - else -> { - // Posts loaded successfully - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, + 1 -> { + item(key = "gallery") { + GalleryTab( + pictureEvents = pictureEvents, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + modifier = Modifier.fillParentMaxHeight(), ) } } } } } + + // Floating header — appears on scroll up when profile header is out of view + AnimatedVisibility( + visible = showFloatingHeader, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Spacer(Modifier.width(8.dp)) + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 28.dp, + contentDescription = "Profile picture", + ) + Spacer(Modifier.width(8.dp)) + Text( + displayName ?: pubKeyHex.take(12) + "...", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + } + } + + // Lightbox overlay + lightboxState?.let { state -> + LightboxOverlay( + urls = state.urls, + initialIndex = state.index, + initialSeekPosition = state.seekPosition, + initialFullscreen = state.fullscreen, + onDismiss = { lightboxState = null }, + ) } // Edit Profile Dialog diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt index d8c7c16321..bae3732b17 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -41,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.login_hide_key diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index 67d1bcdf76..ea6c001135 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -54,6 +53,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt index f4a3c432c3..573d035861 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -35,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt new file mode 100644 index 0000000000..8c9993d671 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Forward +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.EncryptedMediaService +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import org.jetbrains.skia.Image as SkiaImage + +@Composable +fun ChatFileAttachment( + event: ChatMessageEncryptedFileHeaderEvent, + onForward: ((ChatMessageEncryptedFileHeaderEvent) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val url = event.url() + val mimeType = event.mimeType() + val keyBytes = event.key() + val nonce = event.nonce() + val isImage = mimeType?.startsWith("image/") == true + val scope = rememberCoroutineScope() + + var decryptedImage by remember { mutableStateOf(null) } + var decryptedBytes by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(false) } + + // Auto-decrypt images + LaunchedEffect(url) { + if (keyBytes != null && nonce != null && url != null) { + isLoading = true + try { + val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce) + decryptedBytes = bytes + if (isImage) { + withContext(Dispatchers.Default) { + val skImage = SkiaImage.makeFromEncoded(bytes) + decryptedImage = skImage.toComposeImageBitmap() + } + } + } catch (e: Exception) { + error = e.message + } finally { + isLoading = false + } + } + } + + Box { + Card( + modifier = + modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + // Right-click detection handled via awaiting press + // Context menu is shown on secondary button via the other modifier + }, + ) + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { + showContextMenu = true + } + } + } + }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column(modifier = Modifier.padding(8.dp)) { + // Encryption indicator + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Lock, + contentDescription = "Encrypted", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + Text( + "Encrypted file", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(4.dp)) + + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).align(Alignment.CenterHorizontally), + ) + } + + decryptedImage != null -> { + androidx.compose.foundation.Image( + bitmap = decryptedImage!!, + contentDescription = "Encrypted image", + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.FillWidth, + ) + } + + error != null -> { + Text( + "Failed to decrypt: $error", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + else -> { + // Non-image file + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.InsertDriveFile, + contentDescription = "File", + modifier = Modifier.size(32.dp), + ) + Spacer(Modifier.width(8.dp)) + Column { + Text( + mimeType ?: "Unknown file", + style = MaterialTheme.typography.bodySmall, + ) + event.size()?.let { size -> + Text( + "${size / 1024}KB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + + // Right-click context menu + DropdownMenu( + expanded = showContextMenu, + onDismissRequest = { showContextMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Save to disk") }, + leadingIcon = { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + }, + enabled = decryptedBytes != null, + onClick = { + showContextMenu = false + scope.launch { + saveDecryptedFile(decryptedBytes!!, mimeType, event.hash()) + } + }, + ) + if (onForward != null) { + DropdownMenuItem( + text = { Text("Forward") }, + leadingIcon = { + Icon(Icons.Default.Forward, contentDescription = null, modifier = Modifier.size(18.dp)) + }, + onClick = { + showContextMenu = false + onForward(event) + }, + ) + } + } + } +} + +/** + * Save decrypted bytes to disk via a native save dialog. + */ +private suspend fun saveDecryptedFile( + bytes: ByteArray, + mimeType: String?, + hash: String?, +) { + val extension = mimeTypeToExtension(mimeType) + val suggestedName = "${hash?.take(12) ?: "file"}.$extension" + + val file = + withContext(Dispatchers.Main) { + val dialog = + FileDialog(null as Frame?, "Save Decrypted File", FileDialog.SAVE).apply { + this.file = suggestedName + } + dialog.isVisible = true + val dir = dialog.directory ?: return@withContext null + File(dir, dialog.file ?: return@withContext null) + } ?: return + + withContext(Dispatchers.IO) { + file.writeBytes(bytes) + } +} + +private fun mimeTypeToExtension(mimeType: String?): String = + when (mimeType) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + "image/svg+xml" -> "svg" + "video/mp4" -> "mp4" + "video/webm" -> "webm" + "video/quicktime" -> "mov" + "audio/mpeg" -> "mp3" + "audio/ogg" -> "ogg" + "audio/wav" -> "wav" + "audio/flac" -> "flac" + else -> "bin" + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 6c936d8859..da37450021 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui.chats +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,7 +39,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.outlined.AddReaction @@ -46,6 +50,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -53,6 +59,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -60,6 +67,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed @@ -88,14 +97,28 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator +import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker +import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import kotlinx.coroutines.launch +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DnDConstants +import java.awt.dnd.DropTargetDropEvent +import java.io.File private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +private val MEDIA_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") + /** * Right panel of the DM split-pane layout (flexible width). * @@ -111,6 +134,7 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") * @param messageState ChatNewMessageState for composition * @param onNavigateToProfile Called when user clicks on a profile */ +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ChatPane( roomKey: ChatroomKey, @@ -120,13 +144,56 @@ fun ChatPane( messageState: ChatNewMessageState, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, + onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() val feedState by feedViewModel.feedState.feedContent.collectAsState() val messageText by messageState.message.collectAsState() - val isNip17 by messageState.nip17.collectAsState() - val requiresNip17 by messageState.requiresNip17.collectAsState() + val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState() + + // File attachment state + val attachedFiles = remember { mutableStateListOf() } + var isUploading by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + // Helper: attach files + fun attachFiles(files: List) { + val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS } + if (mediaFiles.isEmpty()) return + attachedFiles.addAll(mediaFiles) + } + + // Drag-and-drop target for file attachments (NIP-17 only) + var isDragOver by remember { mutableStateOf(false) } + val dropTarget = + remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + isDragOver = false + val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false + dropEvent.acceptDrop(DnDConstants.ACTION_COPY) + val transferable = dropEvent.transferable + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + @Suppress("UNCHECKED_CAST") + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + attachFiles(files) + dropEvent.dropComplete(true) + return true + } + dropEvent.dropComplete(false) + return false + } + + override fun onStarted(event: DragAndDropEvent) { + isDragOver = true + } + + override fun onEnded(event: DragAndDropEvent) { + isDragOver = false + } + } + } // Resolve users for the header val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } @@ -137,110 +204,184 @@ fun ChatPane( messageState.load(roomKey) } - Column(modifier = modifier.fillMaxSize()) { - // Header - if (isGroup) { - GroupChatroomHeader( - users = users, - onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, - ) - } else { - users.firstOrNull()?.let { user -> - ChatroomHeader( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) - } ?: run { - // Fallback header with raw pubkey - Text( - text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(10.dp), - ) - } - } - - HorizontalDivider() - - // Broadcast status banner - DmBroadcastBanner(status = dmBroadcastStatus) - - // Message list - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - when (feedState) { - is FeedState.Loading -> { - LoadingState("Loading messages...") - } - - is FeedState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - "No messages yet. Send the first one!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is FeedState.Loaded -> { - val loaded = feedState as FeedState.Loaded - val loadedState by loaded.feed.collectAsState() - val messages = loadedState.list - - MessageList( - messages = messages, - account = account, - cacheProvider = cacheProvider, - onAuthorClick = onNavigateToProfile, - onReaction = { note, emoji -> - scope.launch { - try { - sendWrappedReaction(note, emoji, roomKey, account) - } catch (e: Exception) { - println("Failed to send reaction: ${e.message}") - } - } + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .dragAndDropTarget( + shouldStartDragAndDrop = { true }, + target = dropTarget, + ).then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) + } else { + Modifier }, - ) - } - - is FeedState.FeedError -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + ), + ) { + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (onBack != null) { + IconButton( + onClick = onBack, + modifier = Modifier.size(40.dp), ) { - Text( - "Error loading messages", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to conversations", ) } } - } - } - HorizontalDivider() - - // Message input - MessageInput( - messageText = messageText.text, - isNip17 = isNip17, - requiresNip17 = requiresNip17, - canSend = messageState.canSend, - onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, - onToggleNip17 = { messageState.toggleNip17() }, - onSend = { - scope.launch { - if (messageState.send()) { - messageState.clear() + Box(modifier = Modifier.weight(1f)) { + if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) + } else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + // Fallback header with raw pubkey + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } } } - }, + } + + HorizontalDivider() + + // Broadcast status banner + DmBroadcastBanner(status = dmBroadcastStatus) + + // Message list + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when (feedState) { + is FeedState.Loading -> { + LoadingState("Loading messages...") + } + + is FeedState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "No messages yet. Send the first one!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is FeedState.Loaded -> { + val loaded = feedState as FeedState.Loaded + val loadedState by loaded.feed.collectAsState() + val messages = loadedState.list + + MessageList( + messages = messages, + account = account, + cacheProvider = cacheProvider, + onAuthorClick = onNavigateToProfile, + onReaction = { note, emoji -> + scope.launch { + try { + sendWrappedReaction(note, emoji, roomKey, account) + } catch (e: Exception) { + println("Failed to send reaction: ${e.message}") + } + } + }, + ) + } + + is FeedState.FeedError -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "Error loading messages", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + HorizontalDivider() + + // File attachment row (only when NIP-17 and files attached) + if (attachedFiles.isNotEmpty()) { + MediaAttachmentRow( + attachedFiles = attachedFiles, + isUploading = isUploading, + onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, + onPaste = {}, + onRemove = { attachedFiles.remove(it) }, + ) + } + + // Message input + MessageInput( + messageText = messageText.text, + recipientsMissingRelays = recipientsMissingRelays, + canSend = messageState.canSend || attachedFiles.isNotEmpty(), + isUploading = isUploading, + hasAttachments = attachedFiles.isNotEmpty(), + onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, + onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, + onSend = { + scope.launch { + if (attachedFiles.isNotEmpty()) { + isUploading = true + try { + sendEncryptedFiles( + files = attachedFiles.toList(), + roomKey = roomKey, + account = account, + cacheProvider = cacheProvider, + ) + attachedFiles.clear() + } catch (e: Exception) { + // Keep files in attachment row for retry + println("Encrypted file send failed: ${e.message}") + } finally { + isUploading = false + } + } + // Also send text message if present + if (messageState.canSend) { + if (messageState.send()) { + messageState.clear() + } + } else if (attachedFiles.isEmpty()) { + messageState.clear() + } + } + }, + ) + } // end Column + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 80.dp), ) - } + } // end Box } /** @@ -373,6 +514,29 @@ private fun MessageWithReactions( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + // Encryption badge + when (event) { + is PrivateDmEvent -> { + Icon( + Icons.Default.LockOpen, + contentDescription = "NIP-04 (legacy)", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + } + + is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent, + is ChatMessageEncryptedFileHeaderEvent, + -> { + Icon( + Icons.Default.Lock, + contentDescription = "NIP-17 (encrypted)", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + ) + } + } + // Timestamp note.createdAt()?.let { timestamp -> Text( @@ -436,12 +600,20 @@ private fun MessageWithReactions( } }, ) { _ -> - SelectionContainer { - Text( - text = decryptedContent ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + when (note.event) { + is ChatMessageEncryptedFileHeaderEvent -> { + ChatFileAttachment(event = note.event as ChatMessageEncryptedFileHeaderEvent) + } + + else -> { + SelectionContainer { + Text( + text = decryptedContent ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } } @@ -517,11 +689,12 @@ private fun ReactionBar(onReaction: (String) -> Unit) { @Composable private fun MessageInput( messageText: String, - isNip17: Boolean, - requiresNip17: Boolean, + recipientsMissingRelays: Boolean, canSend: Boolean, + isUploading: Boolean = false, + hasAttachments: Boolean = false, onMessageChange: (String) -> Unit, - onToggleNip17: () -> Unit, + onAttach: () -> Unit = {}, onSend: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { @@ -530,6 +703,24 @@ private fun MessageInput( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + // Paperclip attach button — always visible, auto-switches to NIP-17 on send + IconButton( + onClick = onAttach, + enabled = !isUploading, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach file", + tint = + if (isUploading) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.primary + }, + ) + } + OutlinedTextField( value = messageText, onValueChange = onMessageChange, @@ -555,14 +746,14 @@ private fun MessageInput( IconButton( onClick = onSend, - enabled = canSend, + enabled = canSend && !isUploading, modifier = Modifier.size(40.dp), ) { Icon( Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = - if (canSend) { + if (canSend && !isUploading) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) @@ -571,48 +762,72 @@ private fun MessageInput( } } - // NIP-17 indicator + // NIP-17 indicator / recipient warning Spacer(Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp), ) { - IconButton( - onClick = onToggleNip17, - enabled = !requiresNip17, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (isNip17) Icons.Default.Lock else Icons.Default.LockOpen, - contentDescription = if (isNip17) "NIP-17 (encrypted)" else "NIP-04 (legacy)", - modifier = Modifier.size(16.dp), - tint = - if (isNip17) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - }, - ) - } + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "NIP-17 (encrypted)", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) Spacer(Modifier.width(4.dp)) Text( - text = if (isNip17) "NIP-17" else "NIP-04", + text = "NIP-17", style = MaterialTheme.typography.labelSmall, - color = - if (isNip17) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - }, + color = MaterialTheme.colorScheme.primary, ) - if (requiresNip17) { - Spacer(Modifier.width(4.dp)) + if (recipientsMissingRelays) { + Spacer(Modifier.width(8.dp)) Text( - text = "(required for groups)", + text = "Recipient has no DM relay list — messages cannot be delivered", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.error, ) } } } } + +/** + * Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15) + * wrapped in GiftWrap for each recipient. + */ +private suspend fun sendEncryptedFiles( + files: List, + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, +) { + val orchestrator = DesktopUploadOrchestrator() + val server = DesktopPreferences.preferredBlossomServer + val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() } + + for (file in files) { + val cipher = AESGCM() + val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer) + val url = result.blossom.url ?: continue + + val template = + ChatMessageEncryptedFileHeaderEvent.build( + to = recipients, + url = url, + cipher = cipher, + mimeType = result.metadata.mimeType, + hash = result.encryptedHash, + size = result.encryptedSize, + dimension = + if (result.metadata.width != null && result.metadata.height != null) { + DimensionTag(result.metadata.width, result.metadata.height) + } else { + null + }, + blurhash = result.metadata.blurhash, + originalHash = result.metadata.sha256, + ) + account.sendNip17EncryptedFile(template) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index ec2c9d9e73..8f6bb36dd4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -119,7 +119,6 @@ fun ConversationListPane( Column( modifier = modifier - .width(280.dp) .fillMaxHeight() .focusRequester(focusRequester) .focusable() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 599ee6929d..fa8da9e2ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -24,8 +24,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Icon @@ -59,18 +61,19 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.CoroutineScope private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") /** - * Desktop DM screen with split-pane layout. + * Desktop DM screen with two layout modes: * - * Left pane (280dp): ConversationListPane with Known/New tabs - * Right pane (flex): ChatPane with messages + input, or empty state + * - **Split mode** (compactMode = false): Side-by-side with conversation list (280dp) + chat pane. + * Used in single-pane layout where there's plenty of horizontal space. * - * @param account The user's IAccount for DM operations - * @param cacheProvider ICacheProvider for user/note lookups - * @param onNavigateToProfile Called when navigating to a user profile + * - **Compact mode** (compactMode = true): Stacked navigation — full-width contact list OR + * full-width chat. Used in multi-deck columns where width is limited. */ @Composable fun DesktopMessagesScreen( @@ -78,6 +81,7 @@ fun DesktopMessagesScreen( cacheProvider: ICacheProvider, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, + compactMode: Boolean = false, onNavigateToProfile: (String) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -89,51 +93,159 @@ fun DesktopMessagesScreen( val listFocusRequester = remember { FocusRequester() } var showNewDmDialog by remember { mutableStateOf(false) } - Row( - modifier = - Modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + // Shared keyboard shortcuts + val keyHandler = + Modifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed - when { - // Escape -> deselect conversation - event.key == Key.Escape -> { - listState.clearSelection() - true - } + when { + event.key == Key.Escape -> { + listState.clearSelection() + true + } - // Cmd+Shift+N / Ctrl+Shift+N -> new DM - event.key == Key.N && isModifier && event.isShiftPressed -> { - showNewDmDialog = true - true - } + event.key == Key.N && isModifier && event.isShiftPressed -> { + showNewDmDialog = true + true + } - else -> { - false - } - } - }, - ) { - // Left pane: conversation list (280dp fixed) + else -> { + false + } + } + } + + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } + + if (showNewDmDialog) { + NewDmDialog( + cacheProvider = cacheProvider, + relayManager = relayManager, + localCache = localCache, + onUserSelected = { roomKey -> + listState.selectRoom(roomKey) + showNewDmDialog = false + }, + onDismiss = { showNewDmDialog = false }, + ) + } +} + +/** + * Compact (stacked) layout for deck columns. + * Shows either the contact list OR the chat, never both. + */ +@Composable +private fun CompactMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Box(modifier = Modifier.fillMaxSize().then(keyHandler)) { + val currentRoom = selectedRoom + if (currentRoom != null) { + val feedViewModel = + remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = + remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + val broadcastStatus = + if (account is DesktopIAccount) { + account.dmSendTracker.status + .collectAsState() + .value + } else { + DmBroadcastStatus.Idle + } + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + dmBroadcastStatus = broadcastStatus, + onNavigateToProfile = onNavigateToProfile, + onBack = { listState.clearSelection() }, + ) + } else { + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, + focusRequester = listFocusRequester, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** + * Split (side-by-side) layout for single-pane mode. + * Contact list (280dp) + divider + chat pane (flex). + */ +@Composable +private fun SplitMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Row(modifier = Modifier.fillMaxSize().then(keyHandler)) { ConversationListPane( state = listState, selectedRoom = selectedRoom, - onConversationSelected = { roomKey -> - listState.selectRoom(roomKey) - }, - onNewConversation = { showNewDmDialog = true }, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, focusRequester = listFocusRequester, + modifier = Modifier.width(280.dp), ) VerticalDivider(modifier = Modifier.fillMaxHeight()) - // Right pane: chat or empty state (flex) Box(modifier = Modifier.weight(1f).fillMaxHeight()) { val currentRoom = selectedRoom if (currentRoom != null) { - // Create feed VM and message state scoped to the selected room val feedViewModel = remember(currentRoom) { ChatroomFeedViewModel(currentRoom, account, cacheProvider) @@ -142,7 +254,6 @@ fun DesktopMessagesScreen( remember(currentRoom) { ChatNewMessageState(account, cacheProvider, scope) } - val broadcastStatus = if (account is DesktopIAccount) { account.dmSendTracker.status @@ -162,28 +273,14 @@ fun DesktopMessagesScreen( onNavigateToProfile = onNavigateToProfile, ) } else { - // Empty state EmptyConversationState() } } } - - if (showNewDmDialog) { - NewDmDialog( - cacheProvider = cacheProvider, - relayManager = relayManager, - localCache = localCache, - onUserSelected = { roomKey -> - listState.selectRoom(roomKey) - showNewDmDialog = false - }, - onDismiss = { showNewDmDialog = false }, - ) - } } /** - * Shown when no conversation is selected in the right pane. + * Shown when no conversation is selected in the split layout right pane. */ @Composable private fun EmptyConversationState() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index ce90b7ef6c..4e3ff30160 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -53,7 +55,6 @@ import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen -import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -88,6 +89,7 @@ fun DeckColumnContainer( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, @@ -120,37 +122,44 @@ fun DeckColumnContainer( Box( modifier = Modifier.fillMaxSize().padding(12.dp), ) { + // Always keep RootContent composed so state (e.g. search results) survives navigation + RootContent( + columnType = column.type, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + compactMode = true, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) if (currentOverlay != null) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onBack = { navState.pop() }, - ) - } else { - RootContent( - columnType = column.type, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - ) + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } } } } @@ -163,9 +172,11 @@ internal fun RootContent( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, + compactMode: Boolean = false, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, @@ -195,19 +206,12 @@ internal fun RootContent( } DeckColumnType.Messages -> { - val dmSendTracker = - remember(relayManager) { - DmSendTracker(relayManager.client) - } - val iAccount = - remember(account, localCache, relayManager, dmSendTracker) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope) - } DesktopMessagesScreen( account = iAccount, cacheProvider = localCache, relayManager = relayManager, localCache = localCache, + compactMode = compactMode, onNavigateToProfile = onNavigateToProfile, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 1df6bf135c..519d932178 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -58,6 +58,7 @@ fun DeckLayout( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, @@ -109,6 +110,7 @@ fun DeckLayout( localCache = localCache, accountManager = accountManager, account = account, + iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, appScope = appScope, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index e58734588c..ea458daa2a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -20,20 +20,16 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Email @@ -43,12 +39,11 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -57,7 +52,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow @@ -71,6 +65,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope @@ -99,6 +94,7 @@ fun SinglePaneLayout( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, @@ -114,132 +110,96 @@ fun SinglePaneLayout( val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() + val isImmersive by LocalIsImmersiveFullscreen.current + Row(modifier = modifier.fillMaxSize()) { - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - navItems.forEach { item -> - NavigationRailItem( - selected = currentColumnType == item.type && navStack.isEmpty(), - onClick = { - currentColumnType = item.type - navState.clear() - }, - icon = { - Icon( - item.icon, - contentDescription = item.label, - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - item.label, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, + if (!isImmersive) { + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + navItems.forEach { item -> + NavigationRailItem( + selected = currentColumnType == item.type && navStack.isEmpty(), + onClick = { + currentColumnType = item.type + navState.clear() + }, + icon = { + Icon( + item.icon, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + item.label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + + Spacer(Modifier.weight(1f)) + + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.padding(bottom = 12.dp), ) } - - Spacer(Modifier.weight(1f)) - - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - modifier = Modifier.padding(bottom = 12.dp), - ) } - VerticalDivider() + if (!isImmersive) { + VerticalDivider() + } Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - // Show header with back button when navigated into overlay - if (navStack.isNotEmpty()) { - SinglePaneHeader( - title = - when (currentOverlay) { - is DesktopScreen.UserProfile -> "Profile" - is DesktopScreen.Thread -> "Thread" - else -> currentColumnType.title() - }, - onBack = { navState.pop() }, - ) - HorizontalDivider() - } - Box( - modifier = Modifier.fillMaxSize().padding(12.dp), + modifier = Modifier.fillMaxSize().padding(if (isImmersive) 0.dp else 12.dp), ) { + // Always keep RootContent composed so state (e.g. search results) survives navigation + RootContent( + columnType = currentColumnType, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) if (currentOverlay != null) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onBack = { navState.pop() }, - ) - } else { - RootContent( - columnType = currentColumnType, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - ) + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } } } } } } - -@Composable -private fun SinglePaneHeader( - title: String, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .height(40.dp) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt new file mode 100644 index 0000000000..1872b7b04e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Codec +import org.jetbrains.skia.Data +import java.util.concurrent.TimeUnit + +private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF +private const val MIN_FRAME_DURATION_MS = 20 + +private val gifHttpClient: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() +} + +fun isAnimatedGifUrl(url: String): Boolean { + val lower = url.lowercase() + return lower.endsWith(".gif") || + lower.contains(".gif?") || + lower.contains(".gif#") +} + +private class GifFrames( + val frames: List, + val durations: List, +) + +@Composable +fun AnimatedGifImage( + url: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) { + var gifFrames by remember(url) { mutableStateOf(null) } + var currentFrame by remember(url) { mutableIntStateOf(0) } + var loadFailed by remember(url) { mutableStateOf(false) } + + LaunchedEffect(url) { + currentFrame = 0 + loadFailed = false + gifFrames = withContext(Dispatchers.IO) { decodeGifFrames(url) } + if (gifFrames == null) loadFailed = true + } + + // Reset frame index when frames change + DisposableEffect(url) { + onDispose { currentFrame = 0 } + } + + val data = gifFrames + when { + data != null && data.frames.size > 1 -> { + LaunchedEffect(data) { + while (isActive) { + val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS) + delay(duration.toLong()) + currentFrame = (currentFrame + 1) % data.frames.size + } + } + + Image( + bitmap = data.frames[currentFrame], + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + data != null -> { + Image( + bitmap = data.frames[0], + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + loadFailed -> { + AsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + else -> { + Box(modifier) + } + } +} + +private fun decodeGifFrames(url: String): GifFrames? = + try { + val request = Request.Builder().url(url).build() + val response = gifHttpClient.newCall(request).execute() + val bytes = response.body.bytes() + + val skData = Data.makeFromBytes(bytes) + val codec = Codec.makeFromData(skData) + val frameCount = codec.frameCount + if (frameCount <= 0) return null + + val frameBitmapSize = codec.width.toLong() * codec.height * 4 + val totalMemory = frameBitmapSize * frameCount + val decodableFrames = + if (totalMemory > MAX_BITMAP_MEMORY) { + // Only decode first frame for huge GIFs + 1 + } else { + frameCount + } + + val frameInfos = codec.framesInfo + val frames = ArrayList(decodableFrames) + val durations = ArrayList(decodableFrames) + + for (i in 0 until decodableFrames) { + val bitmap = Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, i) + bitmap.setImmutable() + frames.add(bitmap.asComposeImageBitmap()) + durations.add(if (frameInfos.size > i) frameInfos[i].duration else 100) + } + + GifFrames(frames, durations) + } catch (e: Exception) { + println("AnimatedGif: failed to load $url — ${e.message}") + null + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt new file mode 100644 index 0000000000..c02f4167be --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + +@Composable +fun AudioPlayer( + url: String, + modifier: Modifier = Modifier, +) { + val audioState by GlobalMediaPlayer.audioState.collectAsState() + val isActiveAudio = audioState.url == url + + val isPlaying = if (isActiveAudio) audioState.isPlaying else false + val position = if (isActiveAudio) audioState.position else 0f + val duration = if (isActiveAudio) audioState.duration else 0L + val currentTime = if (isActiveAudio) audioState.currentTime else 0L + + Row( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + IconButton( + onClick = { + if (isActiveAudio) { + GlobalMediaPlayer.toggleAudioPlayPause() + } else { + GlobalMediaPlayer.playAudio(url) + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = formatTime(currentTime), + style = MaterialTheme.typography.labelSmall, + ) + + Slider( + value = position, + onValueChange = { GlobalMediaPlayer.seekAudio(it) }, + modifier = Modifier.weight(1f), + ) + + Text( + text = formatTime(duration), + style = MaterialTheme.typography.labelSmall, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt new file mode 100644 index 0000000000..2d398d54ef --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import java.awt.Image +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +object ClipboardPasteHandler { + fun getClipboardFiles(): List { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + return try { + when { + clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor) -> { + @Suppress("UNCHECKED_CAST") + (clipboard.getData(DataFlavor.javaFileListFlavor) as? List) ?: emptyList() + } + + clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor) -> { + val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return emptyList() + val buffered = toBufferedImage(image) + val tempFile = File.createTempFile("clipboard_", ".png") + tempFile.deleteOnExit() + ImageIO.write(buffered, "png", tempFile) + listOf(tempFile) + } + + else -> { + emptyList() + } + } + } catch (_: Exception) { + emptyList() + } + } + + private fun toBufferedImage(image: Image): BufferedImage { + if (image is BufferedImage) return image + val buffered = BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB) + buffered.graphics.drawImage(image, 0, 0, null) + return buffered + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt new file mode 100644 index 0000000000..428a9983af --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.io.FilenameFilter + +object DesktopFilePicker { + private val mediaExtensions = + setOf( + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "avif", + "mp4", + "webm", + "mov", + "mp3", + "ogg", + "wav", + "flac", + ) + + fun pickMediaFiles(parent: Frame? = null): List { + val dialog = + FileDialog(parent, "Select Media", FileDialog.LOAD).apply { + isMultipleMode = true + filenameFilter = + FilenameFilter { _, name -> + val ext = name.substringAfterLast('.', "").lowercase() + ext in mediaExtensions + } + } + dialog.isVisible = true + return dialog.files?.toList() ?: emptyList() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt new file mode 100644 index 0000000000..ae3b082bcb --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache +import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool +import kotlinx.coroutines.delay + +@Composable +fun DesktopVideoPlayer( + url: String, + modifier: Modifier = Modifier, + autoPlay: Boolean = false, + initialSeekPosition: Float = 0f, + onFullscreen: ((Float) -> Unit)? = null, + viewMode: ViewMode = ViewMode.DEFAULT, + onViewModeChange: ((ViewMode) -> Unit)? = null, + trailingControls: @Composable (() -> Unit)? = null, +) { + // Check if this URL is the active video + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + val isActiveVideo = videoState.url == url + + // Thumbnail for inactive videos + var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) } + var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } + + // Load thumbnail when not active + LaunchedEffect(url, isActiveVideo) { + if (!isActiveVideo && thumbnail == null) { + for (attempt in 1..3) { + val result = VideoThumbnailCache.getThumbnail(url) + if (result != null) { + thumbnail = result + break + } + if (attempt < 3) delay(2000L * attempt) + } + } + } + + // Auto-play on mount if requested + LaunchedEffect(url, autoPlay) { + if (autoPlay) { + GlobalMediaPlayer.playVideo(url, initialSeekPosition) + } + } + + // Sync aspect ratio from global state when active + if (isActiveVideo && videoState.aspectRatio != 16f / 9f) { + aspectRatio = videoState.aspectRatio + } + + if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) { + VlcNotAvailableMessage(url, modifier) + return + } + + BoxWithConstraints(modifier = modifier) { + val desiredHeight = maxWidth / aspectRatio + val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(constrainedHeight) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail + displayBitmap?.let { bitmap -> + Image( + bitmap = bitmap, + contentDescription = "Video", + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit, + ) + } + + VideoControls( + isPlaying = if (isActiveVideo) videoState.isPlaying else false, + isBuffering = if (isActiveVideo) videoState.isBuffering else false, + position = if (isActiveVideo) videoState.position else 0f, + duration = if (isActiveVideo) videoState.duration else 0L, + currentTime = if (isActiveVideo) videoState.currentTime else 0L, + volume = if (isActiveVideo) videoState.volume else 100, + isMuted = if (isActiveVideo) videoState.isMuted else false, + viewMode = viewMode, + onPlayPause = { + if (isActiveVideo) { + GlobalMediaPlayer.toggleVideoPlayPause() + } else { + GlobalMediaPlayer.playVideo(url, initialSeekPosition) + } + }, + onSeek = { pos -> + if (isActiveVideo) { + GlobalMediaPlayer.seekVideo(pos) + } + }, + onVolumeChange = { vol -> + GlobalMediaPlayer.setVideoVolume(vol) + }, + onMuteToggle = { + GlobalMediaPlayer.toggleVideoMute() + }, + onFullscreen = + if (onFullscreen != null) { + { + val pos = if (isActiveVideo) videoState.position else 0f + onFullscreen(pos) + } + } else { + null + }, + onViewModeChange = onViewModeChange, + trailingControls = trailingControls, + ) + } + } +} + +@Composable +private fun VlcNotAvailableMessage( + url: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Video: $url\nInstall VLC to play videos: https://www.videolan.org/vlc/", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt new file mode 100644 index 0000000000..8cd466742c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import java.awt.GraphicsEnvironment +import java.awt.Window + +object FullscreenHelper { + fun enterFullscreen(window: Window) { + val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice + if (device.isFullScreenSupported) { + device.fullScreenWindow = window + } + } + + fun exitFullscreen() { + val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice + device.fullScreenWindow = null + } + + fun isFullscreen(): Boolean { + val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice + return device.fullScreenWindow != null + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt new file mode 100644 index 0000000000..18a59732f8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + +@Composable +fun GlobalFullscreenOverlay() { + val isFullscreen by GlobalMediaPlayer.isFullscreen.collectAsState() + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + + if (!isFullscreen || videoState.url == null) return + + val focusRequester = remember { FocusRequester() } + val awtWindow = LocalAwtWindow.current + val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current + + // Enter native fullscreen + LaunchedEffect(isFullscreen) { + if (isFullscreen) { + isImmersiveFullscreen.value = true + awtWindow?.let { FullscreenHelper.enterFullscreen(it) } + focusRequester.requestFocus() + } + } + + // Restore on exit + DisposableEffect(Unit) { + onDispose { + isImmersiveFullscreen.value = false + if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen() + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Escape -> { + GlobalMediaPlayer.exitFullscreen() + true + } + + Key.F -> { + GlobalMediaPlayer.exitFullscreen() + true + } + + Key.Spacebar -> { + GlobalMediaPlayer.toggleVideoPlayPause() + true + } + + else -> { + false + } + } + }, + contentAlignment = Alignment.Center, + ) { + // Video frame + videoFrame?.let { frame -> + Image( + bitmap = frame, + contentDescription = "Video fullscreen", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + + // Video controls overlay + VideoControls( + isPlaying = videoState.isPlaying, + isBuffering = videoState.isBuffering, + position = videoState.position, + duration = videoState.duration, + currentTime = videoState.currentTime, + volume = videoState.volume, + isMuted = videoState.isMuted, + viewMode = ViewMode.FULLSCREEN, + onPlayPause = { GlobalMediaPlayer.toggleVideoPlayPause() }, + onSeek = { GlobalMediaPlayer.seekVideo(it) }, + onVolumeChange = { GlobalMediaPlayer.setVideoVolume(it) }, + onMuteToggle = { GlobalMediaPlayer.toggleVideoMute() }, + onViewModeChange = { mode -> + if (mode == ViewMode.DEFAULT) { + GlobalMediaPlayer.exitFullscreen() + } + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt new file mode 100644 index 0000000000..194bad9f7c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -0,0 +1,521 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File +import java.net.URI + +val LocalWindowState = compositionLocalOf { null } + +val LocalAwtWindow = compositionLocalOf { null } + +val LocalIsImmersiveFullscreen = compositionLocalOf { mutableStateOf(false) } + +enum class ViewMode { DEFAULT, FULLSCREEN } + +private sealed class DownloadState { + data object Idle : DownloadState() + + data class Downloading( + val progress: Float, + val filename: String, + ) : DownloadState() + + data class Done( + val file: File, + ) : DownloadState() + + data class Failed( + val message: String, + ) : DownloadState() +} + +@Composable +fun LightboxOverlay( + urls: List, + initialIndex: Int = 0, + initialSeekPosition: Float = 0f, + initialFullscreen: Boolean = false, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) } + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var menuExpanded by remember { mutableStateOf(false) } + var downloadState by remember { mutableStateOf(DownloadState.Idle) } + var viewMode by remember { mutableStateOf(if (initialFullscreen) ViewMode.FULLSCREEN else ViewMode.DEFAULT) } + val awtWindow = LocalAwtWindow.current + val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + // Sync exclusive fullscreen with viewMode and signal parent layouts + LaunchedEffect(viewMode) { + isImmersiveFullscreen.value = viewMode == ViewMode.FULLSCREEN + if (viewMode == ViewMode.FULLSCREEN) { + awtWindow?.let { FullscreenHelper.enterFullscreen(it) } + } else { + if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen() + } + } + + // Restore fullscreen on dismiss + DisposableEffect(Unit) { + onDispose { + isImmersiveFullscreen.value = false + if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen() + } + } + + // Auto-dismiss banner after 3s + LaunchedEffect(downloadState) { + if (downloadState is DownloadState.Done || downloadState is DownloadState.Failed) { + delay(3000) + downloadState = DownloadState.Idle + } + } + + val currentUrl = urls[currentIndex] + val isVideo = RichTextParser.isVideoUrl(currentUrl) + + fun triggerSave() { + if (downloadState is DownloadState.Downloading) return + val url = urls[currentIndex] + val filename = url.substringAfterLast('/').substringBefore('?').ifBlank { "media" } + downloadState = DownloadState.Downloading(progress = -1f, filename = filename) + scope.launch { + val result = + SaveMediaAction.saveMedia( + url = url, + onProgress = { downloaded, total -> + val progress = if (total > 0) downloaded.toFloat() / total else -1f + downloadState = DownloadState.Downloading(progress = progress, filename = filename) + }, + ) + downloadState = + if (result != null) { + DownloadState.Done(result) + } else { + DownloadState.Failed("Download failed") + } + } + } + + fun toggleFullscreen() { + viewMode = + if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN + } + + // Content modifier based on view mode + val contentModifier = + if (viewMode == ViewMode.DEFAULT) { + Modifier.fillMaxSize().padding(48.dp) + } else { + Modifier.fillMaxSize() + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(if (viewMode == ViewMode.FULLSCREEN) Color.Black else Color.Black.copy(alpha = 0.9f)) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Escape -> { + if (viewMode == ViewMode.FULLSCREEN) { + viewMode = ViewMode.DEFAULT + } else { + onDismiss() + } + true + } + + Key.F -> { + toggleFullscreen() + true + } + + Key.DirectionLeft -> { + if (currentIndex > 0) currentIndex-- + true + } + + Key.DirectionRight -> { + if (currentIndex < urls.lastIndex) currentIndex++ + true + } + + Key.S -> { + if (event.isCtrlPressed || event.isMetaPressed) { + triggerSave() + true + } else { + false + } + } + + else -> { + false + } + } + }.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (viewMode != ViewMode.FULLSCREEN) onDismiss() + }, + ) { + // Main content — video or image + if (isVideo) { + Box( + modifier = + contentModifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + // Consume clicks so backdrop dismiss doesn't fire + }, + contentAlignment = Alignment.Center, + ) { + DesktopVideoPlayer( + url = currentUrl, + autoPlay = true, + initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f, + viewMode = viewMode, + onViewModeChange = { newMode -> + viewMode = newMode + }, + modifier = + if (viewMode == ViewMode.DEFAULT) { + Modifier.widthIn(max = 1200.dp) + } else { + Modifier + }, + trailingControls = { + MoreOptionsMenu( + menuExpanded = menuExpanded, + onExpandMenu = { menuExpanded = true }, + onDismissMenu = { menuExpanded = false }, + onSave = { triggerSave() }, + onCopyUrl = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(urls[currentIndex]), null) + }, + onOpenInBrowser = { + Desktop.getDesktop().browse(URI(urls[currentIndex])) + }, + ) + }, + ) + } + } else { + ZoomableImage( + url = currentUrl, + modifier = contentModifier, + ) + } + + // Download banner (top) + AnimatedVisibility( + visible = downloadState !is DownloadState.Idle, + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter), + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + val state = downloadState + Row( + modifier = + Modifier + .fillMaxWidth() + .background( + when (state) { + is DownloadState.Failed -> MaterialTheme.colorScheme.errorContainer + is DownloadState.Done -> Color(0xFF2E7D32) + else -> Color(0xFF1565C0) + }, + ).padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (state) { + is DownloadState.Downloading -> { + Text( + state.filename, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + if (state.progress >= 0f) { + LinearProgressIndicator( + progress = { state.progress }, + modifier = Modifier.width(120.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + } else { + LinearProgressIndicator( + modifier = Modifier.width(120.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + } + } + + is DownloadState.Done -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "Saved to ${state.file.name}", + color = Color.White, + style = MaterialTheme.typography.bodySmall, + ) + } + + is DownloadState.Failed -> { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + state.message, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + + is DownloadState.Idle -> {} + } + } + } + + // "..." menu (bottom right) — only for images; videos get it in the controls bar + // Hidden in fullscreen to keep the view immersive + if (!isVideo && viewMode != ViewMode.FULLSCREEN) { + Box( + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) { + MoreOptionsMenu( + menuExpanded = menuExpanded, + onExpandMenu = { menuExpanded = true }, + onDismissMenu = { menuExpanded = false }, + onSave = { triggerSave() }, + onCopyUrl = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(urls[currentIndex]), null) + }, + onOpenInBrowser = { + Desktop.getDesktop().browse(URI(urls[currentIndex])) + }, + ) + } + } + + // Close button (top-left) — hidden in fullscreen + if (viewMode != ViewMode.FULLSCREEN) { + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.TopStart).padding(8.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + + // Image counter (bottom-center) — hidden in fullscreen + if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) { + Text( + text = "${currentIndex + 1} / ${urls.size}", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + + // Navigation arrows — hidden in fullscreen + if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) { + if (currentIndex > 0) { + IconButton( + onClick = { currentIndex-- }, + modifier = Modifier.align(Alignment.CenterStart).padding(16.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Previous", + tint = Color.White, + modifier = Modifier.size(48.dp), + ) + } + } + + if (currentIndex < urls.lastIndex) { + IconButton( + onClick = { currentIndex++ }, + modifier = Modifier.align(Alignment.CenterEnd).padding(16.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Next", + tint = Color.White, + modifier = Modifier.size(48.dp), + ) + } + } + } + } +} + +@Composable +private fun MoreOptionsMenu( + menuExpanded: Boolean, + onExpandMenu: () -> Unit, + onDismissMenu: () -> Unit, + onSave: () -> Unit, + onCopyUrl: () -> Unit, + onOpenInBrowser: () -> Unit, +) { + Box { + IconButton(onClick = onExpandMenu, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More options", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissMenu, + ) { + DropdownMenuItem( + text = { Text("Save") }, + leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }, + onClick = { + onDismissMenu() + onSave() + }, + ) + DropdownMenuItem( + text = { Text("Copy URL") }, + leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, + onClick = { + onDismissMenu() + onCopyUrl() + }, + ) + DropdownMenuItem( + text = { Text("Open in Browser") }, + leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) }, + onClick = { + onDismissMenu() + onOpenInBrowser() + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt new file mode 100644 index 0000000000..a8713d0470 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import java.io.File + +@Composable +fun MediaAttachmentRow( + attachedFiles: List, + isUploading: Boolean, + onAttach: () -> Unit, + onPaste: () -> Unit, + onRemove: (File) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onAttach) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach media", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onPaste) { + Icon( + Icons.Default.ContentPaste, + contentDescription = "Paste from clipboard", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + if (isUploading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) + Spacer(Modifier.height(4.dp)) + } + + if (attachedFiles.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (file in attachedFiles) { + AttachedFileThumbnail(file = file, onRemove = { onRemove(file) }) + } + } + } + } +} + +@Composable +private fun AttachedFileThumbnail( + file: File, + onRemove: () -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row { + AsyncImage( + model = file, + contentDescription = file.name, + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + IconButton(onClick = onRemove, modifier = Modifier.size(20.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(Modifier.width(4.dp)) + Text( + text = file.name.take(12), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt new file mode 100644 index 0000000000..978ab591a7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import kotlinx.coroutines.launch + +enum class MediaType { AUDIO, VIDEO } + +@Composable +fun NowPlayingBar(modifier: Modifier = Modifier) { + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val audioState by GlobalMediaPlayer.audioState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + + val hasVideo = videoState.url != null + val hasAudio = audioState.url != null + val visible = hasVideo || hasAudio + + // Show video bar if video is active, otherwise audio + val activeState = if (hasVideo) videoState else audioState + val activeType = if (hasVideo) MediaType.VIDEO else MediaType.AUDIO + + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = modifier, + ) { + if (!visible) return@AnimatedVisibility + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Mini video thumbnail or music icon + if (activeType == MediaType.VIDEO && videoFrame != null) { + Image( + bitmap = videoFrame!!, + contentDescription = "Video thumbnail", + modifier = + Modifier + .size(width = 48.dp, height = 36.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + // Play/pause + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.toggleVideoPlayPause() + } else { + GlobalMediaPlayer.toggleAudioPlayPause() + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (activeState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (activeState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(20.dp), + ) + } + + // Current time + Text( + text = formatTime(activeState.currentTime), + style = MaterialTheme.typography.labelSmall, + ) + + // Seek bar + Slider( + value = activeState.position, + onValueChange = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.seekVideo(it) + } else { + GlobalMediaPlayer.seekAudio(it) + } + }, + modifier = Modifier.weight(1f), + colors = + SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + + // Duration + Text( + text = formatTime(activeState.duration), + style = MaterialTheme.typography.labelSmall, + ) + + // URL label (truncated) + Text( + text = activeState.url?.substringAfterLast('/')?.substringBefore('?') ?: "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(120.dp), + ) + + // Volume / Mute + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.toggleVideoMute() + } else { + GlobalMediaPlayer.toggleAudioMute() + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + if (activeState.isMuted) { + Icons.AutoMirrored.Filled.VolumeOff + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = if (activeState.isMuted) "Unmute" else "Mute", + modifier = Modifier.size(16.dp), + ) + } + + Slider( + value = activeState.volume / 100f, + onValueChange = { + val vol = (it * 100).toInt() + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.setVideoVolume(vol) + } else { + GlobalMediaPlayer.setAudioVolume(vol) + } + }, + modifier = Modifier.width(80.dp), + colors = + SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + + // Save button + val scope = rememberCoroutineScope() + IconButton( + onClick = { + activeState.url?.let { url -> + scope.launch { + SaveMediaAction.saveMedia(url = url) + } + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Save, + contentDescription = "Save", + modifier = Modifier.size(16.dp), + ) + } + + // Fullscreen (video only) + if (activeType == MediaType.VIDEO) { + IconButton( + onClick = { GlobalMediaPlayer.toggleFullscreen() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Fullscreen, + contentDescription = "Fullscreen", + modifier = Modifier.size(16.dp), + ) + } + } + + Spacer(Modifier.width(4.dp)) + + // Close/stop + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.stopVideo() + } else { + GlobalMediaPlayer.stopAudio() + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Stop", + modifier = Modifier.size(16.dp), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt new file mode 100644 index 0000000000..ba2159ccca --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +/** + * Displays a kind 20 picture event (NIP-68) with image-first layout. + */ +@Composable +fun PictureDisplay( + event: PictureEvent, + modifier: Modifier = Modifier, + onImageClick: ((List, Int) -> Unit)? = null, +) { + val imetas = remember(event.id) { event.imetaTags() } + val imageUrls = remember(imetas) { imetas.mapNotNull { it.url } } + val title = remember(event.id) { event.title() } + val description = event.content + + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column { + // Images + for ((index, url) in imageUrls.withIndex()) { + val imageModifier = + Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + .clip( + if (index == 0 && title == null && description.isBlank()) { + RoundedCornerShape(8.dp) + } else if (index == 0) { + RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) + } else { + RoundedCornerShape(0.dp) + }, + ).then( + if (onImageClick != null) { + Modifier.clickable { onImageClick(imageUrls, index) } + } else { + Modifier + }, + ) + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = title, + modifier = imageModifier, + contentScale = ContentScale.FillWidth, + ) + } else { + AsyncImage( + model = url, + contentDescription = title, + modifier = imageModifier, + contentScale = ContentScale.FillWidth, + ) + } + } + + // Title + description below images + if (title != null || description.isNotBlank()) { + Column(modifier = Modifier.padding(12.dp)) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + } + + if (description.isNotBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt new file mode 100644 index 0000000000..e29518db6e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.awt.FileDialog +import java.awt.Frame +import java.io.File + +object SaveMediaAction { + private val httpClient = OkHttpClient() + + /** + * Opens a save dialog and downloads the media URL to the chosen file. + * Returns the saved file path or null if cancelled/failed. + */ + suspend fun saveMedia( + url: String, + suggestedFilename: String? = null, + onProgress: ((downloaded: Long, total: Long) -> Unit)? = null, + ): File? { + val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" } + + // FileDialog must be shown on EDT + val file = + withContext(Dispatchers.Main) { + val dialog = + FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply { + this.file = filename + } + dialog.isVisible = true + + val dir = dialog.directory ?: return@withContext null + File(dir, dialog.file ?: return@withContext null) + } ?: return null + + // Download on IO + return withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + response.use { resp -> + if (!resp.isSuccessful) return@withContext null + val total = resp.body.contentLength() + resp.body.byteStream().use { input -> + file.outputStream().use { output -> + val buffer = ByteArray(8192) + var downloaded = 0L + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + downloaded += bytesRead + onProgress?.invoke(downloaded, total) + } + } + } + } + file + } catch (_: Exception) { + null + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt new file mode 100644 index 0000000000..1dbd20837e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp + +@Composable +fun VideoControls( + isPlaying: Boolean, + position: Float, + duration: Long, + currentTime: Long, + onPlayPause: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + isBuffering: Boolean = false, + volume: Int = 100, + isMuted: Boolean = false, + onVolumeChange: ((Int) -> Unit)? = null, + onMuteToggle: (() -> Unit)? = null, + onFullscreen: (() -> Unit)? = null, + viewMode: ViewMode = ViewMode.DEFAULT, + onViewModeChange: ((ViewMode) -> Unit)? = null, + trailingControls: @Composable (() -> Unit)? = null, +) { + var hovering by remember { mutableStateOf(false) } + + Box( + modifier = + modifier + .fillMaxSize() + .clickable { onPlayPause() } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.Enter -> hovering = true + PointerEventType.Exit -> hovering = false + } + } + } + }, + ) { + // Center play/buffering indicator + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(48.dp), + color = Color.White, + strokeWidth = 3.dp, + ) + } else if (!isPlaying) { + // Always show play button when paused + IconButton( + onClick = onPlayPause, + modifier = Modifier.align(Alignment.Center).size(64.dp), + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play", + tint = Color.White, + modifier = Modifier.size(48.dp), + ) + } + } + + // Bottom controls — show on hover + AnimatedVisibility( + visible = hovering, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 8.dp), + ) { + // Seek slider (full width, no horizontal competition) + Slider( + value = position, + onValueChange = onSeek, + modifier = Modifier.fillMaxWidth(), + colors = + SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = Color.White.copy(alpha = 0.3f), + ), + ) + + // Buttons row + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onPlayPause, modifier = Modifier.size(32.dp)) { + Icon( + if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = "${formatTime(currentTime)} / ${formatTime(duration)}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + + // Spacer pushes right-side controls to the end + Box(Modifier.weight(1f)) + + // Volume + if (onMuteToggle != null) { + IconButton(onClick = onMuteToggle, modifier = Modifier.size(32.dp)) { + Icon( + if (isMuted) { + Icons.AutoMirrored.Filled.VolumeOff + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = if (isMuted) "Unmute" else "Mute", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + + if (onVolumeChange != null) { + Slider( + value = volume / 100f, + onValueChange = { onVolumeChange((it * 100).toInt()) }, + modifier = Modifier.width(240.dp), + colors = + SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color.White.copy(alpha = 0.7f), + inactiveTrackColor = Color.White.copy(alpha = 0.3f), + ), + ) + } + + // Fullscreen toggle (lightbox) or simple fullscreen (inline) + if (onViewModeChange != null) { + IconButton( + onClick = { + onViewModeChange( + if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN, + ) + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (viewMode == ViewMode.FULLSCREEN) { + Icons.Default.FullscreenExit + } else { + Icons.Default.Fullscreen + }, + contentDescription = + if (viewMode == ViewMode.FULLSCREEN) "Exit fullscreen" else "Fullscreen", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } else if (onFullscreen != null) { + IconButton(onClick = onFullscreen, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Fullscreen, + contentDescription = "Fullscreen", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + + // Trailing controls (e.g. more-options menu) + trailingControls?.invoke() + } + } + } + } +} + +internal fun formatTime(millis: Long): String { + val totalSeconds = millis / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt new file mode 100644 index 0000000000..9813b0807f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ZoomableImage( + url: String, + modifier: Modifier = Modifier, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + Box( + modifier = + modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scale = 1f + offsetX = 0f + offsetY = 0f + }, + onTap = { + // Consume single taps so they don't propagate to backdrop + }, + ) + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Scroll) { + val scrollDelta = + event.changes + .firstOrNull() + ?.scrollDelta + ?.y ?: 0f + val zoomFactor = if (scrollDelta > 0) 0.9f else 1.1f + scale = (scale * zoomFactor).coerceIn(0.5f, 10f) + event.changes.forEach { it.consume() } + } + } + } + }.pointerInput(Unit) { + detectDragGestures { _, dragAmount -> + if (scale > 1f) { + offsetX += dragAmount.x + offsetY += dragAmount.y + } + } + }, + contentAlignment = Alignment.Center, + ) { + val imageModifier = + Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ) + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = null, + modifier = imageModifier, + contentScale = ContentScale.Fit, + ) + } else { + AsyncImage( + model = url, + contentDescription = null, + modifier = imageModifier, + contentScale = ContentScale.Fit, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index aca0f12d92..44417e19ad 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -22,13 +22,16 @@ package com.vitorpamplona.amethyst.desktop.ui.note import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -38,16 +41,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage +import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer +import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer +import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState +import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a") /** * Data class for displaying a note card. @@ -69,10 +91,66 @@ data class NoteDisplayData( fun NoteCard( note: NoteDisplayData, modifier: Modifier = Modifier, + localCache: DesktopLocalCache? = null, onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, + onMentionClick: ((String) -> Unit)? = null, + onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } + val imageUrls = + remember(urls) { + urls.withScheme.filter { RichTextParser.isImageUrl(it) } + } + val videoAndAudioUrls = + remember(urls) { + urls.withScheme.filter { RichTextParser.isVideoUrl(it) } + } + val audioUrls = + remember(videoAndAudioUrls) { + videoAndAudioUrls.filter { url -> + val ext = + url + .substringAfterLast('.', "") + .substringBefore('?') + .lowercase() + ext in AUDIO_EXTENSIONS + } + } + val videoUrls = + remember(videoAndAudioUrls, audioUrls) { + videoAndAudioUrls - audioUrls.toSet() + } + val mediaUrls = remember(imageUrls, videoAndAudioUrls) { (imageUrls + videoAndAudioUrls).toSet() } + val strippedContent = + remember(note.content, mediaUrls) { + var text = note.content + for (url in mediaUrls) { + text = text.replace(url, "").trim() + } + text + } + val strippedUrls = + remember(urls, mediaUrls) { + Urls( + withScheme = urls.withScheme - mediaUrls, + withoutScheme = urls.withoutScheme, + emails = urls.emails, + bech32s = urls.bech32s, + relayUrls = urls.relayUrls, + blossomUris = urls.blossomUris, + ) + } + + // Cap media height to half the window so text is never pushed off-screen + val windowState = LocalWindowState.current + val maxMediaHeight = + if (windowState != null) { + (windowState.size.height * 0.5f).coerceAtLeast(200.dp) + } else { + 400.dp + } Card( modifier = modifier.fillMaxWidth(), @@ -80,56 +158,149 @@ fun NoteCard( CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), - onClick = onClick ?: {}, ) { Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + // Header + text area — clickable to navigate to thread + Column( + modifier = + if (onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + }, ) { - // Author with avatar Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = - if (onAuthorClick != null) { - Modifier.clickable { onAuthorClick(note.pubKeyHex) } - } else { - Modifier - }, ) { - UserAvatar( - userHex = note.pubKeyHex, - pictureUrl = note.profilePictureUrl, - size = 32.dp, - contentDescription = "Profile picture of ${note.pubKeyDisplay}", - ) + // Author with avatar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + if (onAuthorClick != null) { + Modifier.clickable { onAuthorClick(note.pubKeyHex) } + } else { + Modifier + }, + ) { + UserAvatar( + userHex = note.pubKeyHex, + pictureUrl = note.profilePictureUrl, + size = 32.dp, + contentDescription = "Profile picture of ${note.pubKeyDisplay}", + ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } + + // Timestamp Text( - text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, + text = note.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Timestamp - Text( - text = note.createdAt.toTimeAgo(withDot = false), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Spacer(Modifier.height(8.dp)) + + if (strippedContent.isNotBlank()) { + RichTextContent( + content = strippedContent, + urls = strippedUrls, + localCache = localCache, + onMentionClick = onMentionClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } // end clickable header+text column + + // Inline images + if (imageUrls.isNotEmpty()) { + if (strippedContent.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + } + for ((index, url) in imageUrls.withIndex()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = maxMediaHeight) + .clip(RoundedCornerShape(8.dp)) + .then( + if (onImageClick != null) { + Modifier.clickable { onImageClick(imageUrls, index) } + } else { + Modifier + }, + ), + ) { + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit, + ) + } else { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit, + ) + } + } + if (url != imageUrls.last()) { + Spacer(Modifier.height(4.dp)) + } + } } - Spacer(Modifier.height(8.dp)) + // Inline videos + if (videoUrls.isNotEmpty()) { + if (strippedContent.isNotBlank() || imageUrls.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + } + for ((index, url) in videoUrls.withIndex()) { + DesktopVideoPlayer( + url = url, + modifier = Modifier.fillMaxWidth().heightIn(max = maxMediaHeight), + onFullscreen = + if (onMediaClick != null) { + { seekPos -> onMediaClick(videoUrls, index, seekPos) } + } else { + null + }, + ) + if (url != videoUrls.last()) { + Spacer(Modifier.height(4.dp)) + } + } + } - RichTextContent( - content = note.content, - urls = urls, - modifier = Modifier.fillMaxWidth(), - ) + // Inline audio + if (audioUrls.isNotEmpty()) { + if (strippedContent.isNotBlank() || imageUrls.isNotEmpty() || videoUrls.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + } + for (url in audioUrls) { + AudioPlayer( + url = url, + modifier = Modifier.fillMaxWidth(), + ) + if (url != audioUrls.last()) { + Spacer(Modifier.height(4.dp)) + } + } + } Spacer(Modifier.height(8.dp)) @@ -148,52 +319,152 @@ fun NoteCard( } /** - * Renders text content with highlighted URLs. - * Uses RichTextParser from commons to detect and highlight links. + * Resolved bech32 mention with display text and optional pubkey for click navigation. + */ +private data class ResolvedMention( + val displayText: String, + val pubKeyHex: String? = null, +) + +/** + * Resolves a nostr: bech32 reference to a display string and optional pubkey. + * For npub/nprofile → @displayName + pubkey hex for navigation. + * For note/nevent → truncated note ID. + */ +private fun resolveBech32( + bech32: String, + localCache: DesktopLocalCache?, +): ResolvedMention { + val parsed = Nip19Parser.uriToRoute(bech32) ?: return ResolvedMention(bech32) + return when (val entity = parsed.entity) { + is NPub -> { + val user = localCache?.getUserIfExists(entity.hex) + ResolvedMention( + displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}", + pubKeyHex = entity.hex, + ) + } + + is NProfile -> { + val user = localCache?.getUserIfExists(entity.hex) + ResolvedMention( + displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}", + pubKeyHex = entity.hex, + ) + } + + is NNote -> { + ResolvedMention("note:${entity.hex.take(8)}...") + } + + is NEvent -> { + ResolvedMention("note:${entity.hex.take(8)}...") + } + + else -> { + ResolvedMention(bech32.take(24) + "...") + } + } +} + +/** + * Extracts pubkey hex strings from all npub/nprofile bech32 references in a set. + * Used to trigger metadata loading for mentioned users. + */ +fun extractMentionedPubkeys(bech32s: Set): List = + bech32s.mapNotNull { bech32 -> + val parsed = Nip19Parser.uriToRoute(bech32) ?: return@mapNotNull null + when (val entity = parsed.entity) { + is NPub -> entity.hex + is NProfile -> entity.hex + else -> null + } + } + +/** + * Renders text content with highlighted URLs and clickable nostr: bech32 mentions. + * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color + * and navigate to profile on click. */ @Composable fun RichTextContent( content: String, urls: Urls, + localCache: DesktopLocalCache? = null, + onMentionClick: ((String) -> Unit)? = null, modifier: Modifier = Modifier, - maxLines: Int = 10, ) { - if (urls.withScheme.isEmpty()) { + val defaultColor = MaterialTheme.colorScheme.onSurface + val primaryColor = MaterialTheme.colorScheme.primary + + if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) { Text( text = content, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, + color = defaultColor, modifier = modifier, ) } else { + data class Segment( + val start: Int, + val raw: String, + val isUrl: Boolean, + ) + + val segments = mutableListOf() + for (url in urls.withScheme) { + val idx = content.indexOf(url) + if (idx != -1) segments.add(Segment(idx, url, true)) + } + for (bech32 in urls.bech32s) { + val idx = content.indexOf(bech32) + if (idx != -1) segments.add(Segment(idx, bech32, false)) + } + segments.sortBy { it.start } + val annotatedText = buildAnnotatedString { var lastIndex = 0 - // TODO: User the other urls. - val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) } - for (url in sortedUrls) { - val startIndex = content.indexOf(url, lastIndex) - if (startIndex == -1) continue + for (segment in segments) { + if (segment.start < lastIndex) continue - // Add text before URL - if (startIndex > lastIndex) { - append(content.substring(lastIndex, startIndex)) + // Add text before segment + if (segment.start > lastIndex) { + append(content.substring(lastIndex, segment.start)) } - // Add URL with styling - withStyle( - SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - ) { - append(url) + if (segment.isUrl) { + withStyle( + SpanStyle( + color = primaryColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(segment.raw) + } + } else { + val resolved = resolveBech32(segment.raw, localCache) + if (resolved.pubKeyHex != null && onMentionClick != null) { + val pubKey = resolved.pubKeyHex + withLink( + LinkAnnotation.Clickable( + tag = "mention", + styles = TextLinkStyles(SpanStyle(color = primaryColor)), + ) { + onMentionClick(pubKey) + }, + ) { + append(resolved.displayText) + } + } else { + withStyle(SpanStyle(color = primaryColor)) { + append(resolved.displayText) + } + } } - lastIndex = startIndex + url.length + lastIndex = segment.start + segment.raw.length } // Add remaining text @@ -205,9 +476,7 @@ fun RichTextContent( Text( text = annotatedText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, + color = defaultColor, modifier = modifier, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt new file mode 100644 index 0000000000..f624e286d7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +/** + * Grid gallery view of a user's picture posts (kind 20). + */ +@Composable +fun GalleryTab( + pictureEvents: List, + onImageClick: ((List, Int) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + if (pictureEvents.isEmpty()) { + Box( + modifier = modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No pictures yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + modifier = modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(pictureEvents, key = { it.id }) { event -> + val firstImageUrl = + remember(event.id) { + event.imetaTags().firstNotNullOfOrNull { it.url } + } + + if (firstImageUrl != null) { + GalleryThumbnail( + url = firstImageUrl, + onClick = { + val allUrls = event.imetaTags().mapNotNull { it.url } + onImageClick?.invoke(allUrls, 0) + }, + ) + } + } + } +} + +@Composable +private fun GalleryThumbnail( + url: String, + onClick: () -> Unit, +) { + AsyncImage( + model = url, + contentDescription = null, + modifier = + Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick), + contentScale = ContentScale.Crop, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt new file mode 100644 index 0000000000..4b18918c42 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt @@ -0,0 +1,429 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.search + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.search.ContentPreset +import com.vitorpamplona.amethyst.commons.search.DateUtils +import com.vitorpamplona.amethyst.commons.search.KindRegistry +import com.vitorpamplona.amethyst.commons.search.QueryParser +import com.vitorpamplona.amethyst.commons.search.SearchQuery + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AdvancedSearchPanel( + query: SearchQuery, + onKindsChanged: (List) -> Unit, + onPseudoKindsChanged: (List) -> Unit, + onAuthorAdded: (String) -> Unit, + onAuthorRemoved: (String) -> Unit, + onDateRangeChanged: (Long?, Long?) -> Unit, + onHashtagAdded: (String) -> Unit, + onHashtagRemoved: (String) -> Unit, + onExcludeAdded: (String) -> Unit, + onExcludeRemoved: (String) -> Unit, + onLanguageChanged: (String?) -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Content type presets + Text( + "Content Type", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + KindRegistry.presets.forEach { (name, preset) -> + FilterChip( + selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()), + onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) }, + label = { Text(name) }, + ) + } + } + + // Author field + AuthorInputField( + authors = query.authors.toList() + query.authorNames.toList(), + onAuthorAdded = onAuthorAdded, + onAuthorRemoved = onAuthorRemoved, + ) + + // Date range + DateRangeFields( + since = query.since, + until = query.until, + onChanged = onDateRangeChanged, + ) + + // Hashtags + ChipGroupWithInput( + label = "Tags", + items = query.hashtags.toList(), + prefix = "#", + placeholder = "Add tag...", + onAdd = onHashtagAdded, + onRemove = onHashtagRemoved, + ) + + // Exclude terms + ChipGroupWithInput( + label = "Exclude", + items = query.excludeTerms.toList(), + prefix = "-", + placeholder = "Exclude term...", + onAdd = onExcludeAdded, + onRemove = onExcludeRemoved, + ) + + // Language + LanguageSelector( + selected = query.language, + onChanged = onLanguageChanged, + ) + + // Clear button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = onClear) { + Text("Clear All") + } + } + } + } +} + +private fun togglePreset( + preset: ContentPreset, + query: SearchQuery, + onKindsChanged: (List) -> Unit, + onPseudoKindsChanged: (List) -> Unit, +) { + val pseudo = preset.pseudoKind + if (pseudo != null) { + val current = query.pseudoKinds.toList() + if (pseudo in current) { + onPseudoKindsChanged(current - pseudo) + } else { + onPseudoKindsChanged(current + pseudo) + } + } else { + val current = query.kinds.toList() + if (current.containsAll(preset.kinds)) { + onKindsChanged(current - preset.kinds.toSet()) + } else { + onKindsChanged((current + preset.kinds).distinct()) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AuthorInputField( + authors: List, + onAuthorAdded: (String) -> Unit, + onAuthorRemoved: (String) -> Unit, +) { + Column { + Text( + "Author", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (authors.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + authors.forEach { author -> + AssistChip( + onClick = { onAuthorRemoved(author) }, + label = { + Text( + if (author.length > 16) author.take(8) + "..." + author.takeLast(4) else author, + style = MaterialTheme.typography.bodySmall, + ) + }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp)) + }, + ) + } + } + Spacer(Modifier.height(4.dp)) + } + var authorInput by remember { mutableStateOf("") } + OutlinedTextField( + value = authorInput, + onValueChange = { authorInput = it }, + modifier = + Modifier.fillMaxWidth().onKeyEvent { + if (it.key == Key.Enter && authorInput.isNotBlank()) { + onAuthorAdded(authorInput.trim()) + authorInput = "" + true + } else { + false + } + }, + placeholder = { Text("npub or name...") }, + singleLine = true, + trailingIcon = { + if (authorInput.isNotBlank()) { + IconButton(onClick = { + onAuthorAdded(authorInput.trim()) + authorInput = "" + }) { + Icon(Icons.Default.Add, contentDescription = "Add author") + } + } + }, + ) + } +} + +@Composable +private fun DateRangeFields( + since: Long?, + until: Long?, + onChanged: (Long?, Long?) -> Unit, +) { + // Local text is source of truth while typing. + // Only propagate valid timestamps (or null when cleared). + // Only sync from external when the timestamp changes to something we didn't produce. + var sinceText by remember { mutableStateOf(since?.let { DateUtils.timestampToDate(it) } ?: "") } + var lastSince by remember { mutableStateOf(since) } + if (since != lastSince) { + sinceText = since?.let { DateUtils.timestampToDate(it) } ?: "" + lastSince = since + } + + var untilText by remember { mutableStateOf(until?.let { DateUtils.timestampToDate(it) } ?: "") } + var lastUntil by remember { mutableStateOf(until) } + if (until != lastUntil) { + untilText = until?.let { DateUtils.timestampToDate(it) } ?: "" + lastUntil = until + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Since", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = sinceText, + onValueChange = { + sinceText = it + val ts = QueryParser.parseDateToTimestamp(it) + if (ts != null || it.isBlank()) { + lastSince = ts + onChanged(ts, until) + } + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("YYYY-MM-DD") }, + singleLine = true, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + "Until", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = untilText, + onValueChange = { + untilText = it + val ts = QueryParser.parseDateToTimestamp(it) + if (ts != null || it.isBlank()) { + lastUntil = ts + onChanged(since, ts) + } + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("YYYY-MM-DD") }, + singleLine = true, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipGroupWithInput( + label: String, + items: List, + prefix: String, + placeholder: String, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, +) { + Column { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + AssistChip( + onClick = { onRemove(item) }, + label = { Text("$prefix$item", style = MaterialTheme.typography.bodySmall) }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp)) + }, + ) + } + } + if (items.isNotEmpty()) Spacer(Modifier.height(4.dp)) + var inputText by remember { mutableStateOf("") } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = inputText, + onValueChange = { inputText = it }, + modifier = + Modifier.weight(1f).onKeyEvent { + if (it.key == Key.Enter && inputText.isNotBlank()) { + onAdd(inputText.trim()) + inputText = "" + true + } else { + false + } + }, + placeholder = { Text(placeholder) }, + singleLine = true, + ) + TextButton( + onClick = { + if (inputText.isNotBlank()) { + onAdd(inputText.trim()) + inputText = "" + } + }, + ) { + Text("Add") + } + } + } +} + +@Composable +private fun LanguageSelector( + selected: String?, + onChanged: (String?) -> Unit, +) { + val languages = + listOf( + null to "Any", + "en" to "English", + "es" to "Spanish", + "pt" to "Portuguese", + "ja" to "Japanese", + "zh" to "Chinese", + "de" to "German", + "fr" to "French", + ) + + Column { + Text( + "Language", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + languages.forEach { (code, name) -> + FilterChip( + selected = selected == code, + onClick = { onChanged(code) }, + label = { Text(name) }, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt new file mode 100644 index 0000000000..2a50d2ce84 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.search + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.SearchSortOrder +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +@Composable +fun SearchResultsList( + state: AdvancedSearchBarState, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + localCache: DesktopLocalCache? = null, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), +) { + val people by state.sortedPeopleResults.collectAsState() + val notes by state.sortedNoteResults.collectAsState() + val eventSortOrder by state.eventSortOrder.collectAsState() + val peopleSortOrder by state.peopleSortOrder.collectAsState() + + val hasResults = people.isNotEmpty() || notes.isNotEmpty() + + if (!hasResults) return + + // Group notes by kind + val textNotes = notes.filter { it.kind == 1 } + val articles = notes.filter { it.kind == LongTextNoteEvent.KIND } + val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND } + + // Per-section collapsed state (absent = expanded) + val collapsedSections = remember { mutableStateMapOf() } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + // People section + if (people.isNotEmpty()) { + val collapsed = collapsedSections["people"] == true + stickyHeader(key = "header-people") { + SortableHeader( + title = "People", + count = people.size, + icon = Icons.Default.Person, + options = SearchSortOrder.PEOPLE_OPTIONS, + selected = peopleSortOrder, + onSelect = { state.updatePeopleSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["people"] = !collapsed }, + ) + } + if (!collapsed) { + val displayPeople = people.take(5) + items(displayPeople, key = { "person-${it.pubkeyHex}" }) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + if (people.size > 5) { + item(key = "people-expand") { + ExpandableSection( + remaining = people.drop(5), + ) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + } + } + } + } + + // Notes section + if (textNotes.isNotEmpty()) { + if (people.isNotEmpty()) { + item(key = "divider-notes") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + } + val collapsed = collapsedSections["notes"] == true + stickyHeader(key = "header-notes") { + SortableHeader( + title = "Notes", + count = textNotes.size, + icon = Icons.Default.Description, + options = SearchSortOrder.EVENT_OPTIONS, + selected = eventSortOrder, + onSelect = { state.updateEventSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["notes"] = !collapsed }, + ) + } + if (!collapsed) { + val displayNotes = textNotes.take(5) + items(displayNotes, key = { "note-${it.id}" }) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } + if (textNotes.size > 5) { + item(key = "notes-expand") { + ExpandableSection( + remaining = textNotes.drop(5), + ) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } + } + } + } + } + + // Articles section + if (articles.isNotEmpty()) { + if (people.isNotEmpty() || textNotes.isNotEmpty()) { + item(key = "divider-articles") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + } + val collapsed = collapsedSections["articles"] == true + stickyHeader(key = "header-articles") { + SortableHeader( + title = "Articles", + count = articles.size, + icon = Icons.AutoMirrored.Default.Article, + options = SearchSortOrder.EVENT_OPTIONS, + selected = eventSortOrder, + onSelect = { state.updateEventSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["articles"] = !collapsed }, + ) + } + if (!collapsed) { + items(articles.take(5), key = { "article-${it.id}" }) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } + if (articles.size > 5) { + item(key = "articles-expand") { + ExpandableSection( + remaining = articles.drop(5), + ) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } + } + } + } + } + + // Other section + if (otherNotes.isNotEmpty()) { + item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + val collapsed = collapsedSections["other"] == true + stickyHeader(key = "header-other") { + SortableHeader( + title = "Other", + count = otherNotes.size, + icon = Icons.Default.Forum, + options = SearchSortOrder.EVENT_OPTIONS, + selected = eventSortOrder, + onSelect = { state.updateEventSortOrder(it) }, + collapsed = collapsed, + onToggleCollapse = { collapsedSections["other"] = !collapsed }, + ) + } + if (!collapsed) { + items(otherNotes.take(5), key = { "other-${it.id}" }) { event -> + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } + } + } + + // Bottom padding + item(key = "bottom-spacer") { Spacer(Modifier.height(16.dp)) } + } +} + +@Composable +private fun SortableHeader( + title: String, + count: Int, + icon: ImageVector, + options: List, + selected: SearchSortOrder, + onSelect: (SearchSortOrder) -> Unit, + collapsed: Boolean = false, + onToggleCollapse: () -> Unit = {}, +) { + val chevronRotation by animateFloatAsState(if (collapsed) -90f else 0f) + + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(onClick = onToggleCollapse).padding(vertical = 4.dp), + ) { + Icon( + Icons.Default.ExpandMore, + contentDescription = if (collapsed) "Expand $title" else "Collapse $title", + modifier = Modifier.size(18.dp).rotate(chevronRotation), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + icon, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp).size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + "$title ($count)", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + Spacer(Modifier.weight(1f)) + if (!collapsed) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + options.forEach { option -> + FilterChip( + selected = option == selected, + onClick = { onSelect(option) }, + label = { + Text( + option.label, + style = MaterialTheme.typography.labelSmall, + ) + }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.height(28.dp), + ) + } + } + } + } + } +} + +@Composable +private fun ExpandableSection( + remaining: List, + content: @Composable (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + if (expanded) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + remaining.forEach { item -> + content(item) + } + } + } else { + TextButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.ExpandMore, contentDescription = null, modifier = Modifier.size(16.dp)) + Text("Show all ${remaining.size} more") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt new file mode 100644 index 0000000000..348dcaf0fe --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.search + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chess.RelaySyncState +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SearchSyncBanner( + relayStates: ImmutableList, + isSearching: Boolean, + modifier: Modifier = Modifier, +) { + val isVisible = isSearching || relayStates.isNotEmpty() + var isExpanded by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = isVisible, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = modifier, + ) { + Column { + // Collapsed summary row + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(horizontal = 4.dp, vertical = 6.dp), + ) { + val responded = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val total = relayStates.size + val totalEvents = relayStates.sumOf { it.eventsReceived } + + Text( + text = + if (total > 0) { + "$responded/$total relays responded \u00B7 $totalEvents events" + } else { + "Connecting to relays..." + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + + // Expanded per-relay details + AnimatedVisibility( + visible = isExpanded && relayStates.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp, + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + ) { + relayStates.forEach { relay -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = relayStatusIcon(relay.status), + contentDescription = null, + tint = relayStatusColor(relay.status), + modifier = Modifier.size(14.dp), + ) + Text( + text = relay.displayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "${relay.eventsReceived} events", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } + } + } + } +} + +private fun relayStatusIcon(status: RelaySyncStatus): ImageVector = + when (status) { + RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty + RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty + RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload + RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle + RelaySyncStatus.FAILED -> Icons.Default.Error + } + +@Composable +private fun relayStatusColor(status: RelaySyncStatus): Color = + when (status) { + RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary + RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary + RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt new file mode 100644 index 0000000000..31f7031e41 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +@Composable +fun MediaServerSettings( + initialServers: List = emptyList(), + onServersChanged: (List) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val servers = remember { mutableStateListOf().apply { addAll(initialServers) } } + val serverStatuses = remember { mutableStateMapOf() } + var newServerUrl by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + var isChecking by remember { mutableStateOf(false) } + + // Check health on first load (parallel) + LaunchedEffect(servers.toList()) { + coroutineScope { + for (server in servers) { + if (server !in serverStatuses) { + launch { + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + } + } + } + } + + Column(modifier = modifier.fillMaxWidth().padding(16.dp)) { + Text( + "Media Servers (Blossom)", + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Configure Blossom servers for media uploads. First server is the default.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + // Server list + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (server in servers.toList()) { + ServerRow( + server = server, + status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN, + isDefault = servers.indexOf(server) == 0, + onSetDefault = { + servers.remove(server) + servers.add(0, server) + onServersChanged(servers.toList()) + }, + onRemove = { + servers.remove(server) + serverStatuses.remove(server) + onServersChanged(servers.toList()) + }, + onRefresh = { + scope.launch { + serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Add server + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newServerUrl, + onValueChange = { newServerUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("https://blossom.example.com") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = { + val url = newServerUrl.trim().removeSuffix("/") + if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) { + servers.add(url) + newServerUrl = "" + onServersChanged(servers.toList()) + scope.launch { + val status = ServerHealthCheck.check(url) + serverStatuses[url] = status + } + } + }, + enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()), + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + Spacer(Modifier.width(4.dp)) + Text("Add") + } + } + + Spacer(Modifier.height(8.dp)) + + // Refresh all + Button( + onClick = { + scope.launch { + isChecking = true + coroutineScope { + for (server in servers) { + launch { + serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + } + } + isChecking = false + } + }, + enabled = !isChecking, + ) { + if (isChecking) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + } else { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + Spacer(Modifier.width(4.dp)) + Text("Check All") + } + } +} + +private fun isValidServerUrl(url: String): Boolean { + val trimmed = url.trim().removeSuffix("/") + return try { + val uri = java.net.URI(trimmed) + uri.scheme in listOf("https", "http") && uri.host != null && uri.host.contains(".") + } catch (_: Exception) { + false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ServerRow( + server: String, + status: ServerHealthCheck.ServerStatus, + isDefault: Boolean, + onSetDefault: () -> Unit, + onRemove: () -> Unit, + onRefresh: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status indicator with tooltip + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + when (status) { + ServerHealthCheck.ServerStatus.ONLINE -> "Online" + ServerHealthCheck.ServerStatus.OFFLINE -> "Offline — server unreachable" + ServerHealthCheck.ServerStatus.UNKNOWN -> "Checking..." + }, + ) + } + }, + state = rememberTooltipState(), + ) { + Surface( + modifier = Modifier.size(12.dp), + shape = CircleShape, + color = + when (status) { + ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) + ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) + ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) + }, + ) {} + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + server, + style = MaterialTheme.typography.bodyMedium, + ) + if (isDefault) { + Text( + "Default server", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + "Set as default", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { onSetDefault() }, + ) + } + } + + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + modifier = Modifier.size(18.dp), + ) + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt index 7a97d52c82..b0733dc813 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt @@ -61,7 +61,7 @@ class AccountManagerHeartbeatTest { NostrSignerRemote.fromBunkerUri( "bunker://$validHex?relay=wss://r.com", ephemeral, - EmptyNostrClient, + EmptyNostrClient(), ), ) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt index e19f4ac631..525a8b4686 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt @@ -102,7 +102,7 @@ class AccountManagerKeyLoginTest { val state = manager.generateNewAccount() assertTrue(state.npub.startsWith("npub1")) assertNotNull(state.nsec) - assertTrue(state.nsec!!.startsWith("nsec1")) + assertTrue(state.nsec.startsWith("nsec1")) assertFalse(state.isReadOnly) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt new file mode 100644 index 0000000000..8921264ecb --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for the AESGCM encryption used by EncryptedMediaService. + * These test the crypto primitives without requiring network access. + */ +class EncryptedMediaServiceTest { + @Test + fun aesgcmEncryptDecryptRoundTrip() { + val plaintext = "Hello, encrypted media!".toByteArray() + val cipher = AESGCM() + + val encrypted = cipher.encrypt(plaintext) + val decrypted = cipher.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun aesgcmEncryptedDataDiffersFromPlaintext() { + val plaintext = "Secret data".toByteArray() + val cipher = AESGCM() + + val encrypted = cipher.encrypt(plaintext) + + assertFalse(plaintext.contentEquals(encrypted)) + assertTrue(encrypted.size > plaintext.size) // Includes auth tag + } + + @Test + fun aesgcmDecryptWithExplicitKeyAndNonce() { + val cipher1 = AESGCM() + val plaintext = "Roundtrip test data".toByteArray() + + val encrypted = cipher1.encrypt(plaintext) + + // Reconstruct cipher with same key and nonce + val cipher2 = AESGCM(cipher1.keyBytes, cipher1.nonce) + val decrypted = cipher2.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun aesgcmKeyAndNonceAreGenerated() { + val cipher = AESGCM() + + assertNotNull(cipher.keyBytes) + assertNotNull(cipher.nonce) + assertTrue(cipher.keyBytes.size == 32) // AES-256 + assertTrue(cipher.nonce.size == 16) + } + + @Test + fun aesgcmDifferentCiphersProduceDifferentOutput() { + val plaintext = "Same message".toByteArray() + val cipher1 = AESGCM() + val cipher2 = AESGCM() + + val encrypted1 = cipher1.encrypt(plaintext) + val encrypted2 = cipher2.encrypt(plaintext) + + // Different keys should produce different ciphertext + assertFalse(encrypted1.contentEquals(encrypted2)) + } + + @Test + fun aesgcmHandlesEmptyData() { + val cipher = AESGCM() + val plaintext = byteArrayOf() + + val encrypted = cipher.encrypt(plaintext) + val decrypted = cipher.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun aesgcmHandlesLargeData() { + val cipher = AESGCM() + // Simulate a small "file" - 64KB + val plaintext = ByteArray(65536) { (it % 256).toByte() } + + val encrypted = cipher.encrypt(plaintext) + val decrypted = cipher.decrypt(encrypted) + + assertContentEquals(plaintext, decrypted) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt new file mode 100644 index 0000000000..bd0a6a205b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ServerHealthCheckTest { + @Test + fun checkOfflineForInvalidUrl() = + runTest { + // Localhost on a random high port should be unreachable + val status = ServerHealthCheck.check("http://127.0.0.1:19999") + assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status) + } + + @Test + fun checkOfflineForMalformedUrl() = + runTest { + val status = ServerHealthCheck.check("not-a-url") + assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status) + } + + @Test + fun checkOfflineForNonexistentHost() = + runTest { + val status = ServerHealthCheck.check("https://this-host-definitely-does-not-exist-12345.example.com") + assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt new file mode 100644 index 0000000000..75b94e6a8b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class DesktopBlossomClientTest { + private fun mockOkHttp( + responseCode: Int, + body: String = "", + headers: Headers = Headers.headersOf(), + ): OkHttpClient { + val requestSlot = slot() + val mockCall = mockk() + val mockClient = mockk() + + every { mockClient.newCall(capture(requestSlot)) } returns mockCall + every { mockCall.execute() } returns + Response + .Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(responseCode) + .message(if (responseCode == 200) "OK" else "Error") + .headers(headers) + .body(body.toResponseBody()) + .build() + + return mockClient + } + + @Test + fun uploadSuccessReturnsResult() = + runTest { + val json = + """{"url":"https://blossom.example.com/abc123.png","sha256":"abc123","size":1024}""" + val client = DesktopBlossomClient(mockOkHttp(200, json)) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1, 2, 3)) + + try { + val result = + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = "Nostr abc", + ) + + assertEquals("https://blossom.example.com/abc123.png", result.url) + assertEquals("abc123", result.sha256) + assertEquals(1024L, result.size) + } finally { + file.delete() + } + } + + @Test + fun uploadFailureThrowsException() = + runTest { + val headers = Headers.headersOf("X-Reason", "File too large") + val client = DesktopBlossomClient(mockOkHttp(413, "", headers)) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1, 2, 3)) + + try { + val ex = + assertFailsWith { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = null, + ) + } + assertTrue(ex.message!!.contains("File too large")) + } finally { + file.delete() + } + } + + @Test + fun uploadFailureUsesStatusCodeWhenNoXReason() = + runTest { + val client = DesktopBlossomClient(mockOkHttp(500)) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1)) + + try { + val ex = + assertFailsWith { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = null, + ) + } + assertTrue(ex.message!!.contains("500")) + } finally { + file.delete() + } + } + + @Test + fun uploadSendsAuthorizationHeader() = + runTest { + val requestSlot = slot() + val mockCall = mockk() + val mockClient = mockk() + + every { mockClient.newCall(capture(requestSlot)) } returns mockCall + every { mockCall.execute() } returns + Response + .Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"url":"https://example.com/hash"}""".toResponseBody()) + .build() + + val client = DesktopBlossomClient(mockClient) + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1)) + + try { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com", + authHeader = "Nostr base64token", + ) + + val sentRequest = requestSlot.captured + assertEquals("Nostr base64token", sentRequest.header("Authorization")) + assertEquals("https://blossom.example.com/upload", sentRequest.url.toString()) + assertEquals("PUT", sentRequest.method) + } finally { + file.delete() + } + } + + @Test + fun uploadUrlStripsTrailingSlash() = + runTest { + val requestSlot = slot() + val mockCall = mockk() + val mockClient = mockk() + + every { mockClient.newCall(capture(requestSlot)) } returns mockCall + every { mockCall.execute() } returns + Response + .Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"url":"https://example.com/hash"}""".toResponseBody()) + .build() + + val client = DesktopBlossomClient(mockClient) + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1)) + + try { + client.upload( + file = file, + contentType = "image/png", + serverBaseUrl = "https://blossom.example.com/", + authHeader = null, + ) + + // Should not have double slash + assertEquals("https://blossom.example.com/upload", requestSlot.captured.url.toString()) + } finally { + file.delete() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt new file mode 100644 index 0000000000..6cef50f6f3 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopMediaCompressorTest { + @Test + fun stripExifReturnsSameFileForPng() { + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "png", file) + + val result = DesktopMediaCompressor.stripExif(file) + + // Should return the same file object since it's not JPEG + assertEquals(file, result) + file.delete() + } + + @Test + fun stripExifReturnsSameFileForTextFile() { + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("not a jpeg") + + val result = DesktopMediaCompressor.stripExif(file) + + assertEquals(file, result) + file.delete() + } + + @Test + fun stripExifReturnsSameFileForMp4() { + val file = File.createTempFile("test_", ".mp4") + file.deleteOnExit() + file.writeBytes(byteArrayOf(0, 0, 0)) + + val result = DesktopMediaCompressor.stripExif(file) + + assertEquals(file, result) + file.delete() + } + + @Test + fun stripExifHandlesJpegWithoutExif() { + // Create a minimal JPEG without EXIF + val file = createMinimalJpeg() + try { + val result = DesktopMediaCompressor.stripExif(file) + // Should return the same file since there's no EXIF to strip + assertEquals(file, result) + } finally { + file.delete() + } + } + + @Test + fun stripExifProcessesJpegFile() { + // Create a JPEG (may or may not have metadata depending on ImageIO) + val file = File.createTempFile("test_", ".jpg") + file.deleteOnExit() + val img = BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "jpg", file) + + val result = DesktopMediaCompressor.stripExif(file) + + // Result should be a valid file regardless + assertTrue(result.exists()) + assertTrue(result.length() > 0) + + // Clean up temp file if different from original + if (result != file) { + result.delete() + } + file.delete() + } + + @Test + fun stripExifHandlesUppercaseJpeg() { + val file = File.createTempFile("test_", ".JPEG") + file.deleteOnExit() + val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "jpg", file) + + val result = DesktopMediaCompressor.stripExif(file) + + assertTrue(result.exists()) + if (result != file) result.delete() + file.delete() + } + + private fun createMinimalJpeg(): File { + val file = File.createTempFile("minimal_", ".jpg") + file.deleteOnExit() + val img = BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB) + ImageIO.write(img, "jpg", file) + return file + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt new file mode 100644 index 0000000000..2bb0f40160 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopMediaMetadataTest { + // --- guessMimeType --- + + @Test + fun guessMimeTypeForJpeg() { + assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpg"))) + assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpeg"))) + assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.JPEG"))) + } + + @Test + fun guessMimeTypeForPng() { + assertEquals("image/png", DesktopMediaMetadata.guessMimeType(File("image.png"))) + } + + @Test + fun guessMimeTypeForGif() { + assertEquals("image/gif", DesktopMediaMetadata.guessMimeType(File("anim.gif"))) + } + + @Test + fun guessMimeTypeForWebp() { + assertEquals("image/webp", DesktopMediaMetadata.guessMimeType(File("image.webp"))) + } + + @Test + fun guessMimeTypeForSvg() { + assertEquals("image/svg+xml", DesktopMediaMetadata.guessMimeType(File("icon.svg"))) + } + + @Test + fun guessMimeTypeForAvif() { + assertEquals("image/avif", DesktopMediaMetadata.guessMimeType(File("photo.avif"))) + } + + @Test + fun guessMimeTypeForVideoFormats() { + assertEquals("video/mp4", DesktopMediaMetadata.guessMimeType(File("clip.mp4"))) + assertEquals("video/webm", DesktopMediaMetadata.guessMimeType(File("clip.webm"))) + assertEquals("video/quicktime", DesktopMediaMetadata.guessMimeType(File("clip.mov"))) + } + + @Test + fun guessMimeTypeForAudioFormats() { + assertEquals("audio/mpeg", DesktopMediaMetadata.guessMimeType(File("song.mp3"))) + assertEquals("audio/ogg", DesktopMediaMetadata.guessMimeType(File("track.ogg"))) + assertEquals("audio/wav", DesktopMediaMetadata.guessMimeType(File("sound.wav"))) + assertEquals("audio/flac", DesktopMediaMetadata.guessMimeType(File("lossless.flac"))) + } + + @Test + fun guessMimeTypeForUnknownExtension() { + assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("data.xyz"))) + assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("noext"))) + } + + // --- compute --- + + @Test + fun computeForPngImage() { + val file = createTempPng(width = 10, height = 5) + try { + val meta = DesktopMediaMetadata.compute(file) + + assertEquals("image/png", meta.mimeType) + assertTrue(meta.size > 0) + assertTrue(meta.sha256.length == 64) // hex-encoded SHA-256 + assertEquals(10, meta.width) + assertEquals(5, meta.height) + assertNotNull(meta.blurhash) + } finally { + file.delete() + } + } + + @Test + fun computeForTextFile() { + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("hello world") + try { + val meta = DesktopMediaMetadata.compute(file) + + assertEquals("application/octet-stream", meta.mimeType) + assertEquals(11L, meta.size) + assertTrue(meta.sha256.isNotEmpty()) + assertNull(meta.width) + assertNull(meta.height) + assertNull(meta.blurhash) + } finally { + file.delete() + } + } + + @Test + fun computeProducesConsistentHash() { + val file = File.createTempFile("hash_", ".txt") + file.deleteOnExit() + file.writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + try { + val meta1 = DesktopMediaMetadata.compute(file) + val meta2 = DesktopMediaMetadata.compute(file) + assertEquals(meta1.sha256, meta2.sha256) + } finally { + file.delete() + } + } + + @Test + fun computeForMp4GivesNoDimensions() { + val file = File.createTempFile("video_", ".mp4") + file.deleteOnExit() + file.writeBytes(byteArrayOf(0, 0, 0)) + try { + val meta = DesktopMediaMetadata.compute(file) + assertEquals("video/mp4", meta.mimeType) + assertNull(meta.width) + assertNull(meta.height) + assertNull(meta.blurhash) + } finally { + file.delete() + } + } + + private fun createTempPng( + width: Int, + height: Int, + ): File { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + // Draw something so blurhash has data + val g = img.createGraphics() + g.color = java.awt.Color.BLUE + g.fillRect(0, 0, width, height) + g.dispose() + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + ImageIO.write(img, "png", file) + return file + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt new file mode 100644 index 0000000000..b2ba3f3ba2 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopUploadOrchestratorTest { + @BeforeTest + fun setup() { + mockkObject(DesktopBlossomAuth) + coEvery { + DesktopBlossomAuth.createUploadAuth(any(), any(), any(), any()) + } returns "Nostr fakeAuthToken" + } + + @AfterTest + fun teardown() { + unmockkObject(DesktopBlossomAuth) + } + + @Test + fun uploadCallsClientWithCorrectParameters() = + runTest { + val mockClient = mockk() + val fileSlot = slot() + val contentTypeSlot = slot() + val urlSlot = slot() + + coEvery { + mockClient.upload( + file = capture(fileSlot), + contentType = capture(contentTypeSlot), + serverBaseUrl = capture(urlSlot), + authHeader = any(), + ) + } returns + BlossomUploadResult( + url = "https://blossom.example.com/abc123.png", + sha256 = "abc123", + size = 100, + ) + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".png") + file.deleteOnExit() + val img = java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB) + javax.imageio.ImageIO.write(img, "png", file) + + val mockSigner = mockk(relaxed = true) + + try { + val result = + orchestrator.upload( + file = file, + alt = "test upload", + serverBaseUrl = "https://blossom.example.com", + signer = mockSigner, + stripExif = false, + ) + + coVerify(exactly = 1) { + mockClient.upload( + file = any(), + contentType = any(), + serverBaseUrl = any(), + authHeader = any(), + ) + } + + assertEquals("https://blossom.example.com", urlSlot.captured) + assertEquals("image/png", contentTypeSlot.captured) + assertNotNull(result.metadata) + assertEquals("image/png", result.metadata.mimeType) + } finally { + file.delete() + } + } + + @Test + fun uploadPassesSameFileWhenNoStripExif() = + runTest { + val mockClient = mockk() + val fileSlot = slot() + + coEvery { + mockClient.upload( + file = capture(fileSlot), + contentType = any(), + serverBaseUrl = any(), + authHeader = any(), + ) + } returns BlossomUploadResult(url = "https://example.com/hash") + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("content") + + val mockSigner = mockk(relaxed = true) + + try { + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = "https://example.com", + signer = mockSigner, + stripExif = false, + ) + + assertEquals(file.absolutePath, fileSlot.captured.absolutePath) + } finally { + file.delete() + } + } + + @Test + fun uploadComputesMetadata() = + runTest { + val mockClient = mockk() + + coEvery { + mockClient.upload(any(), any(), any(), any()) + } returns BlossomUploadResult(url = "https://example.com/hash") + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("hello world") + + val mockSigner = mockk(relaxed = true) + + try { + val result = + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = "https://example.com", + signer = mockSigner, + stripExif = false, + ) + + assertEquals(11L, result.metadata.size) + assertTrue(result.metadata.sha256.length == 64) + assertEquals("application/octet-stream", result.metadata.mimeType) + } finally { + file.delete() + } + } + + @Test + fun uploadPassesAuthHeaderToClient() = + runTest { + val mockClient = mockk() + val authSlot = slot() + + coEvery { + mockClient.upload( + file = any(), + contentType = any(), + serverBaseUrl = any(), + authHeader = captureNullable(authSlot), + ) + } returns BlossomUploadResult(url = "https://example.com/hash") + + val orchestrator = DesktopUploadOrchestrator(mockClient) + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("data") + + val mockSigner = mockk(relaxed = true) + + try { + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = "https://example.com", + signer = mockSigner, + stripExif = false, + ) + + assertEquals("Nostr fakeAuthToken", authSlot.captured) + } finally { + file.delete() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt new file mode 100644 index 0000000000..193aa1019d --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.upload + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopUploadTrackerTest { + @Test + fun initialStateIsIdle() { + val tracker = DesktopUploadTracker() + val state = tracker.state.value + + assertFalse(state.isUploading) + assertNull(state.fileName) + assertNull(state.error) + assertNull(state.result) + } + + @Test + fun startUploadSetsUploadingState() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("photo.jpg") + + val state = tracker.state.value + assertTrue(state.isUploading) + assertEquals("photo.jpg", state.fileName) + assertNull(state.error) + assertNull(state.result) + } + + @Test + fun onSuccessStoresResult() { + val tracker = DesktopUploadTracker() + val metadata = MediaMetadata(sha256 = "abc123", size = 1024, mimeType = "image/png") + val blossom = BlossomUploadResult(url = "https://blossom.example.com/abc123.png") + val result = UploadResult(blossom = blossom, metadata = metadata) + + tracker.startUpload("test.png") + tracker.onSuccess(result) + + val state = tracker.state.value + assertFalse(state.isUploading) + assertNotNull(state.result) + assertEquals("https://blossom.example.com/abc123.png", state.result.blossom.url) + assertNull(state.error) + } + + @Test + fun onErrorStoresErrorMessage() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("test.png") + tracker.onError("Connection refused") + + val state = tracker.state.value + assertFalse(state.isUploading) + assertEquals("Connection refused", state.error) + assertNull(state.result) + } + + @Test + fun resetReturnsToInitialState() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("test.png") + tracker.onError("failed") + tracker.reset() + + val state = tracker.state.value + assertFalse(state.isUploading) + assertNull(state.fileName) + assertNull(state.error) + assertNull(state.result) + } + + @Test + fun stateFlowEmitsLatestValue() = + runTest { + val tracker = DesktopUploadTracker() + + // Initial emission + val initial = tracker.state.first() + assertFalse(initial.isUploading) + + tracker.startUpload("file.mp4") + val uploading = tracker.state.first() + assertTrue(uploading.isUploading) + assertEquals("file.mp4", uploading.fileName) + } + + @Test + fun multipleUploadsOverwriteState() { + val tracker = DesktopUploadTracker() + + tracker.startUpload("first.jpg") + tracker.startUpload("second.png") + + assertEquals("second.png", tracker.state.value.fileName) + } +} diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000000..979b4c9996 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,14 @@ +# TODO + +## DM: Mixed NIP-04 + NIP-17 conversations + +**Question:** What happens when a conversation with an npub has both NIP-04 (kind 4) and NIP-17 (kind 14/15 in GiftWrap) messages? + +**Current behavior to investigate:** +- NIP-04 messages use `PrivateDmEvent.chatroomKey(pubKey)` → creates a `ChatroomKey` based on recipient +- NIP-17 messages use `ChatMessageEvent.chatroomKey(pubKey)` → also creates a `ChatroomKey` based on group members +- Do these produce the **same** `ChatroomKey` for 1-on-1 chats? If yes, both message types merge into one conversation. If no, they appear as separate conversations. +- Android Amethyst handles this — check how `Account.kt` merges them +- Edge cases: timeline ordering, decryption display, NIP-04 messages showing as "legacy" vs NIP-17 + +**Action:** Test with a real conversation that has both types. If they split into two rooms, we need to merge them in `ChatroomListState` or unify the `ChatroomKey` derivation. diff --git a/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md b/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md new file mode 100644 index 0000000000..609a0fdfb6 --- /dev/null +++ b/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md @@ -0,0 +1,230 @@ +# Brainstorm: Advanced Search for Desktop + +**Date:** 2026-03-10 +**Status:** Draft +**Branch:** TBD (`feat/desktop-advanced-search`) + +## What We're Building + +Full-featured advanced search for Amethyst Desktop with: +1. **Twitter-style query operators** (`from:`, `kind:`, `since:`, etc.) that map to NIP-50 Filter fields +2. **Form-based UI** (expandable panel below search bar) for users who don't want to learn syntax +3. **Bidirectional sync** between text operators and form controls — editing one updates the other +4. **Extensible kind presets** — toggle groups like Notes, Articles, Media, Communities +5. **Future AI bridge** (follow-up) — natural language → structured query conversion + +**Philosophy:** Relay-first, pragmatic. Desktop users have resources; we prioritize completeness over privacy. Privacy controls available but not default friction. + +## Why This Approach + +- **No standard Nostr query language exists** — we define one that maps cleanly to `Filter` fields +- **Dual interface (text + form)** — power users get speed, casual users get discoverability +- **Current desktop search is minimal** — only kind 0 + 1, no operators, no kind filtering +- **Android has 30+ kinds** but no query language — desktop leapfrogs with both +- **AI deferred** — core query system must work standalone first; AI is a parsing layer on top + +## How Other Clients Do Search + +| Client | Approach | Operators | Notable | +|--------|----------|-----------|---------| +| **Amethyst Android** | Local cache + NIP-50, 30+ kinds | None (plain text) | Search relay list (kind 10007) | +| **Primal** | Proprietary caching server | Form UI only | Event type, time range, scope dropdowns | +| **Coracle** | NIP-50 + configurable relays | None | Also supports DVM requests (NIP-90) | +| **Damus** | NIP-50 relay search | None | User/profile focused | +| **noStrudel** | Local relay + NIP-50 | None | IndexedDB local indexing | +| **Gossip** | NIP-50 relay search | None | Desktop Rust client | +| **Noogle.lol** | NIP-90 DVM search | `from:npub` `from:me` | Pay-per-query via Lightning | + +**Key insight:** No client ships a query operator language. This is greenfield. + +## Proposed Query Schema + +Operators map directly to `Filter` fields and NIP-50 extensions: + +### Core Operators + +| Operator | Maps To | Example | Notes | +|----------|---------|---------|-------| +| `from:` | `Filter.authors` | `from:npub1abc...` | Resolve names via NIP-05/local cache | +| `kind:` | `Filter.kinds` | `kind:note` or `kind:1` | Named aliases for common kinds | +| `since:` | `Filter.since` | `since:2025-01-01` | ISO 8601 date parsing | +| `until:` | `Filter.until` | `until:2025-06-30` | ISO 8601 date parsing | +| `#` | `Filter.tags["t"]` | `#bitcoin` | Hashtag filter | +| `"exact phrase"` | Quoted in `Filter.search` | `"lightning network"` | Relay-dependent support | +| `-` | Client-side exclusion | `-spam` | Post-filter after relay results | + +### NIP-50 Extension Operators + +| Operator | NIP-50 Extension | Example | +|----------|-----------------|---------| +| `lang:` | `language:xx` | `lang:en` | +| `domain:` | `domain:xx` | `domain:nostr.com` | +| `nsfw:` | `nsfw:xx` | `nsfw:false` | + +### Kind Name Aliases + +| Alias | Kind(s) | Description | +|-------|---------|-------------| +| `note` | 1 | Short text note | +| `article` | 30023 | Long-form content | +| `repost` | 6 | Reposts | +| `reply` | 1 (with `e` tag) | Replies (client-side filter) | +| `media` | 1 (with `imeta` tag) | Notes with media | +| `channel` | 40, 41, 42 | Public channels | +| `live` | 30311 | Live activities | +| `community` | 34550 | Communities | +| `wiki` | 30818 | Wiki pages | +| `video` | 34235 | Video events | +| `classified` | 30402 | Classifieds | +| `profile` | 0 | Metadata/profiles | + +### Boolean Logic + +| Syntax | Behavior | +|--------|----------| +| `bitcoin lightning` | AND (default) | +| `bitcoin OR lightning` | OR — requires multiple relay queries | +| `-spam` | NOT — client-side exclusion | + +## UX Design + +### Search Bar (Default State) +``` +[ Search notes, people, tags... ] [Advanced v] +``` + +### Expanded Advanced Panel +``` +[ from:npub1abc kind:note since:2025-01 bitcoin ] [Advanced ^] ++------------------------------------------------------------------+ +| Content Type: [x] Notes [ ] Articles [ ] Media [ ] All | +| Author: [ npub or name... ] [+ Add] | +| Date Range: [ 2025-01-01 ] to [ today ] | +| Language: [ Any v ] | +| Hashtags: [ #bitcoin ] [+ Add] | +| Exclude: [ spam, nsfw... ] [+ Add] | +| | +| [Clear Filters] [Search] | ++------------------------------------------------------------------+ +``` + +### Bidirectional Sync +- Typing `from:npub1abc` in search bar → Author field populates in panel +- Selecting "Articles" checkbox → `kind:article` appears in search bar +- Editing either side updates the other in real-time +- Form is the "visual representation" of the query string + +### Result Display +- Results grouped by type: People, Notes, Articles, Channels +- Each result shows: content preview, author, timestamp, kind badge +- Infinite scroll with "Load more from relays" button +- Sort: Relevance (default, relay-determined) or Chronological + +## Architecture + +### Query Pipeline + +``` +User Input (text or form) + | + v +QueryParser (commons/commonMain) + |-- Tokenize operators: from:, kind:, since:, etc. + |-- Resolve names → hex pubkeys (local cache + NIP-05) + |-- Parse dates → unix timestamps + |-- Map kind aliases → kind numbers + | + v +SearchQuery (data class in commons) + |-- text: String (free text for NIP-50 search field) + |-- authors: List + |-- kinds: List + |-- since: Long? + |-- until: Long? + |-- tags: Map> + |-- excludeTerms: List + |-- language: String? + |-- nip50Extensions: Map + | + v +FilterBuilder (desktop) + |-- Convert SearchQuery → List + |-- Split by kind groups (like Android: 3 filters, ~10 kinds each) + |-- Inject NIP-50 extensions into search string + | + v +Relay Subscription + |-- Use search relay list (kind 10007) or connected relays + |-- Send REQ with filters + |-- Aggregate results + | + v +Client-side Post-filter + |-- Apply exclusions (-term) + |-- Apply "reply" detection (has e tag) + |-- Apply "media" detection (has imeta tag) + | + v +Results Display +``` + +### Module Placement + +| Component | Module | Rationale | +|-----------|--------|-----------| +| `QueryParser` | `commons/commonMain` | Reusable for Android later | +| `SearchQuery` | `commons/commonMain` | Shared data model | +| `QuerySerializer` | `commons/commonMain` | SearchQuery ↔ string conversion | +| `AdvancedSearchPanel` | `desktopApp` | Desktop-specific UI | +| `SearchScreen` (updated) | `desktopApp` | Desktop layout | +| `FilterBuilder` (updated) | `desktopApp` | Desktop filter assembly | +| Kind alias registry | `commons/commonMain` | Shared kind name mapping | + +### Search Relay Management + +- Use existing `SearchRelayListEvent` (kind 10007) from quartz +- Desktop UI to configure search relays (settings page) +- Default fallback: `relay.nostr.band`, `nostr.wine`, `relay.damus.io` +- Future: auto-discover NIP-50 capable relays via NIP-11 + +## Privacy Considerations + +| Concern | Mitigation | Default | +|---------|-----------|---------| +| Relay sees search queries | User-configurable search relay list | On (kind 10007) | +| Relay sees IP + query | VPN/Tor support (system-level) | Not enforced | +| Query history stored | No server-side history; client-side optional | Off | +| NIP-05 resolution leaks interest | Cache NIP-05 lookups locally | On | +| Author search reveals social graph | Already visible via follow lists | N/A | + +**Stance:** Relay-first, pragmatic. Desktop users accept relay visibility for better results. Advanced users can configure search relays or use Tor. + +## Key Decisions + +1. **Query language is NOT a Nostr standard** — it's a client-side UX convention that maps to `Filter` fields +2. **Bidirectional sync** between text bar and form panel — single source of truth (`SearchQuery` data class) +3. **Kind presets with extensibility** — start with core groups, users can toggle individual kinds +4. **Relay-first execution** — NIP-50 search as primary, local cache as supplement +5. **AI deferred** — follow-up feature, will parse natural language → `SearchQuery` +6. **Query parser in commons** — shared module so Android can adopt later +7. **Client-side post-filtering** for operators relays can't handle (exclusions, reply detection, media detection) + +## Resolved Questions + +1. **Name resolution** — Async + refine. Search immediately with text, resolve `from:name` in background, refine results when pubkey resolved. No blocking. +2. **OR queries** — Yes in v1. `bitcoin OR lightning` sends parallel relay subscriptions, merges results. +3. **Search history** — Recent history (last 20) stored locally. +4. **Saved searches** — Yes in v1. Pin/save queries. Future: persist as Nostr events. +5. **Result caching** — Session cache only (in-memory). Cleared on app restart. No disk persistence. + +## Open Questions + +None — all resolved. + +## Follow-up Features (Out of Scope) + +- AI natural language → query parsing (local Ollama / cloud API / NIP-90 DVM) +- Local SQLite FTS5 index for offline search +- NIP-90 DVM search integration +- Search analytics / trending topics +- Collaborative search (shared saved searches via Nostr events) diff --git a/docs/brainstorms/2026-03-16-blossom-protocol-research.md b/docs/brainstorms/2026-03-16-blossom-protocol-research.md new file mode 100644 index 0000000000..1030342688 --- /dev/null +++ b/docs/brainstorms/2026-03-16-blossom-protocol-research.md @@ -0,0 +1,604 @@ +# Blossom Protocol Research + +**Date**: 2026-03-16 +**Sources**: hzrd149/blossom GitHub (BUD specs), NIP-B7, Nostrify docs, Amethyst upstream codebase, Primal blog posts + +--- + +## Overview + +Blossom (**Bl**obs **O**n **S**imple **S**erver**om**... or something) is a specification for HTTP endpoints that let users store binary blobs on publicly accessible servers. Blobs are content-addressed by their **SHA-256 hash**. Uses Nostr keypairs for identity and authorization. + +**Two Nostr event kinds:** +- **Kind 24242** -- Authorization token (BUD-11) +- **Kind 10063** -- User's Blossom server list (BUD-03, NIP-B7) + +**BUD index (BUD-00 through BUD-11):** + +| BUD | Name | Status | Required | +|-----|------|--------|----------| +| 00 | BUD framework | - | - | +| 01 | Server requirements + blob retrieval | draft | mandatory | +| 02 | Upload + management | draft | optional | +| 03 | User server list (kind 10063) | draft | optional | +| 04 | Mirroring | draft | optional | +| 05 | Media optimization | draft | optional | +| 06 | Upload requirements (HEAD preflight) | draft | optional | +| 07 | Payment required (402) | draft | optional | +| 08 | NIP-94 file metadata tags | draft | optional | +| 09 | Blob report | draft | optional | +| 10 | Blossom URI scheme | draft | optional | +| 11 | Nostr authorization | draft | optional | + +--- + +## BUD-01: Server Requirements + Blob Retrieval + +**Status:** `draft` `mandatory` + +### CORS + +All responses MUST set `Access-Control-Allow-Origin: *`. + +Preflight (`OPTIONS`) responses MUST also set: +``` +Access-Control-Allow-Headers: Authorization, * +Access-Control-Allow-Methods: GET, HEAD, PUT, DELETE +``` + +MAY set `Access-Control-Max-Age: 86400` (cache 24h). + +### Error Responses + +Any 4xx/5xx response MAY include `X-Reason` header with human-readable error message. + +### Endpoints + +All endpoints served from domain root. No path prefix. + +#### GET / -- Get Blob + +```http +GET /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1 +Host: cdn.example.com +``` + +Response: +```http +HTTP/1.1 200 OK +Content-Type: application/pdf +Content-Length: 184292 + + +``` + +- MUST accept optional file extension in URL (`.pdf`, `.png`, etc.) +- MUST return correct `Content-Type` regardless of extension +- MUST default to `application/octet-stream` if MIME unknown +- MAY require authorization (BUD-11) + +**Proxying/Redirection:** +- 3xx redirects MUST redirect to URL containing same SHA-256 hash +- Destination MUST set `Access-Control-Allow-Origin: *`, `Content-Type`, `Content-Length` + +**Range Requests:** +- Servers SHOULD support `Range` header (RFC 7233) on GET +- Signal via `Accept-Ranges: bytes` and `Content-Length` on HEAD + +#### HEAD / -- Has Blob + +Identical to GET but MUST NOT return body. MUST return same `Content-Type` and `Content-Length` headers. + +--- + +## BUD-02: Upload + Management + +**Status:** `draft` `optional` + +### Blob Descriptor + +The standard JSON response for all upload/mirror operations: + +```json +{ + "url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf", + "sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553", + "size": 184292, + "type": "application/pdf", + "uploaded": 1725105921 +} +``` + +Fields: +- `url` -- Public URL to `GET /` endpoint **with file extension** +- `sha256` -- Hex-encoded SHA-256 of the blob +- `size` -- Size in bytes +- `type` -- MIME type (fallback `application/octet-stream`) +- `uploaded` -- Unix timestamp + +MAY include: `magnet`, `infohash`, `ipfs` + +### PUT /upload -- Upload Blob + +```http +PUT /upload HTTP/1.1 +Host: cdn.example.com +Authorization: Nostr +Content-Type: image/png +Content-Length: 184292 +X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553 + + +``` + +Response (success): +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "url": "https://cdn.example.com/b167...553.png", + "sha256": "b167...553", + "size": 184292, + "type": "image/png", + "uploaded": 1725105921 +} +``` + +Key rules: +- Server MUST NOT modify the blob +- Server MUST compute SHA-256 over exact bytes received +- Client SHOULD include `Content-Type` and `Content-Length` +- Client MAY provide `X-SHA-256` header (hex lowercase) +- Server MAY use `X-SHA-256` for pre-upload rejection policies +- Success: 2xx with Blob Descriptor +- Failure: 4xx with error message + +### GET /list/ -- List Blobs (Unrecommended) + +Optional. Returns JSON array of Blob Descriptors for a pubkey. + +Query params: +- `cursor` -- SHA-256 of last blob (cursor-based pagination) +- `limit` -- Max results +- `since`/`until` -- Filter by upload date (deprecated for pagination) + +Sorted by `uploaded` descending. + +### DELETE / -- Delete Blob + +```http +DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1 +Host: cdn.example.com +Authorization: Nostr +``` + +- Multiple `x` tags in auth token MUST NOT be interpreted as batch delete + +--- + +## BUD-03: User Server List + +**Kind 10063** (replaceable event). + +```json +{ + "kind": 10063, + "tags": [ + ["server", "https://cdn.self.hosted"], + ["server", "https://cdn.satellite.earth"], + ["alt", "File servers used by the author"] + ], + "content": "" +} +``` + +- Tag order = priority. Most trusted/reliable first. +- Clients MUST upload to at least the first server in user's list. +- Clients MAY mirror to other listed servers via BUD-04. + +**Discovery flow when URL breaks:** +1. Extract 64-char hex hash from broken URL +2. Fetch author's kind:10063 event +3. Try each listed server in order +4. Fall back to well-known servers + +--- + +## BUD-04: Mirroring + +**Status:** `draft` `optional` + +### PUT /mirror -- Mirror Blob + +```http +PUT /mirror HTTP/1.1 +Host: backup-server.example.com +Authorization: Nostr +Content-Type: application/json + +{ + "url": "https://cdn.satellite.earth/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf" +} +``` + +Response: Blob Descriptor (same as upload). + +Key rules: +- Server downloads blob from provided URL +- Server SHOULD use `Content-Type` from origin server +- Server verifies downloaded blob hash matches `x` tag in auth token +- Returns 2xx + Blob Descriptor on success, 4xx on failure + +**Typical flow:** +1. Client uploads to Server A, gets Blob Descriptor with URL +2. Client sends URL to Server B's `/mirror` with same upload auth token +3. Server B downloads from Server A +4. Server B verifies hash matches `x` tag +5. Server B returns Blob Descriptor + +--- + +## BUD-05: Media Optimization + +**Status:** `draft` `optional` + +### PUT /media -- Optimized Upload + +```http +PUT /media HTTP/1.1 +Host: trusted-server.example.com +Authorization: Nostr +Content-Type: image/png +Content-Length: 4194304 + + +``` + +Response: Blob Descriptor -- but hash will differ from input because server transforms the file. + +Key differences from `/upload`: +- Server MAY modify/optimize the blob (strip EXIF, compress, transcode) +- The returned SHA-256 will be of the **optimized** blob, not the original +- Client has NO control over optimization process +- `t` tag in auth event must be `media` (not `upload`) + +### HEAD /media + +Same as HEAD /upload (BUD-06) but for the media endpoint. + +### Client Implementation Pattern + +1. User selects a "trusted processing" server +2. Client uploads original media to `/media` on trusted server +3. Gets back optimized blob descriptor (new hash) +4. Client signs new upload auth for the optimized hash +5. Calls `/mirror` on other servers to distribute the optimized blob + +This is what Primal does -- all Primal 2.2+ apps use `/media` by default, strips metadata, then mirrors. + +--- + +## BUD-06: Upload Requirements (HEAD Preflight) + +**Status:** `draft` `optional` + +### HEAD /upload -- Pre-flight Check + +Client sends blob metadata, server says yes/no before actual upload. + +Request: +```http +HEAD /upload HTTP/1.1 +Host: cdn.example.com +X-Content-Type: application/pdf +X-Content-Length: 184292 +X-SHA-256: 88a74d0b866c8ba79251a11fe5ac807839226870e77355f02eaf68b156522576 +Authorization: Nostr +``` + +Success: +```http +HTTP/1.1 200 OK +``` + +Failure examples: +```http +HTTP/1.1 400 Bad Request +X-Reason: Invalid X-SHA-256 header format. Expected a string. + +HTTP/1.1 401 Unauthorized +X-Reason: Authorization required for uploading video files. + +HTTP/1.1 403 Forbidden +X-Reason: SHA-256 hash banned. + +HTTP/1.1 411 Length Required +X-Reason: Missing X-Content-Length header. + +HTTP/1.1 413 Content Too Large +X-Reason: File too large. Max allowed size is 100MB. + +HTTP/1.1 415 Unsupported Media Type +X-Reason: Unsupported file type. +``` + +**Note:** Uses `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers (not standard `Content-*`). + +--- + +## BUD-07: Payment Required + +Servers MAY return `402 Payment Required` with payment method headers: + +```http +HTTP/1.1 402 Payment Required +X-Cashu: "" +X-Lightning: "" +``` + +After payment, client retries with proof: +- Cashu: serialized `cashuB` token per NUT-24 +- Lightning: preimage of the BOLT-11 payment + +HEAD requests inform about cost but should not be retried with payment; proceed to PUT/GET after paying. + +--- + +## BUD-08: NIP-94 File Metadata Tags + +Servers MAY include a `nip94` field in Blob Descriptor responses: + +```json +{ + "url": "https://cdn.example.com/b167...553.pdf", + "sha256": "b167...553", + "size": 184292, + "type": "application/pdf", + "uploaded": 1725105921, + "nip94": [ + ["url", "https://cdn.example.com/b167...553.pdf"], + ["m", "application/pdf"], + ["x", "b167...553"], + ["size", "184292"], + ["magnet", "magnet:?xt=urn:btih:..."], + ["i", "infohash-here"] + ] +} +``` + +Follows NIP-94 tag format as KV pairs. Allows clients to get standardized metadata without separate requests. + +--- + +## BUD-09: Blob Report + +### PUT /report + +Body: signed NIP-56 report event (kind 1984): + +```json +{ + "kind": 1984, + "content": "This blob contains illegal content", + "tags": [ + ["x", "", "illegal"], + ["p", ""] + ] +} +``` + +Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal. + +--- + +## BUD-10: Blossom URI Scheme + +Format: +``` +blossom:.[?param1=value1¶m2=value2...] +``` + +Example: +``` +blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.example.com&xs=backup.example.com&as=&sz=184292 +``` + +Query parameters: +- `xs` -- Server domain hints (tried first). Repeatable. +- `as` -- Author hex pubkey for BUD-03 server list lookup. Repeatable. +- `sz` -- Size in bytes. + +**Resolution priority:** +1. Direct server hints (`xs`) via `GET /` +2. Author server lists (fetch kind:10063 for each `as` pubkey) +3. Fallback to well-known servers or local cache + +--- + +## BUD-11: Authorization + +### Kind 24242 Event Structure + +```json +{ + "id": "", + "pubkey": "", + "created_at": 1725105921, + "kind": 24242, + "tags": [ + ["t", "upload"], + ["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"], + ["expiration", "1725109521"], + ["server", "cdn.example.com"] + ], + "content": "Upload cat photo", + "sig": "" +} +``` + +### Required Tags + +| Tag | Description | +|-----|-------------| +| `t` | Action verb: `get`, `upload`, `list`, `delete`, `media` | +| `expiration` | Unix timestamp when token expires (NIP-40) | + +### Optional Tags + +| Tag | Description | +|-----|-------------| +| `x` | SHA-256 hash of specific blob. Multiple allowed. | +| `server` | Domain restriction (lowercase). Multiple allowed. | +| `size` | File size in bytes (Amethyst adds this) | + +### Authorization Header + +``` +Authorization: Nostr +``` + +Note: Amethyst uses standard Base64 (not Base64url), which works in practice. + +### Endpoint Authorization Requirements + +| Endpoint | `t` tag | Hash source | `x` tag | +|----------|---------|-------------|---------| +| GET/HEAD / | `get` | URL path | optional | +| PUT /upload | `upload` | X-SHA-256 header | required | +| HEAD /upload | `upload` | X-SHA-256 header | required | +| DELETE / | `delete` | URL path | required | +| GET /list/ | `list` | -- | N/A | +| PUT /mirror | `upload` | mirrored blob hash | required | +| PUT /media | `media` | X-SHA-256 header | required | +| HEAD /media | `media` | X-SHA-256 header | required | + +### Validation Checklist (Server) + +1. Event kind == 24242 +2. `created_at` < now +3. `expiration` > now +4. `t` tag matches endpoint action +5. `server` tags (if present) include this server's domain +6. `x` tags (if required) match the blob hash + +### Security Note + +Unscoped tokens (no `server` tag) can be replayed to other servers. Always scope `delete` tokens. + +--- + +## Error Handling -- HTTP Status Codes + +| Code | Meaning | When | +|------|---------|------| +| 200 | Success | GET, HEAD, successful upload/mirror/delete | +| 3xx | Redirect | GET with CDN redirect (must preserve hash in URL) | +| 400 | Bad Request | Invalid headers, malformed auth | +| 401 | Unauthorized | Missing or invalid authorization | +| 402 | Payment Required | BUD-07 paid servers | +| 403 | Forbidden | Hash banned, user blocked | +| 404 | Not Found | Blob doesn't exist | +| 411 | Length Required | Missing Content-Length / X-Content-Length | +| 413 | Content Too Large | File exceeds server limit | +| 415 | Unsupported Media Type | Server doesn't accept this MIME type | + +All error responses MAY include `X-Reason` header. + +--- + +## Popular Blossom Servers + +| Server | Limits | Notes | +|--------|--------|-------| +| `blossom.nostr.build` | 100 MiB hard, 20 MiB free | Run by nostr.build team. Supports BUD-01,02,04,05,06,08 | +| `blossom.band` | 100 MiB hard, 20 MiB free | Community server | +| `blossom.primal.net` | Integrated with Primal stack | Uses /media by default, strips metadata | +| `cdn.satellite.earth` | Unknown | Satellite CDN | +| `blossom.azzamo.net` | Free tier + premium | Azzamo's server | +| `blosstr.com` | Enterprise-grade | Commercial offering | + +Rate limiting is not standardized in the protocol. Each server implements its own policies. Free tiers generally have stricter limits. BUD-06 HEAD preflight is the mechanism for discovering server limitations before uploading. + +--- + +## Client Implementations + +### Amethyst (Kotlin -- upstream) + +**Quartz library** (`quartz/src/commonMain/kotlin/.../nipB7Blossom/`): +- `BlossomAuthorizationEvent` -- Kind 24242 event creation (get, upload, delete, list) +- `BlossomServersEvent` -- Kind 10063 server list management +- `BlossomUploadResult` -- Blob Descriptor deserialization (kotlinx.serialization) +- `BlossomUri` -- BUD-10 URI parsing/serialization + +**Android app** (`amethyst/src/main/java/.../service/uploads/blossom/`): +- `BlossomUploader` -- PUT /upload + DELETE implementation using OkHttp +- `BlossomServerResolver` -- BUD-10 URI resolution with LruCache +- `ServerHeadCache` -- HEAD request caching for blob existence checks +- `UploadOrchestrator` -- Orchestrates NIP-95, NIP-96, and Blossom uploads + +**Upload flow:** +1. Read file, compute SHA-256 hash + size +2. Compute blurhash metadata locally +3. Create kind 24242 auth event (t=upload, x=hash, expiration=+1hr) +4. Base64-encode auth event JSON +5. PUT /upload with `Authorization: Nostr `, `Content-Type`, `Content-Length` +6. Parse Blob Descriptor response +7. Download + verify the uploaded file (re-hash check) + +**Key:** Amethyst does NOT use `/media` endpoint. Uses `/upload` only. No mirroring implemented. + +### Primal + +- All Primal 2.2+ apps use `/media` (BUD-05) by default +- Strips all metadata before saving +- Optionally mirrors to other Blossom servers per user settings +- Deeply integrated into Primal stack, enabled by default + +### Nostrify (TypeScript/Web) + +```typescript +const uploader = new BlossomUploader({ + servers: ['https://blossom.primal.net/'], + signer: window.nostr, + expiresIn: 60, // seconds +}); + +const tags = await uploader.upload(file); +// Returns NIP-94 tags: url, x, ox, size, m +``` + +- `ox` tag = original hash (before server processing) +- `x` tag = final hash (after optimization if /media used) + +### NDK Blossom (@nostr-dev-kit/ndk-blossom) + +npm package wrapping BUD-01 through BUD-06. TypeScript. + +### Dart NDK (dart-nostr.com) + +Has Blossom use case documentation. Flutter/Dart integration. + +--- + +## Key Protocol Design Decisions + +1. **Content addressing via SHA-256** -- Same file = same hash everywhere. Deduplication is free. +2. **Servers are interchangeable** -- Any server with the blob can serve it. URLs break? Find the hash elsewhere. +3. **No server-side processing on /upload** -- Bit-perfect storage. Hash computed over exact bytes received. +4. **/media is the exception** -- Trusted server processes/optimizes. New hash for result. +5. **Authorization is opt-in per endpoint** -- Servers choose what to protect. +6. **User controls server list** -- Kind 10063 event = user's preferred servers. +7. **Mirror for redundancy** -- Upload once, mirror to N servers. + +--- + +## Unanswered Questions + +- Does BUD-11 require base64url (no padding) or standard base64? Spec says base64url, Amethyst uses standard base64 -- servers seem to accept both. +- What's the recommended expiration window for auth tokens? Amethyst uses 1 hour. +- How do clients handle the `/media` flow when the optimized hash differs from original? Need to re-sign auth for mirror requests with the new hash. +- Is there a standard way to discover server capabilities (which BUDs supported)? Not currently -- no capability endpoint defined. +- How to handle upload failures mid-stream for large files? No chunked upload in spec. +- Server-side dedup behavior when same hash uploaded by different users? Implementation-specific. diff --git a/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md new file mode 100644 index 0000000000..7793b10901 --- /dev/null +++ b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md @@ -0,0 +1,468 @@ +# Brainstorm: Desktop Media — Full Parity + +**Date:** 2026-03-16 +**Status:** Draft +**Branch:** TBD (`feat/desktop-media`) + +## What We're Building + +Full media functionality for Amethyst Desktop — display, upload, gallery, lightbox, video playback, encrypted media, and desktop-native UX. Feature parity with Android Amethyst's media stack, adapted for mouse-first desktop interaction. + +### Scope + +| Feature | Included | Notes | +|---------|----------|-------| +| Image display in notes | Yes | Coil3 AsyncImage, blurhash previews | +| Video playback | Yes | VLCJ with bundled libvlc | +| Blossom upload | Yes | Extract to commons, Blossom-only (no NIP-96) | +| Drag-drop / clipboard paste | Yes | Essential desktop UX from day 1 | +| Lightbox / zoom | Yes | Full-screen media viewer with zoom | +| Image gallery carousel | Yes | Multi-image posts | +| Profile gallery (NIP-68) | Yes | Kind 20 picture posts, create + view | +| Video events (NIP-71) | Yes | Kind 21/22 display | +| Encrypted media (NIP-17 DMs) | Yes | Full send + receive | +| Media server management | Yes | Blossom server list (kind 10063) | +| Audio/voice playback | Yes | MP3, OGG, FLAC, WAV | +| Media compression | Yes | Desktop-adapted (Java ImageIO) | +| Blurhash generation on upload | Yes | Already in commons/ | +| EXIF stripping | Yes | Privacy: strip metadata before upload | +| Alt text / accessibility | Yes | NIP-92 imeta alt field | + +### Out of Scope (for now) + +- NIP-96 upload (deprecated, Blossom replaces it) +- Voice recording (microphone capture — desktop-specific, complex) +- Picture-in-picture video +- Live streaming (NIP-53) +- Torrent/magnet distribution + +## Why This Approach + +### Blossom Only (No NIP-96) + +NIP-96 is officially marked "unrecommended: replaced by blossom APIs" in the NIP registry. Building desktop from scratch gives us the opportunity to skip legacy protocol support entirely. + +**Blossom advantages:** +- Content-addressed (SHA-256) — files portable across servers +- Native mirroring (BUD-04) — upload once, mirror to N servers +- Server-side optimization (BUD-05) — `/media` endpoint +- Payment support (BUD-07) — Cashu/Lightning +- Clean URI scheme (BUD-10) — `blossom:.` + +### Extraction Strategy — Layered Architecture + +Android's BlossomUploader is tightly coupled to Android (`Context`, `Uri`, `ContentResolver`). We need to separate the HTTP upload protocol from platform file access. + +**Layer 1: commons/commonMain — Pure Blossom protocol client** +- `BlossomClient` — HTTP PUT /upload, /mirror, /media, DELETE, GET /list. Takes `InputStream` + metadata. Returns `BlobDescriptor`. No platform deps. +- `BlossomAuthHelper` — Creates kind 24242 auth events, base64-encodes for Authorization header +- `BlossomServerDiscovery` — Queries kind 10063, resolves server list, caches +- `MediaUploadResult` — Result data class (already platform-agnostic) +- `UploadOrchestrator` — Coordinates upload to server + optional optimization + mirroring +- `MultiUploadOrchestrator` — Manages parallel upload of multiple files +- `MediaUploadTracker` — StateFlow-based progress tracking +- `ServerHeadCache` — BUD-06 pre-flight response cache + +**Layer 2: commons/commonMain — expect/actual for platform file operations** +``` +expect fun readFileBytes(path: String): ByteArray +expect fun computeFileSha256(path: String): String +expect fun getMimeType(path: String): String? +expect fun getFileSize(path: String): Long +expect class MediaMetadataExtractor { + fun extractDimensions(path: String): Pair? + fun extractBlurhash(path: String): String? +} +expect class MediaCompressor { + fun compressImage(path: String, quality: Float): String + fun stripExif(path: String): String +} +``` + +**Layer 3: Platform actuals** +- `androidMain/` — Uses `ContentResolver`, `Uri`, Android Bitmap, `MediaMetadataRetriever` +- `jvmMain/` — Uses `java.io.File`, Java ImageIO, `metadata-extractor`, BufferedImage→blurhash + +**Layer 4: Platform UI (desktopApp/ and amethyst/)** +- File pickers, drag-drop handlers, video players, lightbox composables + +This design lets any future client (iOS, web) reuse Layer 1 + 2 by providing Layer 3 actuals. + +### Coil3 for Image Loading + +Coil3 officially supports Compose Multiplatform including JVM/Desktop. Android Amethyst already uses Coil3. Benefits: +- Disk + memory caching +- Custom fetchers (Blossom URI, blurhash, base64) +- Crossfade animations +- SVG support + +### VLCJ for Video + +ExoPlayer is Android-only (Media3). VLCJ wraps VLC's libvlc via JNA — supports every format VLC does (mp4, webm, m3u8, mkv, etc.). Compose integration via `SwingPanel` or offscreen rendering. We bundle libvlc with the app (~100MB) for zero user setup. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Upload protocol | Blossom only | NIP-96 deprecated | +| Upload code location | commons/commonMain | Share with Android | +| Image loading | Coil3 | Already used on Android, KMP support | +| Video playback | VLCJ (bundled libvlc) | ExoPlayer is Android-only | +| Desktop input | Drag-drop + paste + file picker | Essential for desktop UX | +| Encrypted media | Full support | DM file sharing parity | +| Profile gallery | Yes (NIP-68 kind 20) | Create + view | + +## Existing Codebase Audit + +### Already Shared (commons/) + +| Component | Location | Status | +|-----------|----------|--------| +| BlurHashDecoder/Encoder | `commons/blurhash/` | Ready | +| PlatformImage (expect/actual) | `commons/blurhash/PlatformImage.kt` | Ready (JVM uses BufferedImage) | +| BitmapUtils (JVM) | `commons/blurhash/BitmapUtils.jvm.kt` | Ready | +| Base64ImagePlatform (JVM) | `commons/base64Image/` | Ready | +| RichTextParser | `commons/richtext/RichTextParser.kt` | Ready (classifies URLs as image/video) | +| MediaContentModels | `commons/richtext/MediaContentModels.kt` | Ready (MediaUrlImage, MediaUrlVideo, etc.) | +| UrlParser | `commons/richtext/UrlParser.kt` | Ready | +| Image extensions list | RichTextParser companion | png, jpg, gif, bmp, jpeg, webp, svg, avif | +| Video extensions list | RichTextParser companion | mp4, avi, wmv, mpg, amv, webm, mov + audio | + +### In Quartz (protocol layer, shared) + +| Component | Location | Status | +|-----------|----------|--------| +| BlossomAuthorizationEvent | `quartz/nipB7Blossom/` | Ready (kind 24242) | +| BlossomServersEvent | `quartz/nipB7Blossom/` | Ready (kind 10063) | +| BlossomUri | `quartz/nipB7Blossom/` | Ready (blossom: URI parsing) | +| BlossomUploadResult | `quartz/nipB7Blossom/` | Ready | +| FileHeaderEvent (NIP-94) | `quartz/nip94FileMetadata/` | Ready (kind 1063) | +| BlurhashTag | `quartz/nip94FileMetadata/tags/` | Ready | +| DimensionTag | `quartz/nip94FileMetadata/tags/` | Ready | +| IMetaTag/Builder (NIP-92) | `quartz/nip92IMeta/` | Ready | +| PictureEvent (NIP-68) | `quartz/nip68Picture/` | Ready (kind 20) | +| VideoEvent (NIP-71) | `quartz/nip71Video/` | Ready (kind 21/22/34235/34236) | +| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | Ready | +| FileServersEvent (NIP-96) | `quartz/nip96FileStorage/` | Exists but we're skipping NIP-96 | +| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Ready (encrypted DM files) | + +### Android-Only (needs extraction or desktop equivalent) + +| Component | Location | Action | +|-----------|----------|--------| +| BlossomUploader | `amethyst/service/uploads/blossom/` | Extract HTTP logic to commons | +| Nip96Uploader | `amethyst/service/uploads/nip96/` | Skip (Blossom only) | +| UploadOrchestrator | `amethyst/service/uploads/` | Extract to commons | +| MultiOrchestrator | `amethyst/service/uploads/` | Extract to commons | +| MediaUploadResult | `amethyst/service/uploads/` | Extract (already platform-agnostic) | +| MediaCompressor | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO) | +| BlurhashMetadataCalculator | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO + commons blurhash) | +| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/` | Extract to commons | +| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/` | Extract to commons | +| ImageLoaderSetup | `amethyst/service/images/` | Desktop Coil3 config | +| BlossomFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blossom: URIs) | +| BlurHashFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blurhash) | +| Base64Fetcher | `amethyst/service/images/` | Extract to commons | +| ZoomableContentView | `amethyst/ui/components/` | Desktop lightbox equivalent | +| ZoomableContentDialog | `amethyst/ui/components/` | Desktop lightbox dialog | +| ImageGallery | `amethyst/ui/components/` | Extract carousel to commons | +| MyAsyncImage | `amethyst/ui/components/` | Desktop equivalent | +| VideoView/VideoViewInner | `amethyst/service/playback/` | Desktop video player (VLCJ) | +| ExoPlayerPool/Builder | `amethyst/service/playback/` | Desktop player pool equivalent | +| MediaAspectRatioCache | `amethyst/model/` | Extract to commons | +| NewMediaView/Model | `amethyst/ui/actions/` | Desktop media post composer | +| MediaUploadTracker | `amethyst/ui/actions/uploads/` | Extract to commons | +| SelectFromGallery | `amethyst/ui/actions/uploads/` | Desktop file picker | +| ShowImageUploadItem | `amethyst/ui/actions/uploads/` | Extract upload preview to commons | +| ChatFileSender/Uploader | `amethyst/ui/screen/chats/` | Desktop encrypted upload | +| BlossomServersViewModel | `amethyst/ui/actions/mediaServers/` | Extract to commons | +| GalleryThumb | `amethyst/ui/screen/profile/gallery/` | Desktop gallery grid | +| PictureDisplay | `amethyst/ui/note/types/` | Extract to commons | +| VideoDisplay | `amethyst/ui/note/types/` | Desktop video renderer | +| VoiceTrack | `amethyst/ui/note/types/` | Desktop audio player | + +### Desktop-Specific (new code) + +| Component | Location | Notes | +|-----------|----------|-------| +| DesktopImageLoader setup | `desktopApp/` or `commons/jvmMain/` | Coil3 disk cache config for desktop | +| DesktopVideoPlayer | `desktopApp/` | VLCJ wrapper composable | +| DesktopFilePicker | `desktopApp/` | JFileChooser / AWT FileDialog | +| DragDropHandler | `desktopApp/` | Compose Desktop DnD API | +| ClipboardPasteHandler | `desktopApp/` | AWT clipboard image reading | +| DesktopMediaCompressor | `commons/jvmMain/` | Java ImageIO-based compression | +| DesktopBlurhashCalculator | `commons/jvmMain/` | BufferedImage → blurhash | +| DesktopExifStripper | `commons/jvmMain/` | metadata-extractor (lossless EXIF removal) | +| MediaScreen (desktop) | `desktopApp/` | Desktop media gallery/feed layout | +| UploadDialog (desktop) | `desktopApp/` | Upload progress, server selection, alt text | + +## Blossom Protocol Implementation + +### Upload Flow (BUD-02) + +``` +1. User drops/pastes/picks file +2. Client computes SHA-256 locally +3. (Optional) HEAD /upload pre-flight check (BUD-06) +4. Sign kind 24242 auth event (BUD-11) with t=upload +5. PUT /upload with binary body + Authorization header +6. Server returns BlobDescriptor {url, sha256, size, type, uploaded} +7. (Optional) PUT /media for server-side optimization (BUD-05) +8. (Optional) PUT /mirror to additional servers (BUD-04) +9. Client adds imeta tag to note event (NIP-92) +``` + +### Server Discovery (BUD-03) + +``` +1. Query user's kind 10063 event from relays +2. Parse server URLs from ["server", "https://..."] tags +3. Cache server list +4. Upload to preferred server(s) +5. When URL breaks: extract SHA-256 from URL → try other servers +``` + +### Auth (BUD-11) + +```kotlin +// Kind 24242 event structure +BlossomAuthorizationEvent( + content = "Upload Blob", + tags = [ + ["t", "upload"], + ["x", ""], // scope to specific blob + ["expiration", ""], // required + ["server", "cdn.example.com"] // scope to server + ] +) +// Sent as: Authorization: Nostr +``` + +## NIP Coverage + +| NIP | What | How We Use It | +|-----|------|---------------| +| NIP-92 | Media Attachments (imeta tag) | Attach metadata to media URLs in notes | +| NIP-94 | File Metadata (kind 1063) | File header events, metadata tags | +| NIP-B7 | Blossom Media | Server discovery, URL fallback | +| NIP-68 | Picture Events (kind 20) | Picture-first posts, profile gallery | +| NIP-71 | Video Events (kind 21/22) | Video display and metadata | +| NIP-17 | Private DMs (encrypted files) | Encrypted media in DMs | + +## Desktop UX Patterns + +### Drag & Drop +- Drop zone on compose area +- Visual feedback (border highlight, preview) +- Multiple files → MultiOrchestrator +- Accept: images, videos, audio files + +### Clipboard Paste (Ctrl+V) +- Detect image data in clipboard +- Auto-create temp file → upload flow +- Screenshot workflow: PrtScn → paste → upload + +### File Picker +- OS-native dialog (JFileChooser on desktop) +- Filter by supported media types +- Multiple file selection + +### Lightbox +- Click image → full-screen overlay +- Mouse wheel zoom + pan +- Arrow keys for gallery navigation +- Esc to close +- Save to disk option + +### Upload Progress +- Inline progress indicator per file +- Server selection dropdown (from kind 10063 list) +- Alt text input field +- Compression toggle +- Preview before send + +## Phases + +### Phase 1: Image Display Foundation +**Goal:** Images render in desktop notes with blurhash previews. + +| Task | Module | Details | +|------|--------|---------| +| Desktop Coil3 ImageLoader setup | `desktopApp/` | DiskCache (OS-appropriate path), MemoryCache, OkHttp network, custom fetchers | +| Extract BlossomFetcher | `amethyst/` → `commons/` | Coil3 fetcher for `blossom:` URIs | +| Extract BlurHashFetcher | `amethyst/` → `commons/` | Coil3 fetcher for blurhash placeholder rendering | +| Extract Base64Fetcher | `amethyst/` → `commons/` | Coil3 fetcher for base64 data URIs | +| Desktop inline image rendering | `desktopApp/` | AsyncImage in note content, aspect ratio handling | +| MediaAspectRatioCache extraction | `amethyst/` → `commons/` | LruCache for URL→aspect ratio (replace Android LruCache with common impl) | + +**Deliverable:** Notes in desktop feed display inline images with blurhash placeholders. +**Verifiable:** Run desktop app, navigate to feed with image posts, images load with blue/gray previews → full images. + +### Phase 2: Blossom Upload Protocol Extraction +**Goal:** Shared upload client in commons, usable by Android and Desktop. + +| Task | Module | Details | +|------|--------|---------| +| Create `BlossomClient` | `commons/commonMain/` | HTTP PUT /upload, /mirror, /media. Takes InputStream. Returns BlobDescriptor. | +| Create `BlossomAuthHelper` | `commons/commonMain/` | Kind 24242 event creation + base64 encoding | +| Create `BlossomServerDiscovery` | `commons/commonMain/` | Kind 10063 query, server list resolution | +| Create expect/actual file operations | `commons/` | `readFileBytes`, `computeFileSha256`, `getMimeType`, `getFileSize` | +| Create expect/actual `MediaMetadataExtractor` | `commons/` | Dimensions, blurhash computation | +| Create expect/actual `MediaCompressor` | `commons/` | JPEG quality, EXIF stripping (metadata-extractor) | +| Extract `UploadOrchestrator` | `amethyst/` → `commons/` | Multi-server coordination using BlossomClient | +| Extract `MultiUploadOrchestrator` | `amethyst/` → `commons/` | Parallel file upload management | +| Extract `MediaUploadResult` | `amethyst/` → `commons/` | Already platform-agnostic | +| Migrate Android to use commons upload | `amethyst/` | Android actuals + wire up to existing UploadOrchestrator callers | +| Extract `BlossomServersViewModel` | `amethyst/` → `commons/` | Server list state management | + +**Deliverable:** `./gradlew :commons:jvmTest` passes with upload unit tests. Android still works. +**Verifiable:** Android upload flow unchanged. Desktop can call BlossomClient to upload a file. + +### Phase 3: Desktop Upload UX +**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste. + +| Task | Module | Details | +|------|--------|---------| +| Desktop file picker | `desktopApp/` | JFileChooser with media type filters, multi-select | +| Drag-and-drop handler | `desktopApp/` | `dragAndDropTarget` + `awtTransferable` + `javaFileListFlavor` | +| Clipboard paste handler | `desktopApp/` | AWT Toolkit clipboard, `DataFlavor.imageFlavor`, temp file creation | +| Upload dialog composable | `desktopApp/` | Progress bar, server selector (kind 10063), alt text field, compression toggle | +| Upload preview | `desktopApp/` or `commons/` | Thumbnail preview before upload | +| Wire up to compose screen | `desktopApp/` | Add media button to note composer, connect upload flow | + +**Deliverable:** Desktop user can drag image → see preview → upload to Blossom → post note with imeta. +**Verifiable:** Drop file on compose area, see upload progress, note publishes with embedded image. + +### Phase 4: Video Playback +**Goal:** Videos play inline in desktop notes. + +| Task | Module | Details | +|------|--------|---------| +| Add VLCJ + vlc-setup plugin | `desktopApp/build.gradle.kts` | `ir.mahozad.vlc-setup`, VLCJ 4.8.x dep | +| DesktopVideoPlayer composable | `desktopApp/` | SwingPanel + EmbeddedMediaPlayerComponent | +| Video controls overlay | `desktopApp/` | Play/pause, seek bar, volume, fullscreen toggle | +| Video in note rendering | `desktopApp/` | Replace URL-only display with inline player | +| Video upload support | `desktopApp/` | Accept video files in upload flow (Phase 3) | + +**Deliverable:** Video posts play inline in desktop feed. +**Verifiable:** Navigate to note with mp4/webm URL, video plays with controls. + +### Phase 5: Lightbox & Gallery +**Goal:** Full-screen media viewing with zoom and gallery navigation. + +| Task | Module | Details | +|------|--------|---------| +| Lightbox overlay composable | `desktopApp/` or `commons/` | Full-screen overlay, semi-transparent backdrop | +| Zoom + pan | `desktopApp/` | Mouse wheel zoom, click-drag pan (use zoomable lib or custom) | +| Gallery carousel | `commons/commonMain/` | Multi-image navigation (arrow keys + swipe indicators) | +| Save to disk | `desktopApp/` | Right-click or button → save image/video to local filesystem | +| Keyboard shortcuts | `desktopApp/` | Esc close, Left/Right navigate, +/- zoom | + +**Deliverable:** Click any image → fullscreen lightbox with zoom, multi-image gallery navigation. +**Verifiable:** Click image in note, zooms to fullscreen. Arrow keys cycle images. Esc closes. + +### Phase 6: Encrypted Media (DM Files) +**Goal:** Send and receive encrypted files in NIP-17 DMs. + +| Task | Module | Details | +|------|--------|---------| +| Extract encryption logic | `amethyst/` → `commons/` | NostrCipher usage for file encrypt/decrypt | +| Desktop encrypted upload flow | `desktopApp/` | Pick file → encrypt → Blossom upload → send encrypted event | +| Desktop encrypted display | `desktopApp/` | Receive encrypted file event → download → decrypt → display | +| Chat file upload dialog | `desktopApp/` | Similar to Phase 3 upload dialog but in DM context | + +**Deliverable:** Desktop DM users can send/receive encrypted images and files. +**Verifiable:** Send image in DM from desktop, receive on Android (and vice versa). + +### Phase 7: Profile Gallery (NIP-68) +**Goal:** View and create picture-first posts (kind 20). Profile gallery tab. + +| Task | Module | Details | +|------|--------|---------| +| Picture event display | `desktopApp/` | Kind 20 renderer with image-first layout | +| Profile gallery tab | `desktopApp/` | Grid of user's picture posts | +| Picture post composer | `desktopApp/` | Create kind 20 events with multiple images + imeta | +| Gallery entry events | `desktopApp/` | ProfileGalleryEntryEvent support | + +**Deliverable:** Desktop profile shows gallery tab. Users can create Instagram-style picture posts. +**Verifiable:** View profile → gallery tab shows image grid. Create picture post → visible on Android. + +### Phase 8: Media Server Management +**Goal:** UI for managing Blossom server list (kind 10063). + +| Task | Module | Details | +|------|--------|---------| +| Server list settings screen | `desktopApp/` | View/add/remove/reorder Blossom servers | +| Server status checking | `commons/` | HEAD request to verify server availability | +| Default server selection | `desktopApp/` | Choose preferred upload server | +| Publish kind 10063 | `commons/` | Update server list on relays | + +**Deliverable:** Desktop settings page to manage Blossom servers. +**Verifiable:** Add server → appears in upload dialog dropdown. Remove server → no longer used. + +### Phase 9: Audio Playback +**Goal:** Play audio tracks (MP3, OGG, FLAC) in notes. + +| Task | Module | Details | +|------|--------|---------| +| Audio player composable | `desktopApp/` | VLCJ audio-only mode (no video surface needed) | +| Waveform visualization | `desktopApp/` | Optional: visual waveform for voice messages | +| Audio in note rendering | `desktopApp/` | Play/pause button + progress bar inline | + +**Deliverable:** Audio files play inline in notes. +**Verifiable:** Note with MP3 URL shows audio player, plays on click. + +### Phase Dependency Graph + +``` +Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox) + │ +Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted) + │ + ├──→ Phase 7 (Gallery) + │ + └──→ Phase 8 (Server Mgmt) + +Phase 4 (Video) ────────────→ Phase 9 (Audio) + +Independent: Phase 1, 2, 4 can run in parallel +``` + +## Assumptions + +1. **Coil3 JVM/Desktop is production-ready** — Coil3 3.x advertises Compose Multiplatform support. Verify actual JVM desktop stability before committing. +2. **VLCJ + Compose SwingPanel works** — SwingPanel embeds Swing components in Compose. VLCJ renders to a Canvas/Panel. Need spike to confirm smooth integration (no flickering, proper resizing). +3. **OkHttp in commons is fine** — BlossomUploader uses OkHttp for HTTP. Both Android and Desktop are JVM, so OkHttp works in `commons/jvmAndroid/` or `commons/commonMain/` (OkHttp has KMP support). If iOS is ever targeted, this becomes an issue. +4. **Extracting to commons won't break Android** — Moving upload code from `amethyst/` to `commons/` requires updating Android imports. Must ensure Android's Koin DI and lifecycle wiring still works. +5. **libvlc can be bundled per-platform** — Compose Desktop packaging plugin supports native lib bundling. Need to verify for macOS (dylib), Linux (.so), Windows (.dll). + +## Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| VLCJ SwingPanel flicker | Video unusable | Spike test early (Phase 4 is independent) | +| Commons extraction breaks Android | Regression | Run Android build after each extraction | +| Coil3 JVM disk cache bugs | Missing images | Fall back to OkHttp manual caching | +| libvlc bundle size (~100MB) | Large app | Consider optional download or separate installer | +| Scope creep (15 features) | Never ships | Phases exist for a reason — ship Phase 1-3 first | + +## Resolved Questions + +1. **Video player** — VLCJ with bundled libvlc. Ship libvlc with the app (~100MB) for zero user setup. Full format support (mp4, webm, m3u8, mkv, etc.). + +2. **EXIF stripping** — Use Drew Noakes' `metadata-extractor` library. Surgically strip EXIF while preserving image quality (no re-encoding loss). + +3. **Upload concurrency** — Higher parallelism than Android by default (desktop has more resources). Upload to multiple Blossom servers simultaneously. Make concurrent upload count user-configurable in settings. + +4. **Coil3 disk cache** — Use OS-appropriate paths: macOS `~/Library/Caches/AmethystDesktop`, Linux `$XDG_CACHE_HOME/AmethystDesktop` (default `~/.cache/`), Windows `%LOCALAPPDATA%/AmethystDesktop/cache`. Use `maxSizeBytes(1GB)` not `maxSizePercent` (that needs Android Context). + +5. **Compose Desktop DnD** — `Modifier.dragAndDropTarget` is experimental (`@ExperimentalFoundationApi`) in 1.7.x. Uses `event.awtTransferable` + `DataFlavor.javaFileListFlavor` on desktop. Old `onExternalDrag` deprecated, removed in 1.8.0. API works but expect minor changes. + +6. **Media compression** — JPEG: `ImageWriteParam.compressionQuality` (0.0-1.0). PNG: lossless deflate level. WebP: use `org.sejda.imageio:webp-imageio` for lossy/lossless write (~3MB native libs per platform). Start with JPEG/PNG re-encoding, add WebP output later. + +7. **libvlc bundling** — Use `ir.mahozad.vlc-setup` Gradle plugin. Downloads and bundles libvlc per platform. Targets VLC 3.x + VLCJ 4.8.x. Compose Desktop integration via `SwingPanel`. + +## Open Questions + +1. **vlc-setup Apple Silicon** — Does the vlc-setup plugin support arm64 macOS, or only x86_64? Need to verify. +2. **Compose DnD macOS quirks** — Does `awtTransferable` properly deliver file URIs on macOS, or are there Finder-specific issues? diff --git a/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md new file mode 100644 index 0000000000..543857c56c --- /dev/null +++ b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md @@ -0,0 +1,85 @@ +# Brainstorm: Desktop DM Encrypted Media (Phase 6) + +**Date:** 2026-03-18 +**Status:** Ready for planning +**Branch:** `feat/desktop-media` + +## What We're Building + +Full send + receive encrypted media support in desktop DM chat (NIP-17). Users can attach files in DMs, which get encrypted (AES-GCM) before upload to Blossom, sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap. Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay. + +## Why This Approach + +The protocol layer is complete in quartz (NIP-17, NIP-44, AESGCM, ChatMessageEncryptedFileHeaderEvent). Android has the full flow implemented. Desktop already has `EncryptedMediaService.downloadAndDecrypt()` and `DesktopUploadOrchestrator` (unencrypted only). The work is essentially wiring up the existing pieces with a desktop-native UX. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Scope | Full send + receive | Both sides needed for complete DM file sharing | +| Attach UX | Inline paperclip button | Matches desktop chat conventions; thumbnails above text input | +| Drag & drop | Yes, in addition to button | Desktop-native interaction; ComposeNoteDialog already has this pattern | +| Encryption indicator | Lock icon overlay | Small lock on corner of encrypted media in chat bubbles | +| Upload encryption | AES-GCM via AESGCM class | Matches Android's ChatFileUploader.justUploadNIP17() pattern | +| Event type | Kind 15 (ChatMessageEncryptedFileHeaderEvent) | NIP-17 standard for encrypted file metadata | + +## Architecture Overview + +### Send Flow (Desktop) +``` +User attaches file → thumbnail preview above input + → Click send + → Generate AES-GCM cipher (key + nonce) + → DesktopUploadOrchestrator.uploadEncrypted(cipher, file) + → Encrypt file bytes with cipher + → Upload encrypted blob to Blossom server + → Build ChatMessageEncryptedFileHeaderEvent (kind 15) + → URL, encryption algo/key/nonce, file metadata + → Wrap in GiftWrap (NIP-59) for each recipient + → Send to relays +``` + +### Receive Flow (Desktop) +``` +Receive GiftWrap → unwrap → ChatMessageEncryptedFileHeaderEvent + → Extract URL, encryption key, nonce from tags + → EncryptedMediaService.downloadAndDecrypt(url, key, nonce) + → Display decrypted media inline in chat bubble + → Lock icon overlay on media thumbnail +``` + +## Existing Code to Reuse + +| Component | Location | Action | +|-----------|----------|--------| +| AESGCM cipher | `quartz/utils/ciphers/AESGCM.kt` | Reuse as-is | +| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Reuse as-is | +| NIP17Factory (GiftWrap) | `quartz/nip17Dm/NIP17Factory.kt` | Reuse as-is | +| EncryptedMediaService | `desktopApp/service/media/EncryptedMediaService.kt` | Extend for UI integration | +| DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Add `uploadEncrypted()` | +| ChatPane | `desktopApp/ui/chats/ChatPane.kt` | Add attach button, thumbnails, drag-drop | +| ChatMessageCompose | `commons/ui/chat/ChatMessageCompose.kt` | Add encrypted media display | +| Android ChatFileUploader | `amethyst/chats/privateDM/send/upload/` | Reference pattern | + +## New Code Needed + +| Component | Location | Purpose | +|-----------|----------|---------| +| `uploadEncrypted()` | DesktopUploadOrchestrator | Encrypt file with AESGCM before Blossom upload | +| DM file attach UI | ChatPane.kt | Paperclip button, thumbnail row, drag-drop zone | +| DM file send logic | ChatPane.kt or new helper | Build kind 15 event from upload result + cipher | +| Encrypted media renderer | ChatMessageCompose or new composable | Download, decrypt, display with lock overlay | +| `sendNip17EncryptedFile()` | DesktopIAccount.kt | Bridge to relay manager for sending | + +## Open Questions + +None — all key decisions resolved through brainstorm dialogue. + +## Test Cases (from testing plan) + +| # | Test | Expected | +|---|------|----------| +| 6.1 | DM file attach | Attach button visible, encryption indicator shown | +| 6.2 | Send encrypted | File uploads encrypted to Blossom, kind 15 event sent | +| 6.3 | Receive encrypted | Encrypted file downloads, decrypts, displays in bubble | +| 6.4 | Wrong key | Decryption fails gracefully (no crash, error state) | diff --git a/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md new file mode 100644 index 0000000000..640609897e --- /dev/null +++ b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md @@ -0,0 +1,75 @@ +# Brainstorm: Stacked Messages Layout in Multi-Deck + +**Date:** 2026-03-19 +**Status:** Ready for planning + +## What We're Building + +Replace the side-by-side split-pane Messages layout (contact list + chat) with a stacked navigation in deck columns. Clicking a conversation navigates from the contact list to the chat view; a back arrow returns to the list. Single-pane (non-deck) mode keeps the current split layout. + +## Why This Approach + +In multi-deck mode, columns can be 350-400dp wide. The current split layout allocates 280dp to the contact list, leaving only 70-120dp for the chat pane — unusable. A stacked layout gives the full column width to whichever view is active. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Layout mode | Always stacked in deck columns | Simplest, works at any column width | +| Single-pane mode | Keep split layout | Plenty of horizontal space in single-pane | +| Back navigation | Back arrow in chat header | Discoverable; Escape key already works | +| State management | `selectedRoom` in `ChatroomListState` already controls this | No new state needed — just show list when null, chat when selected | + +## Architecture + +### Current Flow (DesktopMessagesScreen.kt) +``` +Row { + ConversationListPane(280dp) // always visible + VerticalDivider + ChatPane(flex) // or EmptyState +} +``` + +### New Flow (Deck Mode) +``` +// When selectedRoom == null: +ConversationListPane(full width) + +// When selectedRoom != null: +Column { + BackArrow + ChatroomHeader + ChatPane(full width) +} +``` + +### Implementation Approach + +`DesktopMessagesScreen` already has `selectedRoom` state. The change is layout-only: + +1. Add a `compactMode: Boolean` parameter to `DesktopMessagesScreen` +2. In deck mode (`compactMode = true`): show either list OR chat, not both +3. In single-pane mode (`compactMode = false`): keep current split Row +4. Add back arrow to `ChatPane` header when in compact mode +5. `clearSelection()` → back to list (already exists) + +### What Changes + +| Component | Change | +|-----------|--------| +| `DesktopMessagesScreen` | Add `compactMode` param; conditional layout (Row vs when/else) | +| `ChatPane` | Add `onBack` callback + back arrow in header when provided | +| `RootContent` | Pass `compactMode = true` to DesktopMessagesScreen | +| `SinglePaneLayout` `RootContent` | Pass `compactMode = false` | +| `ConversationListPane` | No changes — already works at any width | + +### Edge Cases + +- **Keyboard nav**: Escape already calls `clearSelection()` — works as back +- **New DM dialog**: Opens over whichever view is active — no change needed +- **Receiving DM while in list**: Room appears/updates in list normally +- **Receiving DM while in chat**: Messages appear in real-time — no change + +## Open Questions + +None — all decisions resolved. diff --git a/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md new file mode 100644 index 0000000000..e106e509db --- /dev/null +++ b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md @@ -0,0 +1,33 @@ +# Brainstorm: Per-Message Encryption Badge in DMs + +**Date:** 2026-03-19 +**Status:** Ready for implementation + +## What We're Building + +Per-message encryption indicator in DM chat bubbles — lock icon (NIP-17) or lock-open icon (NIP-04) next to the timestamp. Matches Android's approach with incognito badges. + +## Why This Approach + +Users need to know which messages are truly private (NIP-17: relay can't see sender/recipient) vs legacy encrypted (NIP-04: relay sees metadata). Android already does this with incognito badges. Desktop uses lock/lock-open icons (already imported in ChatPane) for consistency with the existing NIP-17 toggle. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Indicator type | Lock icon per message | Matches existing lock icon pattern in desktop NIP-17 toggle | +| NIP-17 icon | Lock (filled) in primary color | Private, secure | +| NIP-04 icon | LockOpen in muted gray | Legacy, weaker privacy | +| Placement | Next to timestamp in detailRow | Matches Android's IncognitoBadge placement | +| Tooltip | None (match Android) | Keep it subtle, not alarming | + +## Implementation + +The badge goes in `MessageWithReactions` in ChatPane.kt, in the `detailRow` slot of `ChatMessageCompose`. Check `note.event` type: +- `is PrivateDmEvent` → NIP-04 → lock-open gray +- `is ChatMessageEvent` or `is ChatMessageEncryptedFileHeaderEvent` → NIP-17 → lock primary +- else → no badge + +## Key Finding: NIP-04 + NIP-17 Messages Merge + +For 1-on-1 chats, both protocols produce identical `ChatroomKey({otherPubkey})`. Messages from both protocols appear in the same conversation. The per-message badge is the only way to tell them apart. diff --git a/docs/plans/2026-03-05-nip46-tdd-tests-plan.md b/docs/plans/2026-03-05-nip46-tdd-tests-plan.md index 5471baf993..a04d2de040 100644 --- a/docs/plans/2026-03-05-nip46-tdd-tests-plan.md +++ b/docs/plans/2026-03-05-nip46-tdd-tests-plan.md @@ -25,7 +25,7 @@ Write tests that **reproduce the three NIP-46 bugs** before they're fixed, then ### Existing - Framework: `kotlin.test` + `kotlinx-coroutines-test` + `mockk` - Location: `desktopApp/src/jvmTest/kotlin/.../desktop/account/` -- Mocks: `EmptyNostrClient` (no-op), `mockk(relaxed = true)`, temp dirs +- Mocks: `EmptyNostrClient()` (no-op), `mockk(relaxed = true)`, temp dirs - Pattern: `@BeforeTest` setup → `runTest {}` → `@AfterTest` cleanup ### Needed @@ -40,9 +40,9 @@ Three tests pass `client` param to `loadSavedAccount()` which no longer accepts | Test | Fix | |------|-----| -| `loadSavedAccountBunkerNoEphemeralReturnsFailure` (line 114) | Remove `client = EmptyNostrClient` arg | +| `loadSavedAccountBunkerNoEphemeralReturnsFailure` (line 114) | Remove `client = EmptyNostrClient()` arg | | `loadSavedAccountBunkerNoClientFallsBackToInternal` (line 119-136) | **Delete entirely** — concept no longer exists (AccountManager always creates its own NIP-46 client) | -| `loadSavedAccountBunkerSuccess` (line 155-158) | Remove `client = EmptyNostrClient` arg | +| `loadSavedAccountBunkerSuccess` (line 155-158) | Remove `client = EmptyNostrClient()` arg | ## Phase 1: Relay Isolation Tests (Bug 2 + 3) diff --git a/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md b/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md new file mode 100644 index 0000000000..244213ebac --- /dev/null +++ b/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md @@ -0,0 +1,962 @@ +--- +title: "feat: Desktop Advanced Search with Query Operators and Form UI" +type: feat +status: active +date: 2026-03-10 +deepened: 2026-03-10 +origin: docs/brainstorms/2026-03-10-advanced-search-brainstorm.md +--- + +# Desktop Advanced Search + +## Enhancement Summary + +**Deepened on:** 2026-03-10 +**Agents used:** kotlin-expert, compose-expert, kotlin-coroutines, nostr-expert, desktop-expert, kmp-expert, best-practices-researcher, architecture-strategist, performance-oracle, code-simplicity-reviewer, security-sentinel + +### Key Improvements +1. **Bidirectional sync loop prevention** — `sourceOfChange` discriminator (TEXT/FORM/INIT) breaks parse→serialize→parse cycles +2. **Performance** — batch result accumulation via `channelFlow` + 100ms windows, cap OR to 3 terms (not 5), `@Immutable` SearchQuery +3. **Parser architecture** — hand-written recursive descent tokenizer + parser, error recovery via literal text degradation +4. **Module corrections** — SearchFilterFactory stays in desktopApp (needs SubscriptionConfig); SearchResultFilter and SearchHistoryStore can move to commons +5. **Compose patterns** — `FilterChip` for kind presets, `expandVertically(Alignment.Top)` + `fadeIn`, sticky section headers, shimmer loading +6. **Coroutine patterns** — `flatMapLatest` for auto-canceling old subscriptions, `merge()` for OR queries, `supervisorScope` for relay isolation +7. **Simplicity guidance** — MVP can cut OR queries, lang:/domain:, saved searches to ~350 LOC / 5 files. Full plan phases appropriately. + +### New Considerations Discovered +- NIP-50 extensions go inline in search string (`"bitcoin language:en"`), not as separate filter fields +- Pseudo-kinds (reply, media) need separate handling from real kinds — they're client-side post-filters, not relay filters +- `TextFieldValue` (not raw String) needed for cursor position stability during bidirectional sync +- Use `query.hashtags` → `Filter.tags["t"]` (more reliable than putting hashtags in search string) +- OR cap: 3 terms max (not 5) — 5 terms × 3 groups × 3 relays = 45 subs is too many + +--- + +## Overview + +Full-featured search for Amethyst Desktop: Twitter-style query operators (`from:`, `kind:`, `since:`, etc.), expandable form panel below search bar, bidirectional sync between text and form, extensible kind presets, OR queries, search history + saved searches. Relay-first via NIP-50. + +Current desktop search only handles kind 0 (people) + kind 1 (notes) with plain text. Android searches 30+ kinds. This closes that gap and adds capabilities neither platform has. + +## Problem Statement + +Desktop search (`SearchScreen.kt`) is minimal: +- Only `searchPeople()` (kind 0) wired to relay subscription +- `searchNotes()` exists in `FeedSubscription.kt` but not connected +- No kind filtering, no author filtering, no date ranges +- No query language — users can only type plain text or bech32 identifiers +- `SearchBarState` in commons only returns `User` results, no notes/channels + +Users can't find content they've seen, discover new content by topic, or filter by author/type/date. + +## Proposed Solution + +### Query Operator Language + +Client-side query language that maps to `Filter` fields. Not a Nostr standard — a UX convention. + +``` +from:npub1abc kind:note since:2025-01-01 bitcoin OR lightning -spam #nostr +``` + +| Operator | Maps To | Relay-side? | +|----------|---------|-------------| +| `from:` | `Filter.authors` | Yes | +| `kind:` | `Filter.kinds` | Yes | +| `since:` | `Filter.since` | Yes | +| `until:` | `Filter.until` | Yes | +| `#` | `Filter.tags["t"]` | Yes | +| `"exact phrase"` | Quoted in `Filter.search` | Yes (relay-dependent) | +| `lang:` | NIP-50 extension in search string | Relay-dependent | +| `domain:` | NIP-50 extension in search string | Relay-dependent | +| `-` | Client-side exclusion post-filter | No | +| `OR` | Parallel subscriptions, merged | Multiple queries | + +#### Research Insights: NIP-50 Protocol Details + +**NIP-50 extension placement:** Extensions go *inline in the search string*, not as separate filter fields. The relay parses them out: +```json +{"kinds": [1], "search": "bitcoin language:en domain:nostr.com"} +``` + +**Hashtag handling:** Use `tags = {"t": ["bitcoin"]}` in the filter (more reliable across relays) rather than putting `#bitcoin` in the search string. Hashtags in `Filter.tags` are protocol-level, not NIP-50 dependent. + +**Quoted phrase search:** Not standardized — relay-dependent. Some relays treat quotes literally, others ignore them. Degrade gracefully. + +**All filter fields AND together** within a single filter. OR requires separate subscriptions. + +### Dual UI: Text Bar + Expandable Form Panel + +``` +[ from:npub1abc kind:note bitcoin ] [Advanced v] ++----------------------------------------------------------+ +| Content: [x] Notes [ ] Articles [ ] Media [ ] All | +| Author: [ npub or name... ] [+ Add] | +| Since: [ 2025-01-01 ] Until: [ today ] | +| Tags: [ #bitcoin ] [+ Add] | +| Exclude: [ spam ] [+ Add] | +| Language:[ Any v ] | +| | +| [Clear] [Search] | ++----------------------------------------------------------+ +``` + +Bidirectional: typing `kind:article` checks "Articles"; checking "Notes" inserts `kind:note`. + +#### Research Insights: Bidirectional Sync + +**Critical: `sourceOfChange` discriminator.** Without this, parse→serialize→parse loops will occur. Track who initiated the change: + +```kotlin +enum class ChangeSource { TEXT, FORM, INIT } + +fun updateFromText(rawText: String) { + _changeSource = ChangeSource.TEXT + _query.value = QueryParser.parse(rawText) +} + +fun updateKinds(kinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(kinds = kinds) +} + +// In the composable, only update text field when source != TEXT +val displayText by remember { + state.query.map { query -> + if (state.changeSource != ChangeSource.TEXT) { + QuerySerializer.serialize(query) + } else { + // Keep user's raw text as-is + state.rawText + } + } +} +``` + +**Use `TextFieldValue` (not raw String)** for the text bar to preserve cursor position during form-driven updates. When form changes update the serialized text, set `TextFieldValue(text = newText, selection = TextRange(newText.length))`. + +## Technical Approach + +### Architecture + +``` +Text Bar ──parse──> SearchQuery <──serialize── Form Panel + | + FilterBuilder + | + List (split by kind groups, ~10 kinds each) + | + Relay Subscriptions (NIP-50) + | + Client-side Post-filter (exclusions, reply/media detection) + | + Results Display (grouped: People, Notes, Articles, Channels) +``` + +**Single source of truth:** `MutableStateFlow`. Both text bar and form read from it. Text bar changes → `QueryParser` → `SearchQuery`. Form changes → mutate `SearchQuery` directly. `QuerySerializer` regenerates text string. `sourceOfChange` discriminator prevents update loops. + +**Debounce strategy:** Text input debounced 300ms (existing pattern). Form toggle changes trigger immediate search (no debounce). + +#### Research Insights: Kotlin State Patterns + +**`@Immutable` on SearchQuery** — enables Compose to skip recomposition when query hasn't changed: +```kotlin +@Immutable +data class SearchQuery( + val text: String = "", + val authors: ImmutableList = persistentListOf(), + val kinds: ImmutableList = persistentListOf(), + // ... +) { + companion object { + val EMPTY = SearchQuery() + } +} +``` + +Use `kotlinx.collections.immutable` (`ImmutableList`, `ImmutableSet`, `persistentListOf()`) for all collection fields. This gives Compose structural stability guarantees. + +**Granular derived StateFlows** with `distinctUntilChanged()` to prevent unnecessary recomposition: +```kotlin +val kindsForUI: StateFlow> = _query + .map { it.kinds } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.WhileSubscribed(5000), persistentListOf()) +``` + +### Data Flow Detail + +``` +SearchQuery (SSOT) + │ + ├─ text bar reads: QuerySerializer.serialize(query) → displayed string + │ └─ on text change: QueryParser.parse(rawText) → new SearchQuery + │ └─ GUARD: only serialize→display when changeSource != TEXT + │ + ├─ form panel reads: query.kinds, query.authors, query.since, etc. + │ └─ on form change: query.copy(kinds = ...) → new SearchQuery + │ + └─ relay layer reads: SearchFilterFactory.createFilters(query) → List + └─ subscription created per filter group + └─ OR queries: parallel subscriptions via merge(), results deduped by event ID +``` + +### Implementation Phases + +#### Phase 1: Query Engine (commons/commonMain) — Foundation + +Pure Kotlin, no UI, exhaustively unit-tested. + +**Step 1.1: `SearchQuery` data class** + +```kotlin +// commons/src/commonMain/.../search/SearchQuery.kt +@Immutable +data class SearchQuery( + val text: String = "", // Free text for NIP-50 search field + val authors: ImmutableList = persistentListOf(), // Hex pubkeys + val authorNames: ImmutableList = persistentListOf(), // Unresolved names (for display) + val kinds: ImmutableList = persistentListOf(), // Empty = all searchable kinds + val since: Long? = null, // Unix timestamp + val until: Long? = null, + val hashtags: ImmutableList = persistentListOf(), // Without # prefix + val excludeTerms: ImmutableList = persistentListOf(), // Client-side exclusion + val language: String? = null, // ISO 639-1 + val domain: String? = null, // NIP-05 domain + val orTerms: ImmutableList = persistentListOf(), // Terms joined by OR +) { + val isEmpty get() = text.isBlank() && authors.isEmpty() && kinds.isEmpty() + && since == null && until == null && hashtags.isEmpty() + && orTerms.isEmpty() + + companion object { + val EMPTY = SearchQuery() + } +} +``` + +#### Research Insights: Pseudo-Kinds + +**Separate pseudo-kinds from real kinds.** `kind:reply` and `kind:media` are NOT relay filter kinds — they require client-side post-filtering: +- `kind:reply` = kind 1 events WITH `e` tag +- `kind:media` = kind 1 events WITH `imeta` tag or image URLs + +The `SearchQuery` should track these separately or the `KindRegistry` should flag them. Recommended: a `pseudoKinds: Set` field or handle in `SearchResultFilter`. + +**Step 1.2: Kind alias registry** + +```kotlin +// commons/src/commonMain/.../search/KindRegistry.kt +object KindRegistry { + // Import quartz KIND constants instead of hardcoding numbers + val aliases: Map> = mapOf( + "note" to listOf(1), + "article" to listOf(30023), + "repost" to listOf(6), + "profile" to listOf(0), + "channel" to listOf(40, 41, 42), + "live" to listOf(30311), + "community" to listOf(34550), + "wiki" to listOf(30818), + "video" to listOf(34235), + "classified" to listOf(30402), + "highlight" to listOf(9802), + "poll" to listOf(6969), + ) + + // Pseudo-kinds: client-side post-filters, not relay kinds + val pseudoKinds: Set = setOf("reply", "media") + + val presets: Map> = mapOf( + "Notes" to listOf(1), + "Articles" to listOf(30023), + "Media" to listOf(1), // post-filtered for imeta tag + "Channels" to listOf(40, 41, 42), + "Communities" to listOf(34550), + "Wiki" to listOf(30818), + ) + + fun resolve(alias: String): List? = aliases[alias.lowercase()] + fun isPseudoKind(alias: String): Boolean = alias.lowercase() in pseudoKinds + fun nameFor(kind: Int): String? = aliases.entries.find { kind in it.value }?.key +} +``` + +**Step 1.3: `QueryParser`** + +#### Research Insights: Parser Architecture + +**Hand-written recursive descent parser** (not regex, not parser generators). Two-phase: + +1. **Tokenizer** (state machine): Walks characters, emits tokens: `OperatorToken(name, value)`, `TextToken(value)`, `OrToken`, `QuotedToken(value)`, `NegationToken(value)`, `HashtagToken(value)` +2. **Parser** (recursive descent): Consumes tokens, builds `SearchQuery` + +**Key principles:** +- **Preserve raw text in tokens** for roundtrip fidelity (`serialize(parse(input))` ≈ `input`) +- **Error recovery**: malformed operators degrade to literal text, never throw +- **OR precedence**: OR binds to adjacent text terms only. Operators are always AND. + - `from:vitor bitcoin OR lightning kind:note` = `from:vitor AND kind:note AND (bitcoin OR lightning)` +- **Performance**: sub-microsecond parsing, not a concern + +```kotlin +// commons/src/commonMain/.../search/QueryParser.kt +object QueryParser { + fun parse(input: String): SearchQuery { + val tokens = tokenize(input) + return buildQuery(tokens) + } + + private fun tokenize(input: String): List { /* state machine */ } + private fun buildQuery(tokens: List): SearchQuery { /* recursive descent */ } +} + +sealed interface Token { + data class Operator(val name: String, val value: String, val raw: String) : Token + data class Text(val value: String) : Token + data object Or : Token + data class Quoted(val value: String, val raw: String) : Token + data class Negation(val term: String) : Token + data class Hashtag(val tag: String) : Token +} +``` + +Rules: +- Case-insensitive operator matching (`FROM:` = `from:`) +- `from:` → if bech32 npub, decode to hex and add to `authors`; else add to `authorNames` (async resolution) +- `kind:` → resolve via `KindRegistry.resolve()` or parse as int. Flag pseudo-kinds separately. +- `since:` / `until:` → parse ISO 8601 (`2025-01-01`, `2025-01`, `2025`) to unix timestamp +- `#tag` → add to `hashtags` +- `"quoted phrase"` → keep in `text` as quoted +- `-term` → add to `excludeTerms`, strip from relay search string +- `OR` → split adjacent free text terms. `bitcoin OR lightning` → `orTerms = ["bitcoin", "lightning"]` +- Multiple `from:` → AND (multiple authors) +- Multiple `kind:` → union (combined kinds) +- Incomplete operators (`from:` with no value) → treat as literal text + +**Step 1.4: `QuerySerializer`** + +```kotlin +// commons/src/commonMain/.../search/QuerySerializer.kt +object QuerySerializer { + fun serialize(query: SearchQuery): String { ... } +} +``` + +Regenerates the canonical text representation from `SearchQuery`. Used to update text bar when form changes. Ordering: operators first (`from:`, `kind:`, `since:`, `until:`, `lang:`, `domain:`), then hashtags, then free text / OR terms, then exclusions. + +**Step 1.5: Unit tests** + +```kotlin +// commons/src/commonTest/.../search/QueryParserTest.kt +// commons/src/commonTest/.../search/QuerySerializerTest.kt +// commons/src/commonTest/.../search/KindRegistryTest.kt +``` + +Test matrix: +- Single operator of each type +- Combined operators +- OR with operators +- Malformed/incomplete (`from:`, `kind:invalid`, `since:not-a-date`) +- Special characters, emoji, unicode in free text +- Roundtrip: `serialize(parse(input)) == normalized(input)` +- Multiple `from:` authors +- Multiple `kind:` (union) +- Quoted phrases +- Exclusion terms +- Pseudo-kind detection (`kind:reply`, `kind:media`) +- Edge: empty string, whitespace only, very long query +- OR precedence: `from:x a OR b kind:note` → operators AND, text OR + +**Consider property-based testing** with Kotest for roundtrip fidelity. + +#### Phase 2: Filter Factory + Relay Integration (desktopApp) + +**Step 2.1: `SearchFilterFactory`** + +```kotlin +// desktopApp/src/jvmMain/.../subscriptions/SearchFilterFactory.kt +object SearchFilterFactory { + fun createFilters(query: SearchQuery): List { ... } +} +``` + +- If `query.kinds` specified → use those kinds directly +- If `query.kinds` empty → use default searchable kinds (align with Android's 3 groups) +- Split kinds into groups of ~10 (relay `max_filters` limit safety) +- Build NIP-50 search string: `query.text` + inline NIP-50 extensions (`language:en`, `domain:x`) +- Strip exclusion terms from search string (don't send `-spam` to relay) +- `query.authors` → `Filter.authors` (only resolved hex keys) +- `query.since` / `query.until` → `Filter.since` / `Filter.until` +- `query.hashtags` → `Filter.tags["t"]` (not in search string — more reliable) +- OR queries: return separate filter lists per OR term + +#### Research Insights: Module Placement + +**SearchFilterFactory stays in desktopApp** — it depends on `SubscriptionConfig` and relay topology, which are desktop-specific. Correct as planned. + +**SearchResultFilter can move to commons/commonMain** — pure Kotlin, no platform dependencies. Android can reuse it later. + +**SearchHistoryStore can move to commons as expect/actual** — follows `SecureKeyStorage` pattern. `expect class SearchHistoryStore`, with `actual` implementations using `java.util.prefs.Preferences` on desktop and SharedPreferences/DataStore on Android. + +**Step 2.2: Default searchable kind groups** + +Port from Android's `SearchPostsByText.kt` to desktop. Reference the same kinds: + +```kotlin +// Group 1: TextNote, LongText, Badge, PeopleList, BookmarkList, AudioHeader, AudioTrack, PinList, PollNote, ChannelCreate +// Group 2: ChannelMetadata, Classifieds, Community, EmojiPack, Highlight, LiveActivities, PublicMessage, NNS, Wiki, Comment +// Group 3: InteractiveStory (2 kinds), FollowList, NipText, Poll, PollResponse +``` + +Kind group splitting is relay-imposed (`max_filters` limits), not protocol. Use the quartz KIND constants, don't hardcode numbers. + +**Step 2.3: Search subscription factory** + +```kotlin +// desktopApp/src/jvmMain/.../subscriptions/FeedSubscription.kt (extend) +fun createAdvancedSearchSubscription( + relays: Set, + query: SearchQuery, + onEvent: ..., + onEose: ..., +): List +``` + +#### Research Insights: Subscription Management + +**Use `flatMapLatest`** on debouncedQuery to auto-cancel old subscriptions when query changes: +```kotlin +val results: Flow> = debouncedQuery + .flatMapLatest { query -> + if (query.isEmpty) flowOf(emptyList()) + else channelFlow { + supervisorScope { + val filters = SearchFilterFactory.createFilters(query) + // Launch independent subscription per filter group + filters.forEach { filter -> + launch { subscribeAndEmit(filter, relays) } + } + } + } + } +``` + +**`supervisorScope`** for independent relay subscription failure isolation — one relay failure doesn't cancel others. + +**`merge()` (not `combine()`)** for OR query result flows — emit results as they arrive from any term. + +**Batch filters per OR term** in a single REQ (not per kind group), reducing subscription count: +- 3 OR terms × 1 batched REQ × 3 relays = 9 subscriptions (vs 45 if unbatched) + +**Cap: max 3 OR terms** (not 5) — subscription fan-out gets expensive. + +**Step 2.4: Client-side post-filter** + +```kotlin +// commons/src/commonMain/.../search/SearchResultFilter.kt +object SearchResultFilter { + fun filter(events: List, query: SearchQuery): List +} +``` + +- `-term` exclusion: check `event.content` doesn't contain term (case-insensitive) +- `kind:reply` detection: kind 1 with `e` tag +- `kind:media` detection: kind 1 with `imeta` tag or URL patterns +- Deduplication by event ID (for OR query merges) + +#### Research Insights: Performance + +**Batch result accumulation** — current Amethyst pattern of `_results.value = _results.value + item` is O(n^2). Use channel-based batching: + +```kotlin +channelFlow { + val batch = mutableSetOf() // Set for O(1) dedup + var lastEmit = 0L + + onEvent = { event -> + batch.add(event) + val now = System.currentTimeMillis() + if (now - lastEmit > 100) { // 100ms batch window + send(batch.toList()) + lastEmit = now + } + } +} +``` + +**Apply post-filter at batch emission time**, not per-event. + +**Result ordering:** dedup by event ID, sort by `createdAt` descending (match Amethyst Android behavior). + +#### Phase 3: Advanced Search State (commons/commonMain) + +**Step 3.1: `AdvancedSearchBarState`** + +New state holder that extends/replaces `SearchBarState`. Manages the `SearchQuery` as SSOT. + +```kotlin +// commons/src/commonMain/.../viewmodels/AdvancedSearchBarState.kt +class AdvancedSearchBarState( + private val cache: ICacheProvider, + private val scope: CoroutineScope, +) { + private val _query = MutableStateFlow(SearchQuery.EMPTY) + val query: StateFlow = _query.asStateFlow() + + // Track who initiated the change (prevents sync loops) + private var _changeSource: ChangeSource = ChangeSource.INIT + val changeSource get() = _changeSource + + // Raw text from user typing (preserved when source=TEXT) + private val _rawText = MutableStateFlow("") + val rawText: StateFlow = _rawText.asStateFlow() + + // Derived: text representation for the search bar + val displayText: StateFlow = combine(_query, _rawText) { query, raw -> + if (_changeSource == ChangeSource.TEXT) raw + else QuerySerializer.serialize(query) + }.stateIn(scope, SharingStarted.Eagerly, "") + + // For relay subscriptions to observe (300ms debounce) + val debouncedQuery: StateFlow = _query + .debounce(300) + .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY) + + // Results + val peopleResults: StateFlow> + val noteResults: StateFlow> + val isSearching: StateFlow + + // Text bar input (parses into SearchQuery) + fun updateFromText(rawText: String) { + _changeSource = ChangeSource.TEXT + _rawText.value = rawText + _query.value = QueryParser.parse(rawText) + } + + // Form panel input (mutates SearchQuery directly) + fun updateKinds(kinds: List) { + _changeSource = ChangeSource.FORM + _query.value = _query.value.copy(kinds = kinds.toImmutableList()) + } + + fun addAuthor(hexOrName: String) { ... } + fun removeAuthor(hex: String) { ... } + fun updateDateRange(since: Long?, until: Long?) { ... } + fun addHashtag(tag: String) { ... } + fun removeHashtag(tag: String) { ... } + fun addExcludeTerm(term: String) { ... } + fun updateLanguage(lang: String?) { ... } + + // Name resolution (async) + fun resolveAuthorName(name: String, onResolved: (String) -> Unit) + + // History + fun addToHistory(query: SearchQuery) + fun getHistory(): List + fun saveSearch(query: SearchQuery, label: String) + fun getSavedSearches(): List + fun deleteSavedSearch(id: String) +} + +enum class ChangeSource { TEXT, FORM, INIT } +``` + +**Step 3.2: Name resolution** + +- Check local cache first: `cache.findUsersStartingWith(name, 5)` +- If multiple matches → expose as `authorSuggestions: StateFlow>` for autocomplete dropdown +- If single match → auto-resolve to hex key +- If no local match → keep as `authorNames` (display in form as "unresolved: vitor") +- Keep name resolution **out of QueryParser** — parser returns raw strings, platform layer resolves +- No NIP-05 resolution in v1. Follow-up. + +#### Phase 4: Desktop UI (desktopApp) + +**Step 4.1: Rewrite `SearchScreen.kt`** + +```kotlin +// desktopApp/src/jvmMain/.../ui/SearchScreen.kt +@Composable +fun SearchScreen( + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + ... +) { + val state = remember { AdvancedSearchBarState(localCache, scope) } + val query by state.query.collectAsState() + val displayText by state.displayText.collectAsState() + var panelExpanded by remember { mutableStateOf(false) } + + Column { + // Search bar row + Row { + OutlinedTextField( + value = TextFieldValue( + text = displayText, + selection = TextRange(displayText.length), + ), + onValueChange = { state.updateFromText(it.text) }, + placeholder = { Text("Search notes, people, tags... or use operators") }, + ... + ) + TextButton(onClick = { panelExpanded = !panelExpanded }) { + Text(if (panelExpanded) "Advanced ^" else "Advanced v") + } + } + + // Expandable advanced panel + AnimatedVisibility( + visible = panelExpanded, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + AdvancedSearchPanel( + query = query, + onKindsChanged = { state.updateKinds(it) }, + onAuthorAdded = { state.addAuthor(it) }, + onAuthorRemoved = { state.removeAuthor(it) }, + onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, + ... + ) + } + + // Results + SearchResultsList(state = state, ...) + } +} +``` + +#### Research Insights: Compose UI Patterns + +**Panel animation:** `expandVertically(expandFrom = Alignment.Top)` + `fadeIn()` — panel slides down from search bar, feels natural. + +**Kind preset chips:** Use `FilterChip` (not ElevatedFilterChip or AssistChip): +```kotlin +KindRegistry.presets.forEach { (name, kinds) -> + FilterChip( + selected = query.kinds.containsAll(kinds), + onClick = { onKindsChanged(toggleKinds(query.kinds, kinds)) }, + label = { Text(name) }, + ) +} +``` + +**Author autocomplete:** `DropdownMenu` (not `Popup`) — handles dismissal, positioning, focus correctly. + +**Date input:** Text fields with `YYYY-MM-DD` format (no native date picker on desktop). Validate on blur. + +**Keyboard events:** `onPreviewKeyEvent` for Escape (before children), `onKeyEvent` for `/` (after children). + +**Step 4.2: `AdvancedSearchPanel` composable** + +```kotlin +// desktopApp/src/jvmMain/.../ui/search/AdvancedSearchPanel.kt +@Composable +fun AdvancedSearchPanel( + query: SearchQuery, + onKindsChanged: (List) -> Unit, + onAuthorAdded: (String) -> Unit, + ... +) +``` + +Components: +- **Content type row**: `FilterChip` per preset from `KindRegistry.presets`. Checked state derived from `query.kinds`. +- **Author field**: `OutlinedTextField` + `DropdownMenu` autocomplete dropdown (from `authorSuggestions`). Shows chips for added authors. +- **Date range**: Two date text fields (`yyyy-MM-dd` format). Validate on blur. +- **Hashtags**: Chip group with add button. +- **Exclude terms**: Chip group with add button. +- **Language dropdown**: `DropdownMenu` with common ISO 639-1 codes. +- **Clear / Search buttons**: Clear resets `SearchQuery.EMPTY`. Search is implicit (debounced). +- **Tooltips**: `TooltipBox` + `PlainTooltip` for operator hint text on hover. + +**Step 4.3: `SearchResultsList` composable** + +```kotlin +// desktopApp/src/jvmMain/.../ui/search/SearchResultsList.kt +@Composable +fun SearchResultsList(state: AdvancedSearchBarState, ...) +``` + +#### Research Insights: Results Display + +- Single `LazyColumn` with **sticky section headers**: `stickyHeader { Surface(color = background) { ... } }` +- Sections: **People** (kind 0), **Notes** (kind 1), **Articles** (kind 30023), **Other** (everything else) +- Each section shows top 5 results with "Show all N" expand link +- Note results: content preview (first 200 chars), author name, timestamp, kind badge +- Progressive loading: results stream in as relay responds, sections update live +- **Shimmer loading**: Custom shimmer via `Brush.linearGradient` + `InfiniteTransition` (reusable, put in commons) +- Empty state: "No results found. Try broader terms or fewer filters." +- **Stable keys** for LazyColumn items: `key = { "section-${event.id}" }` to prevent recomposition flicker + +**Step 4.4: Relay subscription wiring** + +In `SearchScreen.kt`, use `rememberSubscription()` with the debounced query: + +```kotlin +val debouncedQuery by state.debouncedQuery.collectAsState() +val configuredRelays by remember { + relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() // Prevent churn (FeedScreen pattern) +}.collectAsState(emptySet()) + +// Create subscriptions from query +val filters = remember(debouncedQuery) { SearchFilterFactory.createFilters(debouncedQuery) } +// ... wire up rememberSubscription per filter group +``` + +#### Research Insights: Desktop-Specific Patterns + +**Keyboard shortcuts:** +- `Ctrl+K` or `/` → focus search bar. Use `Window.onKeyEvent` for `/` (after children process), `onPreviewKeyEvent` for Escape. +- `Escape` → close advanced panel / clear search +- `Enter` → execute search immediately (skip debounce) +- Register `Ctrl+K` in `MenuBar { Item("Search", KeyShortcut(Key.K, ctrl = true)) { focusSearch() } }` + +**Clipboard:** Support pasting npub/note/nevent directly into search bar — already handled by `QueryParser` treating bech32 as `from:` equivalent. + +#### Phase 5: Search History + Saved Searches + +**Step 5.1: Local persistence** + +```kotlin +// desktopApp/src/jvmMain/.../storage/SearchHistoryStore.kt +class SearchHistoryStore(private val appDataDir: Path) { + private val historyFile = appDataDir / "search_history.json" + private val savedFile = appDataDir / "saved_searches.json" + + // In-memory cache, async persist on Dispatchers.IO + private var historyCache: MutableList = mutableListOf() + + fun addToHistory(query: SearchQuery) // Dedup by serialized text, max 20 entries + fun getHistory(): List + fun clearHistory() + + fun saveSearch(query: SearchQuery, label: String) + fun getSavedSearches(): List + fun deleteSavedSearch(id: String) +} + +data class SavedSearch( + val id: String, // UUID + val label: String, + val query: SearchQuery, + val createdAt: Long, +) +``` + +JSON serialization via kotlinx.serialization (already in project). + +**Platform data dirs:** macOS `~/Library/Application Support/Amethyst/`, Linux `~/.config/amethyst/`, Windows `%APPDATA%\Amethyst\`. Use existing `DesktopPreferences.kt` pattern or `java.util.prefs.Preferences`. + +**Step 5.2: History UI** + +When search bar is empty → show recent history + saved searches below the bar. +- History items: click to load query into search bar +- Saved searches: click to load, X to delete +- "Clear history" button at bottom + +#### Phase 6: Integration + Polish + +**Step 6.1: Search hints update** + +Update empty state hints to show operator examples: +``` +from:npub1... Filter by author +kind:article Long-form content +since:2025-01 After January 2025 +#bitcoin Hashtag search +"exact phrase" Exact match +bitcoin OR nostr Either term +``` + +**Step 6.2: Keyboard shortcuts** + +- `Ctrl+K` or `/` → focus search bar (desktop convention) +- `Escape` → close advanced panel / clear search +- `Enter` → execute search immediately (skip debounce) + +**Step 6.3: Search relay configuration** + +- Desktop settings page: list of search relays (editable) +- Default: `relay.nostr.band`, `nostr.wine`, `relay.damus.io` (curated, don't auto-probe NIP-11) +- Future: read from kind 10007 `SearchRelayListEvent` + +## System-Wide Impact + +### Interaction Graph + +1. User types in search bar → `AdvancedSearchBarState.updateFromText()` → `QueryParser.parse()` → `_query` updates +2. `_query` change → `displayText` recomputes (serialized, guarded by `changeSource`) → text bar updates +3. `_query` change → `debouncedQuery` emits after 300ms → `flatMapLatest` cancels old subscriptions → new relay subscriptions created +4. Subscription creation → `relayManager.subscribe()` → relay receives REQ +5. Relay responds → `onEvent` callback → events batched (100ms windows) → stored in cache + state +6. Post-filter applied at batch emission → results displayed in `SearchResultsList` + +### Error Propagation + +- Relay timeout → `onEose` fires → `isSearching` set to false → "No results" shown +- Name resolution failure → name stays in `authorNames` as unresolved → user sees "unresolved: vitor" chip +- Parse error → malformed operators treated as literal text → no crash, graceful degradation +- Non-NIP-50 relay → relay ignores `search` field, returns nothing useful → handled by showing results from other relays +- Individual relay failure → `supervisorScope` isolates failure → other relays continue + +### State Lifecycle Risks + +- **Subscription churn**: Mitigated by `distinctUntilChanged()` on relay statuses (proven pattern from FeedScreen) +- **Bidirectional update loop**: Prevented by `sourceOfChange` discriminator — TEXT changes don't trigger re-serialization +- **Stale results**: Session-only cache cleared on restart. `flatMapLatest` clears previous subscription results on new query. +- **Memory pressure from result batching**: Bounded by LRU cache (500 entries) and batch window (100ms) + +### API Surface Parity + +- `SearchBarState` in commons is used by both Android and Desktop today. `AdvancedSearchBarState` extends this pattern but is new. +- `QueryParser`, `SearchQuery`, `KindRegistry`, `SearchResultFilter` placed in commons so Android can adopt later. +- Desktop `FilterBuilders` gets new `searchAdvanced()` methods but existing methods unchanged. + +## Acceptance Criteria + +### Functional + +- [x] Query operators parse correctly: `from:`, `kind:`, `since:`, `until:`, `#tag`, `"phrase"`, `-exclude`, `OR`, `lang:`, `domain:` +- [x] Kind aliases resolve: `kind:note` → kind 1, `kind:article` → kind 30023, etc. +- [x] Pseudo-kinds handled: `kind:reply` and `kind:media` flagged for client-side post-filtering +- [x] Advanced panel expands/collapses below search bar with `expandVertically` + `fadeIn` animation +- [x] Bidirectional sync: text changes update form, form changes update text, no loops (sourceOfChange guard) +- [x] Default search (no kind filter) queries 30+ kinds across 3 filter groups (Android parity) +- [x] OR queries (`bitcoin OR lightning`) send parallel subscriptions, merge + dedup results (max 3 terms) +- [x] Results grouped by type: People, Notes, Articles, Other with sticky section headers +- [x] Note results show content preview, author, timestamp, kind badge +- [x] Search history persists last 20 queries locally +- [x] Saved searches persist across sessions +- [x] Exclusion terms (`-spam`) filtered client-side, not sent to relay +- [x] Empty search bar shows history + saved searches + operator hints +- [x] `Escape` closes panel / clears search (Ctrl+K deferred — needs window-level handler) + +### Non-Functional + +- [x] Search debounce: 300ms for text, immediate for form toggles +- [x] Max 3 OR terms, 10 authors per query +- [x] Relay subscription churn prevented via `distinctUntilChanged()` +- [x] Result accumulation uses set-based dedup +- [x] `@Immutable` SearchQuery with `ImmutableList` fields +- [x] Session cache cleared on restart +- [x] All query parsing logic unit-tested in commons (roundtrip, edge cases, malformed input) + +### Quality Gates + +- [x] `QueryParser` + `QuerySerializer` roundtrip tests pass +- [x] `KindRegistry` tests for all aliases + pseudo-kinds +- [x] `SearchFilterFactory` compiles + filter generation correct +- [x] `SearchResultFilter` handles exclusion, reply detection, media detection +- [x] Desktop search screen renders results for all kind types +- [x] `spotlessApply` passes + +## Dependencies & Prerequisites + +| Dependency | Status | Notes | +|-----------|--------|-------| +| `Filter.search` field | Exists | `quartz/.../Filter.kt` | +| `SearchRelayListEvent` | Exists | `quartz/.../SearchRelayListEvent.kt` (kind 10007) | +| `SearchBarState` | Exists | `commons/.../SearchBarState.kt` — will be extended | +| `SearchParser` | Exists | `commons/.../SearchParser.kt` — bech32 parsing, kept as-is | +| `FilterBuilders` | Exists | `desktopApp/.../FilterBuilders.kt` — extended | +| `rememberSubscription()` | Exists | `desktopApp/.../SubscriptionUtils.kt` | +| `DesktopLocalCache` | Exists | Needs `findNotesStartingWith()` for local note search | +| kotlinx.serialization | In project | For search history JSON persistence | +| kotlinx.collections.immutable | **Add** | For `ImmutableList`/`persistentListOf()` in SearchQuery | + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Bidirectional sync loops | Medium | High | `sourceOfChange` discriminator, TEXT changes preserve raw text | +| Relay subscription explosion (OR + many relays) | Medium | Medium | Cap: 3 OR terms, batch filters per term. Total max ~27 subs | +| NIP-50 relay variability | High | Medium | Graceful degradation — show whatever relays return | +| Name resolution UX confusion | Medium | Medium | Show "unresolved" indicator, autocomplete dropdown | +| O(n^2) result accumulation | Medium | Medium | Batch + set-based dedup via channelFlow | +| Large result sets from broad queries | High | Low | Client-side pagination, "Show more" per section | +| Android `SearchBarState` compatibility | Low | Medium | New `AdvancedSearchBarState`, old class untouched | + +## Simplicity Guidance (MVP Scoping) + +The full plan is comprehensive. If time-constrained, a minimal viable version can ship with: + +**MVP (Phase 1+2+4 subset, ~350 LOC, 5 files):** +- `SearchQuery` data class (no `@Immutable` yet, plain lists) +- `QueryParser` with 5 operators: `from:`, `kind:`, `since:`, `until:`, `#tag` +- `SearchFilterFactory` for filter generation +- Rewritten `SearchScreen.kt` with form panel (no bidirectional sync — form→text only) +- Unit tests for parser + +**Cut for MVP:** +- OR queries, `lang:`, `domain:`, `-exclude` +- `QuerySerializer` (not needed without bidirectional sync) +- Saved searches (history only) +- Shimmer loading states +- Keyboard shortcuts beyond Enter/Escape + +**Add incrementally:** OR queries → bidirectional sync → saved searches → keyboard shortcuts → NIP-50 extensions + +## File Matrix + +| File | Status | Module | Action | +|------|--------|--------|--------| +| `SearchQuery.kt` | New | commons/commonMain | Create data class with `@Immutable` | +| `QueryParser.kt` | New | commons/commonMain | Create recursive descent parser | +| `QuerySerializer.kt` | New | commons/commonMain | Create serializer | +| `KindRegistry.kt` | New | commons/commonMain | Create kind alias registry | +| `AdvancedSearchBarState.kt` | New | commons/commonMain | Create state holder with `sourceOfChange` | +| `SearchResultFilter.kt` | New | commons/commonMain | Create post-filter (reusable) | +| `QueryParserTest.kt` | New | commons/commonTest | Create tests | +| `QuerySerializerTest.kt` | New | commons/commonTest | Create tests | +| `KindRegistryTest.kt` | New | commons/commonTest | Create tests | +| `SearchFilterFactory.kt` | New | desktopApp | Create filter factory | +| `AdvancedSearchPanel.kt` | New | desktopApp | Create form panel composable | +| `SearchResultsList.kt` | New | desktopApp | Create results list composable | +| `SearchHistoryStore.kt` | New | desktopApp | Create persistence | +| `SearchScreen.kt` | Rewrite | desktopApp | Integrate advanced search | +| `FeedSubscription.kt` | Extend | desktopApp | Add `createAdvancedSearchSubscription()` | +| `FilterBuilders.kt` | Extend | desktopApp | Add search filter methods | +| `SearchBarState.kt` | Keep | commons/commonMain | Untouched (backward compat) | +| `SearchParser.kt` | Keep | commons/commonMain | Untouched (bech32 parsing still used) | + +## Future Considerations + +- **AI natural language → query** (deferred) — parse "notes about bitcoin from Jack since January" to operators +- **Local SQLite FTS5 index** — offline search for desktop +- **NIP-90 DVM search** — pay-per-query via Lightning +- **Search relay auto-discovery** — NIP-11 `supported_nips` check for NIP-50 +- **NIP-05 name resolution** — async resolve `from:vitor@nostr.com` +- **Saved searches as Nostr events** — portable across devices +- **Search analytics** — trending topics, popular queries + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-03-10-advanced-search-brainstorm.md](docs/brainstorms/2026-03-10-advanced-search-brainstorm.md) — Key decisions: relay-first approach, Twitter-style operators + form UI, extensible kind presets, AI deferred, session-only caching + +### Internal References + +- Current search screen: `desktopApp/.../ui/SearchScreen.kt` +- Search state: `commons/.../viewmodels/SearchBarState.kt` +- Bech32 parser: `commons/.../search/SearchParser.kt` +- Filter class: `quartz/.../nip01Core/relay/filters/Filter.kt` +- Android kind groups: `amethyst/.../searchCommand/subassemblies/SearchPostsByText.kt` +- FilterBuilders: `desktopApp/.../subscriptions/FilterBuilders.kt` +- Feed subscriptions: `desktopApp/.../subscriptions/FeedSubscription.kt` +- Relay subscription utils: `desktopApp/.../subscriptions/SubscriptionUtils.kt` +- Relay churn fix: `desktopApp/.../ui/FeedScreen.kt:163-167` (`distinctUntilChanged()` pattern) + +### External References + +- [NIP-50 Search](https://nips.nostr.com/50) +- [NIP-50 extensions](https://github.com/nostr-protocol/nips/blob/master/50.md): `language:`, `domain:`, `sentiment:`, `nsfw:`, `include:spam` +- [kotlinx.collections.immutable](https://github.com/Kotlin/kotlinx.collections.immutable) — `ImmutableList`, `persistentListOf()` + +## Unanswered Questions + +None — all resolved in brainstorm. Implementation details clarified by research agents. diff --git a/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md new file mode 100644 index 0000000000..074e1b112a --- /dev/null +++ b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md @@ -0,0 +1,168 @@ +# Manual Testing Plan: Desktop Media Full Parity (`feat/desktop-media`) + +## Context +Branch has 13 commits implementing Phases 0-9 of desktop media: image display, upload, video/audio playback, lightbox, encrypted DM files, profile gallery, server management, and imeta tags. 49 unit tests cover service logic. This plan covers **integration & UI manual testing** that unit tests can't reach. + +## Prerequisites +- VLC installed on system (required for VLCJ video/audio) +- Logged into a Nostr account with signing capability +- At least one reachable Blossom server (e.g. `https://blossom.primal.net`) +- Test files ready: JPEG (with EXIF), PNG, GIF, SVG, MP4, MP3, large file (>10MB) + +## Run Command +```bash +./gradlew :desktopApp:run +``` + +--- + +## Phase 1: Image Display & Caching + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 1.1 | Images load in feed | Open feed with image notes | Blurhash placeholder shows first, then full image fades in | ✅ PASS | +| 1.2 | User avatars render | Browse feed/profile | All profile pictures display correctly | ✅ PASS | +| 1.3 | Animated GIF | Open note with GIF URL | GIF animates with all frames | ✅ PASS (was static-only, fixed with AnimatedGifImage composable) | +| 1.4 | Base64 inline images | Open note with base64 data URI | Image decodes and displays | ✅ PASS | +| 1.5 | Cache persistence | Close app, reopen, revisit same feed | Previously loaded images appear instantly (disk cache) | ✅ PASS | +| 1.6 | Cache location | Check OS cache dir | macOS: `~/Library/Caches/AmethystDesktop/image_cache/` | ✅ PASS | +| 1.7 | Broken image URL | Note with 404 image URL | Graceful fallback (no crash, placeholder or blank) | ✅ PASS | + +--- + +## Phase 2: File Upload (Blossom Protocol) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 2.1 | Upload JPEG | Compose note → attach JPEG → publish | Upload succeeds, URL in note | ✅ PASS | +| 2.2 | Upload PNG | Compose note → attach PNG → publish | Upload succeeds, URL in note | ✅ PASS | +| 2.3 | EXIF stripped | Upload JPEG with GPS EXIF → download result from Blossom URL | No EXIF metadata in downloaded file | ✅ PASS (implicit — DesktopMediaCompressor strips EXIF before upload) | +| 2.4 | Auth header | Upload with Nostr signer | Server accepts upload (BUD-11 auth works) | ✅ PASS (uploads succeed = auth works) | +| 2.5 | Upload progress | Attach large file → upload | Progress indicator updates during upload | ✅ PASS | +| 2.6 | Upload error | Disconnect network mid-upload | Error state shown, no crash | ✅ PASS | +| 2.7 | Server selector | Open compose → attach file → check server selector | Shows configured Blossom servers; dropdown to switch | ✅ PASS (fixed: added ServerSelector shown when files attached) | + +--- + +## Phase 3: Upload UX (File Picker, Paste, Drag-Drop) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 3.1 | File picker | Compose → click Attach → select image | Native dialog opens, file appears in attachment row | ✅ PASS | +| 3.2 | Multi-select | File picker → select 3 images | All 3 appear in attachment row with thumbnails | ✅ PASS | +| 3.3 | File type filter | File picker dialog | Only media files shown (images, video, audio) | ✅ PASS | +| 3.4 | Clipboard paste | Copy image → Compose → Cmd+V/Ctrl+V | Pasted image appears in attachment row | ✅ PASS | +| 3.5 | Drag & drop | Drag image file onto compose dialog | File appears in attachment row; visual drop indicator | ✅ PASS | +| 3.6 | Remove attachment | Click X on attached file | File removed from row | ✅ PASS | +| 3.7 | Thumbnail preview | Attach image | Thumbnail visible in attachment row | ✅ PASS | +| 3.8 | Alt text | Attach image → enter alt text → publish | Alt text included in imeta tag | ✅ PASS | +| 3.9 | Imeta tags | Publish note with image | Published event has imeta tag (url, m, x, dim, blurhash) | ✅ PASS | + +--- + +## Phase 4: Video Playback (VLCJ) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 4.1 | Inline video | Open note with MP4 URL | Video player renders inline (not just URL text) | ✅ PASS | +| 4.2 | Play/pause | Click play button | Video plays; click again pauses | ✅ PASS | +| 4.3 | Seek bar | Drag seek bar | Video jumps to position | ✅ PASS | +| 4.4 | Volume control | Adjust volume slider | Audio level changes | ✅ PASS (widened slider 80dp→240dp for usability) | +| 4.5 | Aspect ratio | Videos with 16:9 and 4:3 | Correct aspect ratio maintained | ✅ PASS | +| 4.6 | Controls auto-hide | Play video, don't move mouse for 2s | Controls fade out; reappear on mouse move | ✅ PASS | +| 4.7 | Player pool limit | Scroll through 5+ video notes | Max 3 players active; earlier ones release | ✅ PASS | +| 4.8 | WebM format | Note with WebM URL | Plays correctly (if VLC supports it) | ✅ PASS | + +--- + +## Phase 5: Lightbox & Gallery Navigation + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 5.1 | Open lightbox | Click image in feed/profile/thread | Full-screen overlay with dark backdrop | ✅ PASS (fixed: lightbox in Box overlay, split click zones in NoteCard) | +| 5.2 | Close (X button) | Click X button top-left | Lightbox closes | ✅ PASS (added X close button) | +| 5.3 | Close (Esc) | Press Escape | Lightbox closes | ✅ PASS | +| 5.4 | Zoom (scroll) | Mouse wheel up/down | Image zooms in/out (up to 10x) | ✅ PASS | +| 5.5 | Pan (drag) | Zoom in → click-drag | Image pans with cursor | ✅ PASS | +| 5.6 | Reset zoom | Double-click image | Zoom resets to fit | ✅ PASS (added onDoubleTap handler) | +| 5.7 | Multi-image nav | Open note with 3+ images → click one | Arrow buttons visible; left/right navigate | ✅ PASS | +| 5.8 | Arrow key nav | Left/Right arrow keys | Navigate between images | ✅ PASS | +| 5.9 | Index indicator | Multi-image gallery | Shows "1 / 5" style counter | ✅ PASS (added bottom-center pill counter) | +| 5.10 | Save to disk | Cmd+S / Ctrl+S in lightbox | Save dialog opens; file downloads to chosen path | ✅ PASS (fixed: added isMetaPressed for macOS) | +| 5.11 | Video in lightbox | Click video thumbnail | Video plays in lightbox with controls | ✅ PASS | + +--- + +## Phase 6: Encrypted Media (NIP-17 DMs) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail with lock icon visible above input | ⬜ TODO — implemented, needs manual test | +| 6.2 | Send encrypted | Attach file in DM → send | File uploads encrypted to Blossom, kind 15 in GiftWrap | ⬜ TODO — implemented, needs manual test | +| 6.3 | Receive encrypted | Receive DM with encrypted file from another client | File downloads and decrypts; displays correctly | ⬜ TODO — ChatFileAttachment implemented | +| 6.4 | Wrong key | (If testable) Attempt to view another user's encrypted media | Decryption fails gracefully | ⬜ TODO | + +--- + +## Phase 7: Profile Gallery (NIP-68 / Kind 20) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 7.1 | Gallery tab visible | Navigate to profile | "Gallery" tab appears | ✅ PASS | +| 7.2 | Grid layout | Click Gallery tab | Thumbnail grid of kind 20 posts | ⬜ TODO — needs user with kind 20 posts to verify | +| 7.3 | Blurhash thumbs | Gallery loading | Blurhash placeholders before full thumbnails | ⬜ TODO | +| 7.4 | Click → lightbox | Click gallery thumbnail | Opens lightbox at that image | ⬜ TODO | +| 7.5 | Empty gallery | Profile with no picture posts | Empty state "No pictures yet" | ✅ PASS | +| 7.6 | Picture post display | Kind 20 note in feed | Shows image-first layout with title + description | ⬜ TODO — needs kind 20 content | +| 7.7 | Create picture post | Compose kind 20 with multiple images | Multi-image post publishes with imeta tags | ⬜ BLOCKED — no kind 20 compose UI | + +--- + +## Phase 8: Blossom Server Management + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 8.1 | Server list loads | Settings → Media Servers | Shows servers from kind 10063 | ✅ PASS | +| 8.2 | Health check | Click refresh on a server | Green/red/grey with hover tooltip | ✅ PASS (added tooltip: Online/Offline/Checking) | +| 8.3 | Check all | Click "Check All" | All servers checked in parallel | ✅ PASS | +| 8.4 | Add server | Enter URL → click Add | Server appears in list; health check runs | ✅ PASS | +| 8.5 | Remove server | Click delete on a server | Server removed from list | ✅ PASS | +| 8.6 | Set default server | Click "Set as default" on a server | Server moves to top of list | ✅ PASS (added set-as-default action) | +| 8.7 | Publish to relays | Add/remove server → check relays | Kind 10063 event updated on relays | ⬜ TODO — needs relay inspector to verify | +| 8.8 | Invalid server URL | Add "not-a-url" | Add button disabled | ✅ PASS (added URL validation) | +| 8.9 | Settings scroll | Scroll settings page | Content scrolls | ✅ PASS (fixed: added verticalScroll) | + +--- + +## Phase 9: Audio Playback + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 9.1 | Inline audio | Note with MP3 URL | Audio player renders inline | ✅ PASS | +| 9.2 | Play/pause | Click play | Audio plays; click again pauses | ✅ PASS | +| 9.3 | Seek | Drag seek bar | Playback jumps to position | ✅ PASS | +| 9.4 | Time display | Play audio file | Shows current/total time | ✅ PASS | +| 9.5 | Multiple formats | Notes with OGG, WAV, FLAC, AAC, OPUS, M4A | All play (where VLC supports) | ⬜ TODO | +| 9.6 | Audio pool | Scroll past 6+ audio notes | Max 5 audio players; earlier ones release | N/A — GlobalMediaPlayer uses single shared player now | +| 9.7 | Initial volume | Play audio without touching volume slider | Audio audible at 100% on first play | 🐛 BUG — VLC starts silent; moving volume slider fixes it. Tried `:start-volume`, `setVolume` in playing callback, delayed retries — none work. Needs investigation into VLCJ audio output init timing on macOS. | + +--- + +## Phase 10: Cross-Cutting / Edge Cases + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 10.1 | No VLC installed | Remove VLC from PATH → run app | Graceful fallback for video/audio (no crash) | | +| 10.2 | Large file upload | Upload 50MB video | Handles without OOM; progress shown | | +| 10.3 | Rapid scrolling | Scroll feed with many images quickly | No memory leak, images load on demand | | +| 10.4 | Window resize | Resize window while viewing gallery/feed | Layout adapts; no clipping | | +| 10.5 | Multiple uploads | Attach 5 files → upload all → publish | All upload, all get imeta tags | | +| 10.6 | App restart | Restart app after uploads/config | Cache, server prefs, all persisted | | + +--- + +## Unanswered Questions +- Is VLCJ arm64 macOS working? (vlcj-setup plugin uncertain for Apple Silicon) +- Can we test encrypted DM file sharing without a second account/client? +- Should we test SVG rendering separately or is Coil3 SVG decoder sufficient? +- How to verify kind 10063 publish without a relay inspector tool? diff --git a/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md new file mode 100644 index 0000000000..e7e02c4457 --- /dev/null +++ b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md @@ -0,0 +1,833 @@ +--- +title: "feat: Desktop Media — Full Parity" +type: feat +status: active +date: 2026-03-16 +origin: docs/brainstorms/2026-03-16-desktop-media-brainstorm.md +deepened: 2026-03-16 +--- + +# Desktop Media — Full Parity + +## Enhancement Summary + +**Deepened on:** 2026-03-16 +**Research agents used:** Architecture Strategist, Performance Oracle, Security Sentinel, Code Simplicity Reviewer, Pattern Recognition Specialist, Race Condition Reviewer, Coil3 Best Practices Researcher, VLCJ Framework Docs Researcher, Blossom Protocol Researcher, Compose Desktop DnD/Clipboard Researcher, KMP Expect/Actual Pattern Analyzer + +### Critical Corrections (from original plan) + +| # | Original Assumption | Correction | +|---|-------------------|------------| +| 1 | `metadata-extractor` for EXIF stripping | **READ-ONLY library.** Use Apache Commons Imaging `ExifRewriter.removeExifMetadata()` for lossless JPEG EXIF removal | +| 2 | `LinkedHashMap.removeEldestEntry` for caches | **NOT thread-safe** — `get()` in access-order mode mutates internal linked list. Use existing `androidx.collection.LruCache` (already KMP dep in commons) or `ConcurrentHashMap` | +| 3 | `coil-gif` works on JVM desktop | **Android-only** — `AnimatedImageDecoder` requires Android API 28+. Use `org.jetbrains.skia.Codec` for GIF decoding (first frame or animated) | +| 4 | VLCJ via `SwingPanel` | **Flickering confirmed.** Use DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image` (no SwingPanel needed) | +| 5 | Missing `kotlinx-coroutines-swing` dep | Required for `Dispatchers.Main.immediate` on JVM desktop with Coil3 | +| 6 | BlossomFetcher "just move" to commons | **Not trivial** — depends on `BlossomServerResolver` which uses Android `LruCache` + `IRoleBasedHttpClientBuilder`. Must abstract resolver interface first | +| 7 | UploadOrchestrator "extract" to commons | Really a **REWRITE** — deeply coupled to `Context`, `Uri`, `R.string`, `Account`, `ServerType` enum with NIP-95/96/Blossom paths | +| 8 | `vlc-setup` plugin ready for production | **No confirmed arm64 macOS support.** Only tested on Intel Mac (High Sierra). macOS support marked "experimental" | +| 9 | Coil3 memory cache "just works" | **Hard-codes 512MB total memory on non-Android.** Must set `maxSizeBytes()` explicitly using `Runtime.getRuntime().maxMemory()` | +| 10 | `MediaUploadResult` has no platform deps | **Hidden dependency** on `BlurhashWrapper` which imports from Android-only `BlurHashFetcher.kt` | + +### Key Improvements from Research + +1. **VLCJ DirectRendering pattern** — Complete working code from ComposeVideoPlayer project (no SwingPanel) +2. **Coil3 JVM cookbook** — `PlatformContext.INSTANCE`, explicit cache sizing, stable Keyer for custom fetchers +3. **Blossom full spec** — BUD-01 through BUD-11 documented with HTTP examples and auth flow +4. **Compose DnD modern API** — `Modifier.dragAndDropTarget` (old `onExternalDrag` removed in 1.8), `text/uri-list` fallback for Linux +5. **Race condition mitigations** — 13 identified (3 CRITICAL), with concrete fixes +6. **Simplicity alternative** — Desktop-only code first, extract to commons later (0 users → ship fast) + +--- + +## Overview + +Add complete media functionality to Amethyst Desktop: image display, video playback, Blossom upload, drag-drop/paste UX, lightbox, gallery, encrypted DM media, profile gallery, server management, and audio playback. Currently desktop renders **zero media** — notes show URL text only, no inline images or video. + +## Problem Statement + +Desktop Amethyst is text-only. The `NoteCard` at `desktopApp/ui/note/NoteCard.kt:128-132` renders URLs as blue underlined text via `RichTextContent`. No `AsyncImage`, no Coil dependency, no ImageLoader setup. `UserAvatar` uses `coil3.compose.AsyncImage` from commons but likely fails silently — no `SingletonImageLoader` configured for desktop. + +Android has 40+ media-related files across upload, display, playback, and gallery. All are tightly coupled to Android APIs (`Context`, `Uri`, `ContentResolver`, `BitmapFactory`, `MediaMetadataRetriever`, `ExoPlayer`). + +## Proposed Solution + +Extract platform-agnostic media logic to commons using a 4-layer architecture, then build desktop-specific UI on top. Blossom-only upload (no NIP-96). Coil3 for images (already KMP). VLCJ with DirectRendering for video. + +(see brainstorm: `docs/brainstorms/2026-03-16-desktop-media-brainstorm.md`) + +### Simplicity Consideration + +The simplicity reviewer recommends a **desktop-only-first** approach: write desktop code in `desktopApp/` without extracting to commons. Extract later when Android migration happens. Rationale: desktop has zero users, so ship fast and iterate. This plan documents the full extraction architecture but **implementation should start desktop-only** for Phases 0-3, deferring commons extraction to a follow-up PR. + +## Technical Approach + +### Architecture: 4-Layer Extraction + +``` +Layer 1: commons/commonMain — Pure protocol (BlossomClient, auth, server discovery) +Layer 2: commons/commonMain — expect/actual file operations +Layer 3: commons/{androidMain,jvmMain} — Platform actuals +Layer 4: desktopApp/ and amethyst/ — Platform UI +``` + +### Research Insight: jvmAndroid Source Set + +The codebase already has a `jvmAndroid` intermediate source set in `commons/build.gradle.kts` that bridges Android and Desktop JVM code. OkHttp-based HTTP clients work identically on both — place shared JVM code there, not in `commonMain` (which would try to compile for iOS/web targets). + +### Key Architectural Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Upload protocol | Blossom only | NIP-96 deprecated (see brainstorm) | +| Image loading | Coil3 3.4.0 | Already in project, KMP support | +| Video playback | VLCJ 4.8.x + DirectRendering | SwingPanel flickers; DirectRendering uses CallbackVideoSurface → Skia Bitmap | +| Upload code | Desktop-only first, extract later | Ship fast, 0 desktop users, extract when Android migrates | +| Desktop input | Drag-drop + paste + file picker | Essential desktop UX | +| EXIF stripping | Apache Commons Imaging | `metadata-extractor` is read-only; Commons Imaging does lossless EXIF removal | +| Video bundling | Require system VLC for now | `vlc-setup` plugin has no confirmed arm64 macOS support | +| GIF decoding | `org.jetbrains.skia.Codec` | `coil-gif` is Android-only; Skia Codec decodes frames | +| Cache implementation | `androidx.collection.LruCache` | Already KMP dep in commons; thread-safe unlike `LinkedHashMap` | + +### Existing Codebase Inventory + +**Already shared (ready to use):** + +| Component | Location | +|-----------|----------| +| BlurHashDecoder/Encoder | `commons/blurhash/` | +| PlatformImage expect/actual | `commons/blurhash/PlatformImage.kt` → `.android.kt` / `.jvm.kt` | +| Base64ImagePlatform | `commons/base64Image/` (JVM uses ImageIO) | +| RichTextParser (URL → media type) | `commons/richtext/RichTextParser.kt` | +| MediaContentModels | `commons/richtext/MediaContentModels.kt` | +| UserAvatar (AsyncImage) | `commons/ui/components/UserAvatar.kt` | +| GalleryParser | `commons/richtext/GalleryParser.kt` | +| Blossom protocol events | `quartz/nipB7Blossom/` (kind 24242, 10063, URI, UploadResult) | +| NIP-94 FileHeader | `quartz/nip94FileMetadata/` | +| NIP-92 IMetaTag | `quartz/nip92IMeta/` | +| NIP-68 PictureEvent | `quartz/nip68Picture/` | +| NIP-71 VideoEvent/VideoMeta | `quartz/nip71Video/` | +| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | +| `androidx.collection.LruCache` | Already in commons dependencies (KMP) | + +**Android-only (needs extraction or desktop equivalent):** + +| Component | File | Android Deps | Action | +|-----------|------|-------------|--------| +| BlossomUploader | `amethyst/service/uploads/blossom/BlossomUploader.kt` | ContentResolver, Uri, Context, MimeTypeMap | **Rewrite** HTTP core for desktop | +| UploadOrchestrator | `amethyst/service/uploads/UploadOrchestrator.kt` | Context, Uri, R.string, Account, ServerType | **Rewrite** — too coupled for extraction | +| MultiOrchestrator | `amethyst/service/uploads/MultiOrchestrator.kt` | Context | Defer | +| MediaUploadResult | `amethyst/service/uploads/MediaUploadResult.kt` | Hidden BlurhashWrapper dep | Fix dep chain first | +| MediaCompressor | `amethyst/service/uploads/MediaCompressor.kt` | Context, Bitmap, Uri, Compressor | expect/actual | +| BlurhashMetadataCalculator | `amethyst/service/uploads/BlurhashMetadataCalculator.kt` | Context, BitmapFactory, MediaMetadataRetriever | expect/actual | +| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt` | LruCache (Android), IRoleBasedHttpClientBuilder | Replace LruCache with `androidx.collection.LruCache` (KMP) | +| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt` | None | Move (safe) | +| ImageLoaderSetup | `amethyst/service/images/ImageLoaderSetup.kt` | Android SingletonImageLoader, GIF decoder (API 28+) | Desktop equivalent | +| ImageCacheFactory | `amethyst/service/images/ImageCacheFactory.kt` | Context for safeCacheDir | Desktop paths | +| BlossomFetcher | `amethyst/service/images/BlossomFetcher.kt` | Depends on BlossomServerResolver (Android LruCache) | Abstract resolver interface first | +| BlurHashFetcher | `amethyst/service/images/BlurHashFetcher.kt` | toAndroidBitmap() | JVM: toBufferedImage() | +| Base64Fetcher | `amethyst/service/images/Base64Fetcher.kt` | toBitmap() | JVM: toBufferedImage() | +| ZoomableContentView | `amethyst/ui/components/ZoomableContentView.kt` | Android zoom lib | Desktop equivalent | +| ImageGallery | `amethyst/ui/components/ImageGallery.kt` | Pure Compose | Extract | +| MediaAspectRatioCache | `amethyst/model/MediaAspectRatioCache.kt` | Android LruCache | `androidx.collection.LruCache` (KMP) | + +### Dependency Changes + +**desktopApp/build.gradle.kts — add:** +```kotlin +// Image loading (Coil3) +implementation(libs.coil.compose) +implementation(libs.coil.okhttp) +implementation(libs.coil.svg) +// NOTE: coil-gif is Android-only, skip it. Use Skia Codec instead. + +// Coroutines — required for Coil3 Main dispatcher on JVM desktop +implementation(libs.kotlinx.coroutines.swing) + +// Video playback (VLCJ) +implementation("uk.co.caprica:vlcj:4.8.3") + +// EXIF stripping (lossless) +implementation("org.apache.commons:commons-imaging:1.0.0-alpha5") +``` + +**gradle/libs.versions.toml — add:** +```toml +vlcj = "4.8.3" +commons-imaging = "1.0.0-alpha5" +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +``` + +### Research Insight: Coil3 JVM Desktop Configuration + +```kotlin +// Critical: PlatformContext.INSTANCE (not Android Context) +// Critical: Set memory cache explicitly (Coil3 hard-codes 512MB total on non-Android) +// Critical: Set disk cache to OS-appropriate persistent path (default is temp dir) + +fun createDesktopImageLoader(): ImageLoader { + return ImageLoader.Builder(PlatformContext.INSTANCE) + .memoryCache { + MemoryCache.Builder() + .maxSizeBytes(calculateDesktopMemoryCacheSize()) + .strongReferencesEnabled(true) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(getDesktopCacheDir().resolve("amethyst/image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) // 512 MB + .build() + } + .precision(Precision.INEXACT) + .crossfade(true) + .components { + add(SvgDecoder.Factory()) // Works on JVM via Skia SVG + // add(SkiaGifDecoder.Factory()) // Custom: first-frame GIF via Codec + // add(BlossomFetcher.Factory(...)) + } + .build() +} + +fun calculateDesktopMemoryCacheSize(): Long { + val maxMemory = Runtime.getRuntime().maxMemory() + return (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024) +} + +fun getDesktopCacheDir(): File { + val os = System.getProperty("os.name").lowercase() + return when { + "mac" in os -> File(System.getProperty("user.home"), "Library/Caches") + "win" in os -> File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home")) + else -> File(System.getenv("XDG_CACHE_HOME") ?: "${System.getProperty("user.home")}/.cache") + } +} +``` + +### Research Insight: Skia GIF Decoding (replaces coil-gif) + +```kotlin +// org.jetbrains.skia.Codec provides frame-by-frame GIF decoding +// Skia supports: PNG, JPEG, WebP (static), BMP, ICO, WBMP +// GIF: first frame via Image.makeFromEncoded, full animation via Codec +// Animated WebP: first frame only (same Codec approach for animation) +// HEIC: NOT supported on JVM + +class SkiaGifDecoder(private val source: ImageSource) : Decoder { + override suspend fun decode(): DecodeResult { + val bytes = source.source().use { it.readByteArray() } + val data = org.jetbrains.skia.Data.makeFromBytes(bytes) + val codec = org.jetbrains.skia.Codec.makeFromData(data) + val bitmap = org.jetbrains.skia.Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, 0) // first frame + bitmap.setImmutable() + return DecodeResult(image = bitmap.asImage(), isSampled = false) + } + class Factory : Decoder.Factory { /* check mimeType == "image/gif" */ } +} +``` + +### Research Insight: VLCJ DirectRendering Pattern + +```kotlin +// NO SwingPanel — renders directly to Skia Bitmap → Compose Image +val callbackVideoSurface = CallbackVideoSurface( + object : BufferFormatCallback { + override fun getBufferFormat(w: Int, h: Int): BufferFormat { + info = ImageInfo.makeN32(w, h, ColorAlphaType.OPAQUE) + return RV32BufferFormat(w, h) + } + override fun allocatedBuffers(buffers: Array) { + byteArray = ByteArray(buffers[0].limit()) + } + }, + object : RenderCallback { + override fun display(mp: MediaPlayer, bufs: Array, fmt: BufferFormat?) { + bufs[0].get(byteArray); bufs[0].rewind() + val bmp = Bitmap(); bmp.allocPixels(info!!); bmp.installPixels(byteArray) + imageBitmap = bmp.asComposeImageBitmap() // triggers recomposition + } + }, + true, VideoSurfaceAdapters.getVideoSurfaceAdapter() +) +// Then display via: Image(bitmap = imageBitmap, ...) +``` + +**Performance:** 2 frame copies per display frame. ~8MB/frame at 1080p. Adequate for 1-2 players, CPU-intensive for more. New ByteArray per frame required (reusing crashes). + +### Research Insight: Compose Desktop Drag-and-Drop + +```kotlin +// Modern API (1.8+): Modifier.dragAndDropTarget +// Old onExternalDrag was REMOVED in 1.8 +// awtTransferable + DataFlavor.javaFileListFlavor for files +// text/uri-list fallback needed for Linux + browser drops +// Cannot inspect file types during drag hover — filter in onDrop + +val dropTarget = remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val transferable = event.awtTransferable ?: return false + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + onFilesDropped(files) + return true + } + return false + } + } +} +``` + +**Clipboard:** Compose `ClipboardManager` is text-only. Use AWT `Toolkit.getDefaultToolkit().systemClipboard` with `DataFlavor.imageFlavor` for image paste, `DataFlavor.javaFileListFlavor` for file paste. + +--- + +## Implementation Phases + +### Phase 0: Spike — Coil3 + VLCJ Desktop Validation + +**Goal:** Confirm Coil3 loads images on JVM desktop with disk cache. Confirm VLCJ DirectRendering works in Compose. + +| # | Task | File | Details | +|---|------|------|---------| +| 0.1 | Coil3 spike | `desktopApp/spike/CoilSpike.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with DiskCache (persistent path), MemoryCache (explicit size), load HTTP image via AsyncImage | +| 0.2 | VLCJ spike | `desktopApp/spike/VlcjSpike.kt` | DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image`. Test mp4 URL. **NO SwingPanel** | +| 0.3 | Validate SVG | spike | Test `coil-svg` + `SvgDecoder.Factory()` — confirmed KMP-compatible via Skia SVG | +| 0.4 | Validate GIF (first frame) | spike | Test `Image.makeFromEncoded(bytes)` for static GIF. Optionally test `Codec` for frame count | +| 0.5 | Add `kotlinx-coroutines-swing` | `desktopApp/build.gradle.kts` | Required for `Dispatchers.Main.immediate` | + +**Gate:** If VLCJ DirectRendering drops frames badly or Coil3 disk cache fails, evaluate alternatives (Klarity for video, OkHttp cache interceptor for images). +**Deliverable:** Spike branch with working video + image rendering. +**Effort:** 1-2 days. + +### Research Insight: Spike Simplification + +The simplicity reviewer notes Phase 0 can be a 20-line test in `main()` — no need for separate spike files. Just add deps, create `ImageLoader`, call `AsyncImage` in the existing desktop window. + +--- + +### Phase 1: Image Display Foundation + +**Goal:** Images render inline in desktop notes with blurhash previews. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 1.1 | Add Coil3 deps to desktopApp | `desktopApp/build.gradle.kts` | `coil-compose`, `coil-okhttp`, `coil-svg`, `kotlinx-coroutines-swing`. **NOT** `coil-gif` (Android-only) | +| 1.2 | Create DesktopImageLoaderSetup | `desktopApp/service/images/DesktopImageLoaderSetup.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with explicit MemoryCache size (25% of JVM heap, max 512MB), DiskCache (OS-appropriate persistent path), OkHttp, SvgDecoder, custom fetchers. Package: `com.vitorpamplona.amethyst.desktop.service.images` | +| 1.3 | Create DesktopImageCacheFactory | `desktopApp/service/images/DesktopImageCacheFactory.kt` | macOS: `~/Library/Caches/AmethystDesktop`, Linux: `$XDG_CACHE_HOME/AmethystDesktop`, Windows: `%LOCALAPPDATA%/AmethystDesktop/cache`. `maxSizeBytes(512MB)` | +| 1.4 | Create desktop BlossomFetcher | `desktopApp/service/images/DesktopBlossomFetcher.kt` | **Desktop-only first** (don't extract to commons yet). Simplified version without `IRoleBasedHttpClientBuilder`. Uses single OkHttpClient for now. Resolves `blossom:` URIs via kind 10063 server list | +| 1.5 | Create desktop BlurHashFetcher | `desktopApp/service/images/DesktopBlurHashFetcher.kt` | Uses `PlatformImage.toBufferedImage()` → `BitmapImage(toComposeImageBitmap())`. Implement stable `Keyer` to avoid recomposition flicker | +| 1.6 | Create desktop Base64Fetcher | `desktopApp/service/images/DesktopBase64Fetcher.kt` | Uses existing `Base64ImagePlatform.jvm.kt` with ImageIO | +| 1.7 | Create MediaAspectRatioCache | `desktopApp/model/MediaAspectRatioCache.kt` | Use `androidx.collection.LruCache(1000)` (already KMP dep). **NOT** `LinkedHashMap` (not thread-safe) | +| 1.8 | Update NoteCard for inline images | `desktopApp/ui/note/NoteCard.kt` | Parse imeta tags from events. When URL matches image extension → `AsyncImage` with blurhash placeholder. Use `RichTextParser` media classification | +| 1.9 | Initialize ImageLoader in Main.kt | `desktopApp/Main.kt` | Call `setSingletonImageLoaderFactory` at app startup. Use `@OptIn(DelicateCoilApi::class)` for eager init | +| 1.10 | Add SkiaGifDecoder | `desktopApp/service/images/SkiaGifDecoder.kt` | Custom Coil3 `Decoder` using `org.jetbrains.skia.Codec` for GIF first-frame rendering | + +**Deliverable:** Notes with images show blurhash placeholder → full image. `UserAvatar` also starts working. + +**Acceptance Criteria:** +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] Desktop app shows inline images in feed notes +- [ ] Blurhash placeholders display while loading +- [ ] SVG images render correctly (via SvgDecoder) +- [ ] GIF shows first frame (static display is acceptable for MVP) +- [ ] `UserAvatar` renders profile pictures +- [ ] Disk cache persists across app restarts (not in temp dir) +- [ ] Android build unbroken: `./gradlew :amethyst:compileDebugKotlin` + +### Research Insight: Coil3 Custom Fetcher Gotchas + +- Use `coil3.Uri` not `android.net.Uri` +- `ConnectivityChecker` is a no-op on desktop (always returns "connected") +- Custom fetchers **must** implement stable `Keyer` or cache key — otherwise recomposition triggers re-fetch ([#2551](https://github.com/coil-kt/coil/issues/2551)) +- `PlatformContext.INSTANCE` is an empty singleton on JVM — don't cast or use Android methods + +--- + +### Phase 2: Desktop Blossom Upload Client + +**Goal:** Upload media from desktop to Blossom servers. + +**Approach:** Write a **desktop-only** upload client in `desktopApp/`. Don't extract to commons yet (simplicity-first). The Android upload path stays unchanged. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 2.1 | Create DesktopBlossomClient | `desktopApp/service/upload/DesktopBlossomClient.kt` | HTTP PUT `/upload`, `/mirror`, `/media`, DELETE. Takes `File` + metadata. Returns `BlossomUploadResult` (from quartz). Uses OkHttp. Package: `com.vitorpamplona.amethyst.desktop.service.upload` | +| 2.2 | Create DesktopBlossomAuthHelper | `desktopApp/service/upload/DesktopBlossomAuthHelper.kt` | Creates kind 24242 auth events via quartz `BlossomAuthorizationEvent`, base64-encodes for `Authorization: Nostr ` header. Include `t`, `x`, `expiration`, `server` tags per BUD-11 | +| 2.3 | Create DesktopServerDiscovery | `desktopApp/service/upload/DesktopServerDiscovery.kt` | Queries kind 10063 from relays, resolves server list, caches with `androidx.collection.LruCache` | +| 2.4 | Create DesktopMediaMetadata | `desktopApp/service/upload/DesktopMediaMetadata.kt` | `ImageIO.read(file)` for dimensions, `BlurHashEncoder` (from commons) for blurhash, file size + SHA-256 | +| 2.5 | Create DesktopMediaCompressor | `desktopApp/service/upload/DesktopMediaCompressor.kt` | JPEG: `ImageWriteParam.compressionQuality`. EXIF strip: Apache Commons Imaging `ExifRewriter.removeExifMetadata()`. No re-encoding for EXIF removal | +| 2.6 | Create DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Coordinate: strip EXIF → compute metadata → auth → upload. Accept `File` directly (not Uri). Blossom-only (no NIP-95/96) | +| 2.7 | Create DesktopMediaUploadTracker | `desktopApp/service/upload/DesktopMediaUploadTracker.kt` | Simple `mutableStateOf(isUploading: Boolean)` for MVP (matches existing Android's 47-line tracker). StateFlow upgrade later | +| 2.8 | BUD-06 preflight check | `desktopApp/service/upload/ServerHeadCache.kt` | HEAD `/upload` with `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers. Cache results | +| 2.9 | Unit tests | `desktopApp/src/jvmTest/` | Mock OkHttp responses. Test upload, auth header format (base64 of kind 24242), SHA-256 computation, EXIF stripping | + +**Deliverable:** `./gradlew :desktopApp:jvmTest` passes. Desktop can upload files to Blossom. + +**Acceptance Criteria:** +- [ ] BlossomClient uploads file to test server (integration test or mock) +- [ ] Auth headers correctly signed (kind 24242 with `t=upload`, `x=`, `expiration`, `server`) +- [ ] SHA-256 computed correctly for test files +- [ ] EXIF stripped losslessly from JPEG test image +- [ ] BUD-06 preflight HEAD works +- [ ] Android build unbroken + +### Research Insight: Blossom Protocol Details + +**Upload flow per BUD-02:** +1. Compute SHA-256 of exact file bytes +2. Create kind 24242 event: `t=upload`, `x=`, `expiration=+1hr`, `server=` +3. Base64-encode (standard base64 works despite spec saying base64url) +4. `PUT /upload` with `Authorization: Nostr `, `Content-Type`, `Content-Length`, `X-SHA-256` +5. Response: `BlobDescriptor` JSON with `url`, `sha256`, `size`, `type`, `uploaded` + +**`/upload` vs `/media` (BUD-05):** +- `/upload` — server MUST NOT modify blob. Hash preserved. +- `/media` — server MAY optimize (strip EXIF, compress). Returned hash differs. Primal uses this exclusively. +- **Recommendation:** Use `/upload` first (simpler). Add `/media` support later for trusted servers. + +**Auth token security (BUD-11):** +- Always include `server` tag to prevent token replay across servers +- Unscoped `delete` tokens can be replayed — always scope delete operations +- Include `x` tag (blob hash) for upload/delete operations + +### Research Insight: Race Conditions in Upload + +**CRITICAL:** Upload scope cancellation on navigation. If user navigates away during upload, the coroutine scope gets cancelled. Mitigation: launch uploads in a `GlobalScope` or `applicationScope` that survives navigation, with a reference in the tracker. + +**HIGH:** DnD during active upload. User drops new files while upload in progress. Mitigation: queue uploads, don't replace in-flight uploads. + +--- + +### Phase 3: Desktop Upload UX + +**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 3.1 | Desktop file picker | `desktopApp/ui/media/DesktopFilePicker.kt` | `java.awt.FileDialog` (native look) or `JFileChooser` with media type filters. Multi-select support | +| 3.2 | Drag-and-drop handler | `desktopApp/ui/media/DragDropHandler.kt` | `Modifier.dragAndDropTarget` (modern API, 1.8+). `event.awtTransferable` + `DataFlavor.javaFileListFlavor`. Include `text/uri-list` fallback for Linux. Visual drop zone indicator via `onEntered`/`onExited` callbacks | +| 3.3 | Clipboard paste handler | `desktopApp/ui/media/ClipboardPasteHandler.kt` | AWT `Toolkit.getDefaultToolkit().systemClipboard`. Check `DataFlavor.imageFlavor` (→ save BufferedImage to temp file → upload) and `DataFlavor.javaFileListFlavor` (→ direct file upload) | +| 3.4 | Upload dialog composable | `desktopApp/ui/media/UploadDialog.kt` | Progress bar per file, server selector (kind 10063 list), alt text input, cancel button | +| 3.5 | Upload preview thumbnails | `desktopApp/ui/media/UploadPreview.kt` | Thumbnail generation from local file using ImageIO | +| 3.6 | Wire upload to ComposeNoteDialog | `desktopApp/ui/ComposeNoteDialog.kt` | Add "Attach media" button. Connect to file picker + drag-drop. On upload complete → insert imeta tag + URL into note | +| 3.7 | Media button in note composer | `desktopApp/ui/ComposeNoteDialog.kt` | IconButton (Attach) → opens file picker. Shows attached files with remove option | + +**Deliverable:** Drop/paste/pick file → preview → upload → note publishes with embedded image. + +**Acceptance Criteria:** +- [ ] File picker opens, filters by media type, multi-select works +- [ ] Drag file onto compose area shows drop zone highlight +- [ ] Clipboard paste (Ctrl+V / Cmd+V) detects images and files +- [ ] Upload progress shows per-file +- [ ] Server selection from kind 10063 list +- [ ] Alt text saved in imeta tag +- [ ] Published note contains correct imeta tags +- [ ] Cancel upload works mid-flight + +### Research Insight: DnD Platform Quirks + +| Platform | Behavior | Note | +|----------|----------|------| +| macOS Finder | `javaFileListFlavor` works | Aliases resolve to alias file (use `canonicalFile`) | +| Windows Explorer | `javaFileListFlavor` works | Stable | +| Linux Nautilus | `javaFileListFlavor` works | Some WMs only provide `text/uri-list` | +| Browser drag | Usually `text/uri-list` | Not `javaFileListFlavor` — handle separately | +| **Cannot inspect file types during drag hover** | Only know flavor (files vs text), not extensions | Filter in `onDrop`, show generic "drop files here" during hover | + +--- + +### Phase 4: Video Playback (VLCJ DirectRendering) + +**Goal:** Videos play inline in desktop notes. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 4.1 | Add VLCJ deps | `desktopApp/build.gradle.kts` | `uk.co.caprica:vlcj:4.8.3`. **No vlc-setup plugin** — require system VLC for now (arm64 macOS not confirmed for plugin) | +| 4.2 | DesktopVideoPlayer composable | `desktopApp/ui/media/DesktopVideoPlayer.kt` | DirectRendering: `CallbackVideoSurface` → `RV32BufferFormat` → `ByteBuffer.get(byteArray)` → `Skia Bitmap.installPixels()` → `asComposeImageBitmap()` → Compose `Image`. Lazy init player | +| 4.3 | Video controls overlay | `desktopApp/ui/media/VideoControls.kt` | Play/pause, seek bar, volume slider, fullscreen toggle, time display. Auto-hide after 2s. Compose overlay on top of Compose Image (no z-order issues since no SwingPanel) | +| 4.4 | Video in note rendering | `desktopApp/ui/note/NoteCard.kt` | When URL matches video extension → show thumbnail + play button. On click → load VLCJ player | +| 4.5 | Video upload support | `desktopApp/ui/media/UploadDialog.kt` | Accept video files in upload flow. No transcoding — upload as-is | +| 4.6 | Video thumbnail generation | `desktopApp/service/media/VideoThumbnailGenerator.kt` | Use VLCJ `snapshots().get(320, 0)` on a dedicated player. Play → seek to 2s → pause → snapshot → stop | +| 4.7 | VLCJ player pool | `desktopApp/service/media/VlcjPlayerPool.kt` | Single `MediaPlayerFactory`, pool of 2-3 reusable `EmbeddedMediaPlayer` instances. Reuse players (`media().play(newMrl)`) instead of create/destroy. Strong references to prevent GC crash | + +**Deliverable:** Video posts play inline. Controls work. Resource-efficient. + +**Acceptance Criteria:** +- [ ] mp4, webm URLs play inline (m3u8 if VLC supports) +- [ ] Play/pause, seek, volume controls functional +- [ ] Fullscreen toggle works +- [ ] Video pauses when scrolled out of view +- [ ] Player instances pooled (max 3 active) +- [ ] Graceful fallback if VLC init fails (show URL link) +- [ ] VLC not installed → show "Install VLC" prompt with download link + +### Research Insight: VLCJ Lifecycle Critical Rules + +1. **Never let player instances get garbage collected** — native callbacks crash JVM if Java object is collected +2. **Keep strong references** to `MediaPlayerFactory`, `EmbeddedMediaPlayer`, and video surfaces +3. **Reuse players** — one factory, pool of players. Change media with `media().play(newMrl)` +4. **Release order:** player first, then factory +5. **macOS: only CallbackVideoSurface works** — no heavyweight AWT components since Java 7 +6. **Audio-only:** Use `MediaPlayerFactory("--no-video")` for audio tracks + +### Research Insight: Race Conditions in Video + +**CRITICAL:** VLCJ use-after-release segfault. If `DisposableEffect.onDispose` releases player while a `RenderCallback.display()` is executing on the VLC thread → native crash. Mitigation: state machine per player slot. Set `RELEASING` state, wait for in-flight render to complete, then release. + +**CRITICAL:** Rapid navigation between notes with video. User scrolls fast → player allocated → scrolled away before `mediaPlayerReady` event → player released during initialization. Mitigation: debounce player allocation (300ms visibility threshold before allocating). + +--- + +### Phase 5: Lightbox & Gallery + +**Goal:** Full-screen media viewing with zoom and gallery navigation. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 5.1 | Lightbox overlay | `desktopApp/ui/media/LightboxOverlay.kt` | Full-window overlay with semi-transparent backdrop. Click outside to close | +| 5.2 | Zoom + pan | `desktopApp/ui/media/ZoomableImage.kt` | Mouse wheel zoom, click-drag pan. `graphicsLayer` with `scaleX/Y` + `translationX/Y`. Double-click to reset | +| 5.3 | Gallery carousel | `desktopApp/ui/media/GalleryCarousel.kt` | Multi-image navigation with left/right buttons and indicator dots | +| 5.4 | Save to disk | `desktopApp/ui/media/SaveMediaAction.kt` | Button → `JFileChooser` save dialog. Download URL → write to chosen path | +| 5.5 | Keyboard shortcuts | `desktopApp/ui/media/LightboxOverlay.kt` | Esc=close, Left/Right=navigate, +/-=zoom, Ctrl+S=save, Space=play/pause (video) | +| 5.6 | Extract ImageGallery layout logic | `amethyst/ui/components/ImageGallery.kt` → `commons/` | The 1-5+ image grid layout is pure Compose — extract to commons for reuse | + +**Deliverable:** Click image → fullscreen lightbox with zoom. Multi-image carousel. Keyboard navigation. + +**Acceptance Criteria:** +- [ ] Click any inline image opens lightbox +- [ ] Mouse wheel zooms in/out smoothly +- [ ] Click-drag pans when zoomed +- [ ] Arrow keys navigate gallery +- [ ] Esc closes lightbox +- [ ] Save downloads file to disk +- [ ] Video plays in lightbox with controls + +### Research Insight: Race Condition — Lightbox Double-Click + +**MEDIUM:** User double-clicks an image. First click opens lightbox, second click registers as "click outside" → closes immediately. Mitigation: 200ms debounce on lightbox open/close transitions. + +--- + +### Phase 6: Encrypted Media (NIP-17 DM Files) + +**Goal:** Send and receive encrypted files in DMs. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 6.1 | Verify encryption is shared | `quartz/nip17Dm/files/` | `ChatMessageEncryptedFileHeaderEvent` (kind 15) already in commonMain with AESGCM cipher from quartz utils. Should work on desktop as-is | +| 6.2 | Encrypted upload flow | `desktopApp/ui/chat/` | Pick file → encrypt with NIP-44 → Blossom upload → create ChatMessageEncryptedFileHeaderEvent | +| 6.3 | Encrypted download + display | `desktopApp/ui/chat/` | Receive encrypted file event → download from Blossom → decrypt → display/save | +| 6.4 | Chat file upload UI | `desktopApp/ui/chat/ChatFileUploader.kt` | Similar to Phase 3 upload dialog but in DM context. Show encryption indicator | + +**Deliverable:** Desktop DM users can send/receive encrypted images and files. + +**Acceptance Criteria:** +- [ ] Send image in DM from desktop, visible on Android +- [ ] Receive encrypted image from Android, displays on desktop +- [ ] Non-participants cannot view encrypted media +- [ ] Upload progress shown in chat compose + +--- + +### Phase 7: Profile Gallery (NIP-68) + +**Goal:** View and create picture-first posts (kind 20). Profile gallery tab. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 7.1 | Picture event display | `desktopApp/ui/note/PictureDisplay.kt` | Kind 20 renderer. Image-first layout with title + description below. Uses imeta tags for multi-image | +| 7.2 | Profile gallery tab | `desktopApp/ui/profile/GalleryTab.kt` | Grid layout of user's picture posts. Lazy grid with thumbnails | +| 7.3 | Picture post composer | `desktopApp/ui/media/PicturePostComposer.kt` | Create kind 20 events. Multi-image upload + imeta + title + description | +| 7.4 | Gallery entry event support | `desktopApp/ui/profile/GalleryTab.kt` | Read `ProfileGalleryEntryEvent` (kind 1163) for curated galleries | + +**Deliverable:** Profile shows gallery tab with image grid. Users can create picture posts. + +**Acceptance Criteria:** +- [ ] Profile screen shows Gallery tab +- [ ] Gallery grid loads thumbnails with blurhash +- [ ] Click thumbnail opens lightbox +- [ ] Can create kind 20 picture post from desktop +- [ ] Picture post visible on Android Amethyst + +--- + +### Phase 8: Media Server Management + +**Goal:** UI for managing Blossom server list (kind 10063). + +| # | Task | Module | Details | +|---|------|--------|---------| +| 8.1 | Server list settings screen | `desktopApp/ui/settings/MediaServerSettings.kt` | View/add/remove servers. Drag to reorder | +| 8.2 | Server status checking | `desktopApp/service/media/ServerHealthCheck.kt` | HEAD request to verify availability. Show green/red indicator | +| 8.3 | Default server selection | settings UI | Mark preferred upload server. Persist in Preferences | +| 8.4 | Publish kind 10063 | Uses quartz `BlossomServersEvent` | Update on relays when user modifies list | + +**Deliverable:** Settings page to manage Blossom servers. + +**Acceptance Criteria:** +- [ ] Server list loads from kind 10063 event +- [ ] Add/remove servers updates list +- [ ] Health check shows server status +- [ ] Changes published to relays +- [ ] Upload dialog reflects server list + +--- + +### Phase 9: Audio Playback + +**Goal:** Play audio tracks (MP3, OGG, FLAC, WAV) inline in notes. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 9.1 | Audio player composable | `desktopApp/ui/media/AudioPlayer.kt` | VLCJ with `MediaPlayerFactory("--no-video")`. Play/pause + progress bar + time. No video surface needed | +| 9.2 | Audio in note rendering | `desktopApp/ui/note/NoteCard.kt` | URL matches audio extension → show inline audio player | +| 9.3 | Waveform visualization (optional) | `desktopApp/ui/media/AudioWaveform.kt` | Simple amplitude bars. Low priority — skip if VLCJ doesn't expose PCM | + +**Deliverable:** Audio files play inline in notes. + +**Acceptance Criteria:** +- [ ] MP3, OGG, FLAC URLs show audio player +- [ ] Play/pause and seek work +- [ ] Multiple audio players on screen don't conflict + +### Phase Dependency Graph + +``` +Phase 0 (Spike) ──→ Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox) + │ + Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted) + │ + ├──→ Phase 7 (Gallery) + │ + └──→ Phase 8 (Server Mgmt) + + Phase 4 (Video) ────────────→ Phase 9 (Audio) + +Independent: Phase 0 first. Then Phase 1, 2, 4 can run in parallel. +Ship Phases 0-3 as first PR. Phases 4-9 are incremental follow-ups. +``` + +## System-Wide Impact + +### Interaction Graph + +- Image display: NoteCard → RichTextParser → MediaContentModels → Coil3 ImageLoader → custom Fetchers → OkHttp → Blossom servers +- Upload: ComposeNoteDialog → DesktopFilePicker/DragDrop/Clipboard → DesktopMediaCompressor → DesktopBlossomClient → OkHttp → Blossom server → imeta tag → relay broadcast +- Auth chain: DesktopUploadOrchestrator → DesktopBlossomAuthHelper → quartz BlossomAuthorizationEvent → Account.signer → kind 24242 → base64 header + +### Error & Failure Propagation + +| Error Source | Handling | +|-------------|----------| +| Coil3 load failure | Show blurhash if available, else placeholder icon. Log error | +| Blossom upload network error | Retry with exponential backoff (3 attempts). Show error in upload dialog | +| VLCJ init failure (VLC not installed) | Show "Install VLC" prompt with download link. Fall back to URL link | +| SHA-256 mismatch after upload | Re-upload. Warn user if persistent | +| Server unreachable (BUD-06 preflight) | Skip server, try next in list | +| Encrypted media decrypt failure | Show "Unable to decrypt" placeholder | +| EXIF strip failure | Upload anyway with warning (privacy risk) | + +### Race Condition Mitigations (from review) + +| Severity | Issue | Mitigation | +|----------|-------|------------| +| **CRITICAL** | LinkedHashMap concurrent corruption | Use `androidx.collection.LruCache` (thread-safe) or `ConcurrentHashMap` | +| **CRITICAL** | VLCJ use-after-release segfault | State machine per player: `IDLE`→`LOADING`→`PLAYING`→`RELEASING`. Guard `RenderCallback.display()` with state check | +| **CRITICAL** | Upload scope cancelled on navigation | Launch uploads in application-scoped coroutine (survives navigation) | +| HIGH | Stale blurhash/image flash in lazy list | Stable `Keyer` for Coil3 custom fetchers | +| HIGH | DnD during active upload | Queue uploads, don't replace in-flight | +| HIGH | Non-atomic progress + state update | Use `MutableStateFlow` (single source of truth, atomic update) | +| HIGH | Pause-after-dispose (VLCJ) | Check player state before calling `controls().pause()` | +| MEDIUM | Stale server cache | TTL on server discovery cache (5 min) | +| MEDIUM | Lightbox double-click | 200ms debounce on open/close | +| MEDIUM | Gallery rapid navigation | Debounce image load requests | +| MEDIUM | StateFlow progress frequency | Throttle upload progress updates to 100ms intervals | +| LOW | Clipboard + DnD concurrent | Serialize input handling (queue) | +| LOW | Video control timer overlap | Single timer job for auto-hide | + +### State Lifecycle Risks + +| Risk | Mitigation | +|------|------------| +| Upload in progress + user navigates away | Application-scoped coroutine. Upload continues in background. Show notification on completion | +| VLCJ player leak | `DisposableEffect` with state machine release. Player pool enforces max count. Strong references prevent GC crash | +| Coil disk cache corruption | Coil3 handles internally. Worst case: re-download | +| Partial upload (network cut) | SHA-256 pre-computed. Server rejects incomplete uploads. Client retries | + +### API Surface Parity + +| Interface | Android | Desktop | Same? | +|-----------|---------|---------|-------| +| BlossomClient | Android-specific | Desktop-specific | **Different** (for now — extract to commons later) | +| UploadOrchestrator | Android-specific | Desktop-specific | **Different** (for now) | +| ImageLoader fetchers | Android-specific | Desktop-specific | **Different** (for now — same Coil3 API though) | +| VideoPlayer | ExoPlayer | VLCJ DirectRendering | Different | +| FilePicker | ActivityResultContracts | JFileChooser / FileDialog | Different | +| MediaCompressor | Zelory + MediaCodec | ImageIO + Commons Imaging | Different | + +### Integration Test Scenarios + +1. **Upload round-trip:** Desktop uploads image → published note with imeta → other client sees image via Blossom URL +2. **Cross-platform encrypted DM:** Android sends encrypted image → Desktop receives, decrypts, displays +3. **Blossom fallback:** Primary server down → resolver tries secondary server from kind 10063 +4. **Gallery consistency:** Create kind 20 on desktop → visible in Android profile gallery +5. **Video lifecycle:** Scroll feed with 5 video notes → only 2-3 VLCJ players active → no OOM + +## Alternative Approaches Considered + +| Approach | Why Rejected | +|----------|-------------| +| NIP-96 + Blossom | NIP-96 deprecated. Double protocol = double maintenance (see brainstorm) | +| JavaFX MediaView for video | Adds JavaFX runtime (~50MB), conflicts with Compose Desktop rendering | +| SwingPanel for VLCJ | Confirmed flickering + always-on-top z-order issues. DirectRendering avoids entirely | +| Extract to commons immediately | Over-engineering for 0 desktop users. Ship desktop-only first, extract when needed | +| FFmpeg JNI for video | Complex native binding. VLCJ wraps VLC which bundles FFmpeg internally | +| Ktor instead of OkHttp | OkHttp already used everywhere. Both are JVM. No benefit to switching | +| `metadata-extractor` for EXIF strip | **Read-only library**. Only reads EXIF, cannot remove it | +| `LinkedHashMap` for caches | **Not thread-safe** in access-order mode. `get()` mutates internal state | +| Klarity (FFmpeg-based) | Interesting alternative (65 stars, Feb 2026 update), but small community. Worth prototyping as VLCJ backup | + +## Risk Analysis & Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|------------|--------|------------| +| VLCJ DirectRendering CPU overhead at 1080p | Medium | Medium | ~8MB/frame copy. Adequate for 1-2 players. Pool limits exposure | +| VLC not installed on user system | High | High | Show clear "Install VLC" prompt. Link to VLC download. Future: bundle VLC | +| vlc-setup plugin no arm64 macOS | Confirmed | Medium | Don't use plugin for now. Require system VLC. Revisit when plugin matures | +| Coil3 JVM edge cases | Low | Medium | Coil 3.4.0 is mature. Explicit cache sizing avoids defaults. SvgDecoder confirmed working | +| Commons extraction breaks Android | N/A (deferred) | N/A | Desktop-only approach for Phases 0-3 eliminates this risk | +| Apache Commons Imaging alpha status | Medium | Low | Only using EXIF strip (well-tested path). Fallback: skip EXIF strip for MVP | +| Scope creep across 9 phases | High | High | Ship Phases 0-3 as first PR. Later phases are incremental | +| Compose DnD experimental API changes | Medium | Low | Pin Compose version. AWT `DropTarget` fallback available | + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Images display inline in desktop feed notes with blurhash placeholders +- [ ] Upload works via file picker, drag-drop, and clipboard paste +- [ ] Videos play inline with controls (play/pause, seek, volume) +- [ ] Lightbox opens on image click with zoom and gallery navigation +- [ ] Encrypted media works in DMs (send + receive) +- [ ] Profile gallery shows picture posts +- [ ] Blossom server management UI in settings +- [ ] Audio plays inline in notes + +### Non-Functional Requirements + +- [ ] Image load time < 2s for typical photos on broadband +- [ ] Upload shows progress in real-time +- [ ] Max 3 active video players (prevent OOM) +- [ ] Disk cache respects 512MB limit +- [ ] EXIF stripped before upload (privacy) +- [ ] Android build never broken (desktop-only approach) + +### Quality Gates + +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew :desktopApp:jvmTest` passes (upload unit tests) +- [ ] `./gradlew :amethyst:compileDebugKotlin` passes (no Android breakage) +- [ ] `./gradlew spotlessApply` passes +- [ ] Manual test: full upload → display round-trip on desktop + +## Key Files (New or Modified) + +### New Files + +| File | Phase | +|------|-------| +| `desktopApp/.../service/images/DesktopImageLoaderSetup.kt` | 1 | +| `desktopApp/.../service/images/DesktopImageCacheFactory.kt` | 1 | +| `desktopApp/.../service/images/DesktopBlossomFetcher.kt` | 1 | +| `desktopApp/.../service/images/DesktopBlurHashFetcher.kt` | 1 | +| `desktopApp/.../service/images/DesktopBase64Fetcher.kt` | 1 | +| `desktopApp/.../service/images/SkiaGifDecoder.kt` | 1 | +| `desktopApp/.../model/MediaAspectRatioCache.kt` | 1 | +| `desktopApp/.../service/upload/DesktopBlossomClient.kt` | 2 | +| `desktopApp/.../service/upload/DesktopBlossomAuthHelper.kt` | 2 | +| `desktopApp/.../service/upload/DesktopServerDiscovery.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaMetadata.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaCompressor.kt` | 2 | +| `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaUploadTracker.kt` | 2 | +| `desktopApp/.../ui/media/DesktopFilePicker.kt` | 3 | +| `desktopApp/.../ui/media/DragDropHandler.kt` | 3 | +| `desktopApp/.../ui/media/ClipboardPasteHandler.kt` | 3 | +| `desktopApp/.../ui/media/UploadDialog.kt` | 3 | +| `desktopApp/.../ui/media/UploadPreview.kt` | 3 | +| `desktopApp/.../ui/media/DesktopVideoPlayer.kt` | 4 | +| `desktopApp/.../ui/media/VideoControls.kt` | 4 | +| `desktopApp/.../service/media/VlcjPlayerPool.kt` | 4 | +| `desktopApp/.../service/media/VideoThumbnailGenerator.kt` | 4 | +| `desktopApp/.../ui/media/LightboxOverlay.kt` | 5 | +| `desktopApp/.../ui/media/ZoomableImage.kt` | 5 | +| `desktopApp/.../ui/media/GalleryCarousel.kt` | 5 | +| `desktopApp/.../ui/media/AudioPlayer.kt` | 9 | + +### Modified Files + +| File | Phase | Change | +|------|-------|--------| +| `desktopApp/build.gradle.kts` | 0,1,4 | Add Coil3, kotlinx-coroutines-swing, VLCJ, Commons Imaging deps | +| `gradle/libs.versions.toml` | 0,1,4 | Add new version entries | +| `desktopApp/ui/note/NoteCard.kt` | 1,4,9 | Add inline media rendering | +| `desktopApp/ui/ComposeNoteDialog.kt` | 3 | Add media attach button + upload flow | +| `desktopApp/Main.kt` | 1 | Initialize ImageLoader | + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-03-16-desktop-media-brainstorm.md](docs/brainstorms/2026-03-16-desktop-media-brainstorm.md) +- Key decisions carried forward: Blossom-only, Coil3 for images, VLCJ for video, desktop-only-first approach + +### Internal References + +- Existing shared UI analysis: `docs/shared-ui-analysis.md` +- PlatformImage expect/actual pattern: `commons/blurhash/PlatformImage.kt` +- Android ImageLoader setup: `amethyst/service/images/ImageLoaderSetup.kt:25-91` +- Android BlossomUploader: `amethyst/service/uploads/blossom/BlossomUploader.kt` +- NoteCard (current text-only): `desktopApp/ui/note/NoteCard.kt:128-132` +- ComposeNoteDialog (current text-only): `desktopApp/ui/ComposeNoteDialog.kt` +- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md` + +### External References + +- Blossom protocol spec: https://github.com/hzrd149/blossom +- Coil3 Compose Multiplatform: https://coil-kt.github.io/coil/compose/ +- Coil3 GIF on JVM limitation: https://github.com/coil-kt/coil/issues/2347 +- Coil3 SVG on desktop: https://github.com/coil-kt/coil/issues/2330 +- Coil3 Main dispatcher: https://github.com/coil-kt/coil/issues/2009 +- VLCJ documentation: https://github.com/caprica/vlcj +- VLCJ GC tutorial: https://capricasoftware.co.uk/tutorials/vlcj/4/garbage-collection +- ComposeVideoPlayer (DirectRendering): https://github.com/rjuszczyk/ComposeVideoPlayer +- Klarity (FFmpeg alternative): https://github.com/numq/Klarity +- vlc-setup Gradle plugin: https://github.com/nickolay-mahozad/vlc-setup +- Apache Commons Imaging: https://commons.apache.org/proper/commons-imaging/ +- Compose Desktop DnD docs: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-drag-drop.html +- Skiko Codec API: https://jetbrains.github.io/skiko/skiko/org.jetbrains.skia/-codec/index.html +- HEIC not supported on JVM: https://github.com/JetBrains/skiko/issues/942 + +## Answered Questions (from original plan) + +| # | Question | Answer | +|---|----------|--------| +| 1 | Does `coil3-gif` work on JVM desktop? | **No.** Android-only (`AnimatedImageDecoder` requires API 28+). Use `org.jetbrains.skia.Codec` for GIF decoding | +| 2 | Does `vlc-setup` support arm64 macOS? | **Unconfirmed.** Only Intel Mac (High Sierra) tested. macOS marked "experimental". Require system VLC for now | +| 3 | Does `awtTransferable` deliver files on macOS? | **Yes.** `DataFlavor.javaFileListFlavor` works with Finder on JDK 17+. Include `text/uri-list` fallback for Linux | +| 4 | Is `LinkedHashMap.removeEldestEntry` sufficient? | **No.** Not thread-safe in access-order mode. Use `androidx.collection.LruCache` (already KMP dep) or `ConcurrentHashMap` | +| 5 | Is Coil3 `SvgDecoder` KMP? | **Yes.** Works on JVM via `org.jetbrains.skia.svg.SVGDOM`. Add `SvgDecoder.Factory()` to ImageLoader components | +| 6 | Is `UserAvatar` rendering on desktop? | **Likely failing silently** — no `SingletonImageLoader` configured. Will work after Phase 1 ImageLoader init | + +## Remaining Unanswered Questions + +1. Does VLCJ DirectRendering achieve acceptable frame rate at 1080p on Apple Silicon? +2. Can `org.jetbrains.skia.Codec` handle all animated WebP variants reliably on JVM? +3. Is VLC 3.0.21 universal binary reliable via JNA on arm64 macOS with JDK 17+? +4. Is there a GPU-accelerated path for VLCJ → Skia Bitmap transfer (avoid CPU copy)? +5. Does `Modifier.dragAndDropTarget` work on Linux Wayland in Compose 1.10.x? +6. Can Klarity handle streaming URLs (HLS, DASH) as VLCJ alternative? +7. Does Coil3 `@ExperimentalCoilApi` `NetworkFetcher` constructor stay stable across versions? diff --git a/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md new file mode 100644 index 0000000000..94c4eeebb3 --- /dev/null +++ b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md @@ -0,0 +1,800 @@ +--- +title: "feat: Desktop DM Encrypted Media (NIP-17)" +type: feat +status: completed +date: 2026-03-18 +deepened: 2026-03-18 +origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md +--- + +# feat: Desktop DM Encrypted Media (NIP-17) + +## Enhancement Summary + +**Deepened on:** 2026-03-18 +**Sections enhanced:** 5 phases + security + performance + edge cases +**Research sources:** Source code analysis of all 9 key files, Android reference implementation, Blossom protocol research, existing learnings + +### Key Improvements +1. Corrected `DesktopBlossomClient` — needs `ByteArray` overload (currently only accepts `File`) +2. `IAccount` interface needs `sendNip17EncryptedFile()` added (not just `DesktopIAccount`) +3. `NIP17Factory.createEncryptedFileNIP17()` already exists — plan incorrectly referenced `createFileNIP17()` +4. `DesktopMediaMetadata.compute()` reads file bytes internally — encrypted upload must avoid double-read +5. Added streaming encryption consideration for large files and memory pressure mitigation + +### Critical Corrections from Source Code +- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` parameter — encrypted size, not original +- `DesktopBlossomClient.upload()` only accepts `File`, not `ByteArray` — needs overload or temp file +- `ChatMessageEncryptedFileHeaderEvent.build()` takes `cipher: AESGCM` directly — no manual key/nonce extraction needed +- `key()` and `nonce()` return tag values as parsed types (via `EncryptionKey`/`EncryptionNonce` tags) + +--- + +## Overview + +Implement full send + receive encrypted media support in desktop DM chat. Users can attach files via paperclip button or drag-and-drop, which get AES-GCM encrypted before upload to Blossom. Files are sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap (NIP-59). Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay. + +This is Phase 6 of the desktop media parity plan. + +## Problem Statement / Motivation + +Desktop DM chat (`ChatPane.kt`) supports text messages but has no file attachment capability. Android has a complete implementation (`ChatFileUploader` + `ChatFileSender`). Most protocol and service pieces exist on desktop already — this work wires them together with a desktop-native UX. + +## Proposed Solution + +Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol layer (quartz) and extending desktop services. Three workstreams: upload pipeline, send logic, and receive/display. + +(see brainstorm: `docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md`) + +--- + +## Implementation Phases + +### Phase A: Encrypted Upload Pipeline + +**Goal:** `DesktopUploadOrchestrator` gains `uploadEncrypted()` method. + +**Files:** +- `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` — add `uploadEncrypted()` +- `desktopApp/.../service/upload/DesktopBlossomClient.kt` — add `ByteArray` upload overload +- `desktopApp/.../service/upload/DesktopBlossomAuth.kt` — no changes needed (already takes hash + size) +- `desktopApp/.../service/upload/DesktopMediaMetadata.kt` — no changes needed + +**Implementation:** + +```kotlin +// DesktopUploadOrchestrator.kt — new method +suspend fun uploadEncrypted( + file: File, + cipher: AESGCM, + serverBaseUrl: String, + signer: NostrSigner, +): EncryptedUploadResult { + // 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) + // NOTE: DesktopMediaMetadata.compute() reads file bytes internally for SHA256 + val metadata = DesktopMediaMetadata.compute(file) + + // 2. Read file bytes and encrypt + val plaintext = file.readBytes() + val encrypted = cipher.encrypt(plaintext) + + // 3. Compute SHA256 of ENCRYPTED blob (critical: not plaintext) + val encryptedHash = sha256(encrypted).toHexKey() + val encryptedSize = encrypted.size.toLong() + + // 4. Create Blossom auth with ENCRYPTED hash and size + val authHeader = DesktopBlossomAuth.createUploadAuth( + hash = encryptedHash, + size = encryptedSize, + alt = "Encrypted upload", + signer = signer, + ) + + // 5. Upload encrypted blob (needs ByteArray overload on client) + val result = client.upload( + bytes = encrypted, + contentType = "application/octet-stream", // encrypted blob, not original mime + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + return EncryptedUploadResult( + blossom = result, + metadata = metadata, // pre-encryption metadata (dimensions, blurhash, mime) + encryptedHash = encryptedHash, + encryptedSize = encryptedSize.toInt(), + ) +} +``` + +```kotlin +// New data class alongside existing UploadResult +data class EncryptedUploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, // original file metadata + val encryptedHash: String, // SHA256 of encrypted blob + val encryptedSize: Int, // size of encrypted blob +) +``` + +```kotlin +// DesktopBlossomClient.kt — add ByteArray overload +suspend fun upload( + bytes: ByteArray, + contentType: String, + serverBaseUrl: String, + authHeader: String?, +): BlossomUploadResult = withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + + val requestBuilder = Request.Builder() + .url(apiUrl) + .put(requestBody) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } +} +``` + +### Research Insights — Phase A + +**Critical gotchas (from Blossom protocol research + source code analysis):** + +| Issue | Detail | Solution | +|-------|--------|----------| +| Hash mismatch | `X-SHA-256` header must match encrypted blob hash, not plaintext | Compute SHA256 after `cipher.encrypt()` | +| Content-Type | Upload encrypted blob as `application/octet-stream`, not original MIME | Server stores opaque blob | +| Auth size | `DesktopBlossomAuth.createUploadAuth(size=)` must be encrypted size | Pass `encrypted.size.toLong()` | +| Double file read | `DesktopMediaMetadata.compute()` calls `file.readBytes()` internally | Acceptable — metadata computation is separate from encryption read | +| Memory pressure | `file.readBytes()` + `cipher.encrypt()` = 2x file size in memory | For files <50MB this is fine; for larger files consider streaming (future) | + +**Security considerations:** +- Generate fresh `AESGCM()` per file — never reuse key/nonce pairs (AES-GCM nonce reuse completely breaks confidentiality) +- Clear `plaintext` ByteArray after encryption (`plaintext.fill(0)`) to minimize exposure window +- Encrypted blob content type should be `application/octet-stream` to avoid leaking file type to Blossom server + +--- + +### Phase B: DM File Attach UI in ChatPane + +**Goal:** Paperclip button, thumbnail row, drag-and-drop in `ChatPane.kt`. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — modify `MessageInput()` composable and `ChatPane()` for drag-drop + +**Implementation:** + +Modify `MessageInput()` (currently lines 527-627) — add parameters and UI elements: + +```kotlin +// Updated MessageInput signature +@Composable +private fun MessageInput( + messageText: String, + isNip17: Boolean, + requiresNip17: Boolean, + canSend: Boolean, + attachedFiles: List, // NEW + onMessageChange: (String) -> Unit, + onToggleNip17: () -> Unit, + onAttachFiles: (List) -> Unit, // NEW + onRemoveFile: (Int) -> Unit, // NEW + onSend: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + // Attachment thumbnail row (above text input) + if (attachedFiles.isNotEmpty()) { + AttachmentRow( + files = attachedFiles, + isEncrypted = isNip17, + onRemove = onRemoveFile, + ) + Spacer(Modifier.height(4.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Paperclip attach button (only in NIP-17 mode) + if (isNip17) { + IconButton( + onClick = { /* open JFileChooser */ }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach file", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Existing OutlinedTextField... + // Existing Send button... + } + + // Existing NIP-17 indicator... + } +} +``` + +```kotlin +// AttachmentRow composable +@Composable +private fun AttachmentRow( + files: List, + isEncrypted: Boolean, + onRemove: (Int) -> Unit, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + ) { + itemsIndexed(files) { index, file -> + Box(modifier = Modifier.size(64.dp)) { + // Thumbnail (image preview or file icon) + AttachmentThumbnail(file) + + // Remove button (top-right) + IconButton( + onClick = { onRemove(index) }, + modifier = Modifier.align(Alignment.TopEnd).size(18.dp), + ) { + Icon(Icons.Default.Close, "Remove", Modifier.size(12.dp)) + } + + // Lock icon overlay (bottom-end, only when encrypted) + if (isEncrypted) { + Icon( + Icons.Default.Lock, + contentDescription = "Encrypted", + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + RoundedCornerShape(4.dp), + ) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} +``` + +**File picker (JFileChooser pattern from ComposeNoteDialog):** + +```kotlin +// File picker helper — runs on AWT thread +private fun openFilePicker(onFilesSelected: (List) -> Unit) { + val chooser = JFileChooser().apply { + isMultiSelectionEnabled = true + fileFilter = FileNameExtensionFilter( + "Media files", + "jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov", + "mp3", "ogg", "wav", "flac", "aac", + ) + } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + onFilesSelected(chooser.selectedFiles.toList()) + } +} +``` + +**Drag-and-drop on ChatPane (wrapping the Column):** + +```kotlin +// In ChatPane() — wrap the main Column with drag-drop +var isDragOver by remember { mutableStateOf(false) } + +Column( + modifier = modifier + .fillMaxSize() + .onExternalDrag( + onDragStart = { isDragOver = true }, + onDragExit = { isDragOver = false }, + onDrop = { state -> + isDragOver = false + val files = state.dragData + .let { it as? DragData.FilesList } + ?.readFiles() + ?.mapNotNull { uri -> File(URI(uri)) } + ?: emptyList() + // Add to attachedFiles state + attachedFiles.addAll(files) + }, + ) + .then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) + } else { + Modifier + } + ), +) { + // ... existing ChatPane content +} +``` + +### Research Insights — Phase B + +**Compose best practices applied:** + +| Pattern | Recommendation | Rationale | +|---------|---------------|-----------| +| State location | `attachedFiles` as `mutableStateListOf()` in `ChatPane`, not `MessageInput` | State hoisted to parent that also handles send/upload logic | +| File picker thread | `JFileChooser` must run on EDT; use `withContext(Dispatchers.Main)` or `SwingUtilities.invokeLater` | AWT file dialogs block; Compose coroutines must not be blocked | +| Drag-drop modifier | `Modifier.onExternalDrag` from `compose.ui` | Already used in ComposeNoteDialog — consistent pattern | +| Thumbnail rendering | Use `ImageIO.read()` for image thumbnails; generic icon for audio/video | Avoid loading full-resolution images; scale down for 64dp thumbnails | +| `canSend` update | `canSend` should also be true when `attachedFiles.isNotEmpty()` even if `messageText.isEmpty()` | Allow sending file-only messages (no text required) | + +**Desktop UX considerations:** +- Keyboard shortcut: Cmd+V / Ctrl+V paste should also add clipboard images to attachments (future enhancement) +- Tooltip on disabled attach button (NIP-04 mode): "Switch to NIP-17 to send files" +- Maximum attachment count: limit to 10 files to prevent UI overflow +- File size validation: warn on files >50MB before attempting upload + +--- + +### Phase C: Send Encrypted File Event + +**Goal:** Build and dispatch `ChatMessageEncryptedFileHeaderEvent` (kind 15) from upload results. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — send logic in `ChatPane()` scope +- `desktopApp/.../model/DesktopIAccount.kt` — add `sendNip17EncryptedFile()` +- `commons/.../model/IAccount.kt` — add interface method + +**Implementation:** + +```kotlin +// IAccount.kt — add to interface (commons/commonMain) +/** Send a NIP-17 gift-wrapped encrypted file header */ +suspend fun sendNip17EncryptedFile(template: EventTemplate) +``` + +```kotlin +// DesktopIAccount.kt — implement (mirrors sendNip17PrivateMessage exactly) +override suspend fun sendNip17EncryptedFile( + template: EventTemplate, +) { + if (!isWriteable()) return + + val result = NIP17Factory().createEncryptedFileNIP17(template, signer) + + // Optimistic local add — use the inner event + val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent + addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey)) + + // Collect wraps with target relays and send + val batch = result.wraps.map { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = if (recipientKey != null) { + val dmRelays = localCache.getOrCreateUser(recipientKey) + .dmInboxRelays()?.toSet() + dmRelays?.ifEmpty { null } ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + wrap to targetRelays + } + + scope.launch { dmSendTracker.sendBatch(batch) } +} +``` + +```kotlin +// ChatPane.kt — send handler for encrypted files +// In ChatPane composable scope, after upload completes: +private suspend fun sendEncryptedFiles( + uploads: List>, + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, +) { + val recipients = roomKey.users.map { cacheProvider.getOrCreateUser(it).toPTag() } + + for ((result, cipher) in uploads) { + val template = ChatMessageEncryptedFileHeaderEvent.build( + to = recipients, + url = result.blossom.url, + cipher = cipher, // passes algo, key, nonce automatically + mimeType = result.metadata.mimeType, + hash = result.encryptedHash, // hash of encrypted blob + size = result.encryptedSize, + dimension = result.metadata.width?.let { w -> + result.metadata.height?.let { h -> DimensionTag(w, h) } + }, + blurhash = result.metadata.blurhash, + originalHash = result.metadata.sha256, // hash of original plaintext + ) + account.sendNip17EncryptedFile(template) + } +} +``` + +**Full send flow in ChatPane (upload + send):** + +```kotlin +// In ChatPane composable — triggered by Send button when attachedFiles.isNotEmpty() +scope.launch { + val orchestrator = DesktopUploadOrchestrator() + val server = /* user's default Blossom server from kind 10063 */ + val uploads = mutableListOf>() + + for (file in attachedFiles) { + val cipher = AESGCM() // fresh cipher per file + try { + val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer) + uploads.add(result to cipher) + } catch (e: Exception) { + // Show error, keep remaining files for retry + println("Upload failed for ${file.name}: ${e.message}") + } + } + + if (uploads.isNotEmpty()) { + sendEncryptedFiles(uploads, roomKey, account, cacheProvider) + attachedFiles.clear() + + // Also send text message if present + if (messageState.canSend) { + messageState.send() + messageState.clear() + } + } +} +``` + +### Research Insights — Phase C + +**Correctness checks from source code:** + +| Verified | Detail | +|----------|--------| +| `NIP17Factory.createEncryptedFileNIP17()` | Exists at `NIP17Factory.kt:86-97` — takes `EventTemplate` | +| `ChatMessageEncryptedFileHeaderEvent.build()` | Takes `cipher: AESGCM` directly — auto-extracts algo/key/nonce via `encryptionAlgo(cipher.name())`, `encryptionKey(cipher.keyBytes)`, `encryptionNonce(cipher.nonce)` | +| Android pattern | `Account.sendNip17EncryptedFile()` at line 1612 calls `NIP17Factory().createEncryptedFileNIP17(template, signer)` then `broadcastPrivately(wraps)` | +| Desktop pattern | `DesktopIAccount.sendNip17PrivateMessage()` at line 128 — same structure, replace `createMessageNIP17` with `createEncryptedFileNIP17` | + +**Concurrency considerations:** +- Upload files sequentially (not parallel) to avoid memory pressure from multiple concurrent encryptions +- Use `supervisorScope` if you want one failed upload to not cancel others +- Send events can be parallelized (each is independent after upload) + +**Interface change impact:** +- Adding `sendNip17EncryptedFile` to `IAccount` requires implementation in Android's `Account.kt` too — but it already has it as a non-override method. Just add `override` keyword. + +--- + +### Phase D: Receive & Display Encrypted Media + +**Goal:** Encrypted media in received DMs renders inline with lock icon. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — enhance `ChatFileAttachment()` (line 442) +- `desktopApp/.../service/media/EncryptedMediaService.kt` — already exists, enhance with caching + +**Current state:** `ChatFileAttachment` is called at line 442 but its implementation is minimal or placeholder. The event is already detected as `ChatMessageEncryptedFileHeaderEvent`. + +**Implementation:** + +```kotlin +@Composable +private fun ChatFileAttachment(event: ChatMessageEncryptedFileHeaderEvent) { + // Parse cipher params from event tags + val url = event.url() + val keyBytes = event.key() // returns ByteArray? (parsed from EncryptionKey tag) + val nonceBytes = event.nonce() // returns ByteArray? (parsed from EncryptionNonce tag) + val mimeType = event.mimeType() + val blurhashStr = event.blurhash() + + if (url.isNullOrEmpty() || keyBytes == null || nonceBytes == null) { + // Missing encryption params — show error + EncryptedFileError("Missing encryption data") + return + } + + // Async download + decrypt with proper state management + var decryptionState by remember(event.id) { + mutableStateOf(DecryptionState.Loading) + } + + LaunchedEffect(event.id) { + decryptionState = try { + val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonceBytes) + DecryptionState.Success(bytes) + } catch (e: Exception) { + DecryptionState.Error(e.message ?: "Decryption failed") + } + } + + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + ) { + when (val state = decryptionState) { + is DecryptionState.Loading -> { + // Blurhash placeholder or shimmer + if (blurhashStr != null) { + BlurhashPlaceholder( + blurhash = blurhashStr, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(24.dp), + ) + } + } + + is DecryptionState.Success -> { + DecryptedMediaContent( + bytes = state.bytes, + mimeType = mimeType, + modifier = Modifier.fillMaxWidth(), + ) + } + + is DecryptionState.Error -> { + EncryptedFileError(state.message) + } + } + + // Lock icon overlay (always visible) + Icon( + Icons.Default.Lock, + contentDescription = "End-to-end encrypted", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .size(20.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + RoundedCornerShape(4.dp), + ) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } +} + +private sealed class DecryptionState { + data object Loading : DecryptionState() + data class Success(val bytes: ByteArray) : DecryptionState() + data class Error(val message: String) : DecryptionState() +} +``` + +```kotlin +// DecryptedMediaContent — render based on MIME type +@Composable +private fun DecryptedMediaContent( + bytes: ByteArray, + mimeType: String?, + modifier: Modifier = Modifier, +) { + when { + mimeType?.startsWith("image/") == true -> { + val bitmap = remember(bytes) { + org.jetbrains.skia.Image.makeFromEncoded(bytes) + .toComposeImageBitmap() + } + Image( + bitmap = bitmap, + contentDescription = "Encrypted image", + contentScale = ContentScale.Fit, + modifier = modifier, + ) + } + mimeType?.startsWith("video/") == true -> { + // Show video thumbnail or play button + // Full video playback requires writing decrypted bytes to temp file for VLC + VideoFilePlaceholder(bytes.size, modifier) + } + mimeType?.startsWith("audio/") == true -> { + AudioFilePlaceholder(bytes.size, modifier) + } + else -> { + GenericFilePlaceholder(mimeType, bytes.size, modifier) + } + } +} +``` + +### Research Insights — Phase D + +**Compose state management:** +- Use `LaunchedEffect(event.id)` not `produceState` — better control over loading/error states via sealed class +- `remember(event.id)` keys state to the event, preventing re-download on recomposition +- `remember(bytes)` for Skia bitmap conversion prevents recreating bitmap every recomposition + +**Performance considerations:** + +| Concern | Mitigation | +|---------|------------| +| Re-download on scroll | Add in-memory LRU cache to `EncryptedMediaService` keyed by URL | +| Large decrypted images in memory | Scale down to max display size (300dp) before caching | +| Bitmap creation from bytes | `org.jetbrains.skia.Image.makeFromEncoded()` is efficient for JVM | +| Video/audio playback | Requires writing decrypted bytes to temp file (VLC needs file path). Use `File.createTempFile()` with `.deleteOnExit()` | + +**Add caching to EncryptedMediaService:** + +```kotlin +object EncryptedMediaService { + private val httpClient = OkHttpClient() + private val cache = LruCache(maxSize = 20) // ~20 decrypted files + + suspend fun downloadAndDecrypt( + url: String, + keyBytes: ByteArray, + nonce: ByteArray, + ): ByteArray { + cache.get(url)?.let { return it } + + return withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + val encryptedBytes = response.use { + if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}") + it.body.bytes() + } + + val cipher = AESGCM(keyBytes, nonce) + val decrypted = cipher.decrypt(encryptedBytes) + cache.put(url, decrypted) + decrypted + } + } +} +``` + +**Security note:** Cached decrypted bytes are in JVM heap memory. This is acceptable for a desktop app (no shared memory concerns). Consider cache eviction on app minimize if paranoid. + +--- + +### Phase E: Error Handling & Edge Cases + +**Files:** Across all modified files above. + +| Scenario | Handling | Implementation | +|----------|----------|----------------| +| Upload fails mid-way | Show error snackbar, keep file in attachment row for retry | Catch in upload loop, skip failed file, continue others | +| Network disconnect during upload | Catch `IOException`, show "Upload failed — check connection" | OkHttp throws on network failure | +| Decryption fails (wrong key) | Show "Could not decrypt" placeholder, no crash | `DecryptionState.Error` sealed class variant | +| Blossom server unreachable | Fallback to next server in user's kind 10063 list | Query `BlossomServersEvent` for alternatives | +| Large file (>10MB) | Show progress indicator during encrypt + upload | Extend `DesktopUploadTracker` for encrypted uploads | +| Unsupported MIME type | Show generic file icon with filename + size | `GenericFilePlaceholder` composable | +| No Blossom servers configured | Disable attach button, show tooltip | Check server list before enabling button | +| NIP-04 mode active | Attach button hidden (encrypted files are NIP-17 only) | `if (isNip17)` guard on paperclip button | +| Corrupt encrypted blob | `AESGCM.decrypt()` throws `AEADBadTagException` | Catch specifically, show "File corrupted or tampered" | +| Duplicate upload (same file) | Each send generates new cipher — different encrypted blob | Intentional: no deduplication for privacy | +| Rapid send taps | Disable send button during upload/send | `isUploading` state flag | + +### Research Insights — Phase E + +**Error hierarchy (from AESGCM source):** +- `AESGCM.decrypt()` uses JCE `Cipher` on JVM — throws `AEADBadTagException` for wrong key (not generic exception) +- `AESGCM.decryptOrNull()` exists — returns null instead of throwing. Prefer this for UI code. + +**Security edge cases:** +- Never log encryption keys, nonces, or decrypted content +- Temp files for video playback must be deleted after use (`deleteOnExit()` + explicit delete on composable disposal) +- Don't show detailed error messages that could leak cipher state ("wrong key" is fine, "key was X but expected Y" is not) + +--- + +## Technical Considerations + +### Security +- New `AESGCM()` cipher per file (random key + nonce) — never reuse +- Encrypt before hashing — Blossom auth scoped to encrypted blob +- Plaintext never leaves device unencrypted +- GiftWrap ensures only recipients can see the kind 15 event +- Zero `plaintext` ByteArray after encryption to minimize exposure window +- Upload content type is `application/octet-stream` (doesn't leak file type to server) +- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay attacks + +### Performance +- Encryption runs on `Dispatchers.IO` (non-blocking) +- Sequential file upload (not parallel) to limit memory to 2x single file size +- In-memory LRU cache for decrypted media (20 entries) avoids re-downloading +- `org.jetbrains.skia.Image.makeFromEncoded()` for efficient bitmap creation +- Large files (>50MB): warn user before upload; consider streaming in future + +### Architecture +- No new modules — extends existing desktop services +- Protocol layer (quartz) unchanged — fully reuses existing events/ciphers +- Follows Android patterns for consistency across platforms +- `IAccount` interface gains one new method; Android already has implementation (add `override`) + +--- + +## Acceptance Criteria + +- [x] Paperclip attach button visible in DM chat input (NIP-17 mode only) +- [x] File picker opens, supports image/video/audio selection +- [x] Selected files show as thumbnails above input with X to remove +- [x] Drag-and-drop files onto chat area adds to attachments (visual drop indicator) +- [x] Send encrypts files with AES-GCM before upload to Blossom +- [x] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap +- [x] Lock icon overlay visible on attachment thumbnails before send +- [x] Received encrypted media downloads, decrypts, displays inline +- [x] Lock icon overlay on received encrypted media in chat bubbles +- [x] Wrong key / failed decryption shows error state, no crash +- [x] Upload progress indicator during encrypt + upload +- [x] No attach button when in NIP-04 mode +- [x] `canSend` true when files attached (even without text) +- [x] Send button disabled during active upload + +## Test Plan (from Phase 6 testing plan) + +| # | Test | Steps | Expected | +|---|------|-------|----------| +| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail appears above input with lock indicator | +| 6.2 | Send encrypted | Attach file → send | File uploads encrypted to Blossom, kind 15 event in GiftWrap sent | +| 6.3 | Receive encrypted | Receive DM with encrypted file (from Android) | File downloads, decrypts, displays in bubble with lock icon | +| 6.4 | Wrong key | View encrypted media where key doesn't match | "Could not decrypt" placeholder, no crash | +| 6.5 | Drag-drop attach | Drag image onto DM chat | File appears in attachment row with lock icon | +| 6.6 | Multiple files | Attach 3 files → send | All 3 upload encrypted, each gets own kind 15 event | +| 6.7 | Large file | Attach 15MB image → send | Progress shown, upload succeeds | +| 6.8 | NIP-04 mode | Toggle to NIP-04 → check attach button | Attach button hidden/disabled | + +## Dependencies & Risks + +| Dependency | Risk | Mitigation | +|-----------|------|------------| +| Blossom server availability | Upload fails | Retry + fallback to alternate servers from kind 10063 | +| VLC for video playback | Encrypted video won't play without VLC | Show "Download" button for video; image display works regardless | +| Android client for cross-platform test | Can't verify interop | Use Android emulator or second account | +| `DesktopBlossomClient` ByteArray overload | New method needed | Simple addition — uses OkHttp `ByteArray.toRequestBody()` | +| `IAccount` interface change | Requires Android-side `override` keyword | Android already has the method, just not `override` | + +## Sources & References + +### Origin +- **Brainstorm document:** [docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md](docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md) — Key decisions: inline attach button UX, drag-drop support, lock icon overlay, full send+receive scope + +### Internal References (verified from source code) +- Android `ChatFileUploader.justUploadNIP17()`: `amethyst/.../upload/ChatFileUploader.kt:39-80` +- Android `ChatFileSender.sendNIP17()`: `amethyst/.../upload/ChatFileSender.kt:46-71` +- Android `Account.sendNip17EncryptedFile()`: `amethyst/.../model/Account.kt:1612-1617` +- Desktop `ChatPane.kt`: `desktopApp/.../ui/chats/ChatPane.kt` (628 lines) +- Desktop `DesktopUploadOrchestrator`: `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` (78 lines) +- Desktop `DesktopBlossomClient`: `desktopApp/.../service/upload/DesktopBlossomClient.kt` (74 lines) +- Desktop `DesktopBlossomAuth`: `desktopApp/.../service/upload/DesktopBlossomAuth.kt` (43 lines) +- Desktop `DesktopMediaMetadata`: `desktopApp/.../service/upload/DesktopMediaMetadata.kt` (89 lines) +- Desktop `EncryptedMediaService`: `desktopApp/.../service/media/EncryptedMediaService.kt` (57 lines) +- Desktop `DesktopIAccount`: `desktopApp/.../model/DesktopIAccount.kt` +- Desktop `ComposeNoteDialog` (drag-drop pattern): `desktopApp/.../ui/ComposeNoteDialog.kt` +- Commons `IAccount` interface: `commons/.../model/IAccount.kt:106-113` +- Quartz `ChatMessageEncryptedFileHeaderEvent.build()`: `quartz/.../nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt:80-110` +- Quartz `NIP17Factory.createEncryptedFileNIP17()`: `quartz/.../nip17Dm/NIP17Factory.kt:86-97` +- Quartz `AESGCM`: `quartz/.../utils/ciphers/AESGCM.kt` +- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md` + +### Gotchas (from learnings research + source verification) +- Hash encrypted blob, not plaintext, for Blossom auth `x` tag and `X-SHA-256` header +- New AESGCM cipher per file — never reuse key/nonce pair (nonce reuse breaks AES-GCM completely) +- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay +- NIP-04 does not support encrypted file headers — hide attach button in NIP-04 mode +- `DesktopBlossomClient.upload()` only accepts `File` — needs `ByteArray` overload for encrypted blobs +- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` — must be encrypted blob size +- Content-Type for encrypted upload must be `application/octet-stream`, not original MIME +- `AESGCM.decryptOrNull()` exists — prefer over `decrypt()` for UI code (no exception on wrong key) diff --git a/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md new file mode 100644 index 0000000000..bdb90980ef --- /dev/null +++ b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md @@ -0,0 +1,343 @@ +--- +title: "feat: Stacked messages layout in deck columns" +type: feat +status: completed +date: 2026-03-19 +deepened: 2026-03-19 +origin: docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md +--- + +# feat: Stacked Messages Layout in Deck Columns + +## Enhancement Summary + +**Deepened on:** 2026-03-19 +**Files to change:** 4 +**Approach:** Add `compactMode` flag, conditional layout in `DesktopMessagesScreen` + +### Key Implementation Details +1. `ConversationListPane` width is hardcoded at line 122 (`Modifier.width(280.dp)`) — remove it, let caller control width +2. `ChatPane` header (lines 211-231) uses `ChatroomHeader`/`GroupChatroomHeader` — wrap with `Row` adding back arrow +3. `DesktopMessagesScreen` already has `selectedRoom` state and `clearSelection()` — stacked nav is pure layout change +4. Keyboard Escape handling already at line 102 calls `listState.clearSelection()` — works as-is for back nav + +--- + +## Overview + +Replace the side-by-side split-pane Messages layout with stacked navigation in deck columns. Full-width contact list OR full-width chat — clicking a conversation navigates to chat, back arrow returns to list. Single-pane mode keeps the current split layout. + +## Problem Statement + +In multi-deck mode, columns are 350-400dp wide. The current layout allocates 280dp to `ConversationListPane` (line 122) and the remaining 70-120dp to `ChatPane` — unusable. + +(see brainstorm: `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`) + +--- + +## Step 1: Make ConversationListPane width flexible + +**File:** `desktopApp/.../ui/chats/ConversationListPane.kt` (line 119-123) + +**Current code:** +```kotlin +Column( + modifier = + modifier + .width(280.dp) // ← hardcoded, breaks compact mode + .fillMaxHeight() +``` + +**Change:** Remove the hardcoded width from the composable. The caller controls width via the `modifier` parameter. + +```kotlin +Column( + modifier = + modifier + .fillMaxHeight() +``` + +**Call sites:** +- Split mode (DesktopMessagesScreen): passes no modifier → add `Modifier.width(280.dp)` at call site +- Compact mode: passes `Modifier.fillMaxWidth()` → uses full column width + +### Research Insights + +- `ConversationListPane` already accepts `modifier: Modifier = Modifier` (line 95) — just unused for width +- The keyboard nav (`onPreviewKeyEvent` at line 126) and `LazyColumn` (line 243) work at any width +- `ConversationCard` (line 268) uses `Modifier.fillMaxWidth()` — adapts automatically + +--- + +## Step 2: Add `onBack` to ChatPane header + +**File:** `desktopApp/.../ui/chats/ChatPane.kt` (lines 136-144, 211-231) + +**Current signature:** +```kotlin +fun ChatPane( + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, + feedViewModel: ChatroomFeedViewModel, + messageState: ChatNewMessageState, + dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, + onNavigateToProfile: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) +``` + +**Add:** `onBack: (() -> Unit)? = null` parameter. + +**Current header (lines 211-231):** +```kotlin +// Header +if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) +} else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } +} +``` + +**New header:** Wrap in a `Row` with conditional back arrow: + +```kotlin +Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), +) { + if (onBack != null) { + IconButton( + onClick = onBack, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to conversations", + ) + } + } + + Box(modifier = Modifier.weight(1f)) { + // Existing header content (ChatroomHeader / GroupChatroomHeader / fallback) + } +} +``` + +### Research Insights + +- `Icons.AutoMirrored.Filled.ArrowBack` is already available in material-icons-extended +- `ChatroomHeader` and `GroupChatroomHeader` are shared composables from commons — don't modify them +- The `Row` wrapper doesn't affect the existing divider at line 233 (`HorizontalDivider()`) + +--- + +## Step 3: Refactor DesktopMessagesScreen layout + +**File:** `desktopApp/.../ui/chats/DesktopMessagesScreen.kt` + +**Current:** Single `Row` layout (lines 92-168) with `ConversationListPane` + `VerticalDivider` + `ChatPane`/`EmptyState`. + +**Change:** Add `compactMode: Boolean = false` parameter. Extract existing Row into a private `SplitMessagesContent` composable. Add a new `CompactMessagesContent` for stacked mode. + +```kotlin +@Composable +fun DesktopMessagesScreen( + account: IAccount, + cacheProvider: ICacheProvider, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + compactMode: Boolean = false, // NEW + onNavigateToProfile: (String) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val listState = remember(account) { + ChatroomListState(account, cacheProvider, relayManager, localCache, scope) + } + val selectedRoom by listState.selectedRoom.collectAsState() + val listFocusRequester = remember { FocusRequester() } + var showNewDmDialog by remember { mutableStateOf(false) } + + // Keyboard shortcuts (shared between modes) + val keyHandler = Modifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + when { + event.key == Key.Escape -> { + listState.clearSelection() + true + } + event.key == Key.N && isModifier && event.isShiftPressed -> { + showNewDmDialog = true + true + } + else -> false + } + } + + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + showNewDmDialog = showNewDmDialog, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + keyHandler = keyHandler, + onShowNewDm = { showNewDmDialog = true }, + ) + } + + // New DM dialog (shared) + if (showNewDmDialog) { /* existing NewDmDialog code */ } +} +``` + +**CompactMessagesContent:** +```kotlin +@Composable +private fun CompactMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + showNewDmDialog: Boolean, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Box(modifier = Modifier.fillMaxSize().then(keyHandler)) { + val currentRoom = selectedRoom + if (currentRoom != null) { + // Full-width chat with back arrow + val feedViewModel = remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + val broadcastStatus = if (account is DesktopIAccount) { + account.dmSendTracker.status.collectAsState().value + } else DmBroadcastStatus.Idle + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + dmBroadcastStatus = broadcastStatus, + onNavigateToProfile = onNavigateToProfile, + onBack = { listState.clearSelection() }, + ) + } else { + // Full-width contact list + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, + focusRequester = listFocusRequester, + modifier = Modifier.fillMaxSize(), + ) + } + } +} +``` + +**SplitMessagesContent:** Extract existing `Row` code verbatim from current `DesktopMessagesScreen`, adding `Modifier.width(280.dp)` to the `ConversationListPane` call. + +--- + +## Step 4: Wire compactMode from deck + +**File:** `desktopApp/.../ui/deck/DeckColumnContainer.kt` (line 206-214) + +```kotlin +DeckColumnType.Messages -> { + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + relayManager = relayManager, + localCache = localCache, + compactMode = true, // ← ADD THIS + onNavigateToProfile = onNavigateToProfile, + ) +} +``` + +`SinglePaneLayout` — no change needed, `compactMode` defaults to `false`. + +--- + +## Edge Cases + +| Scenario | Behavior | Verified by | +|----------|----------|-------------| +| Escape in chat | `clearSelection()` → back to list | Existing keyboard handler (line 102) | +| Escape in list | No-op (already no selection) | Same handler, `clearSelection()` on null is safe | +| New DM dialog in compact chat | Dialog opens over chat, selecting user switches to that chat | `showNewDmDialog` is shared state | +| Receiving DM while viewing list | Chatroom list updates via 2s polling | `ChatroomListState.refreshRooms()` | +| Receiving DM while in chat | Messages appear real-time via `ChatroomFeedViewModel` | No change needed | +| Back arrow + drag-drop | Drag-drop zone is on the ChatPane Column, unaffected by back arrow Row | Separate modifier chain | + +--- + +## Acceptance Criteria + +- [x] Deck Messages column shows full-width contact list (no split) +- [x] Clicking conversation navigates to full-width chat view +- [x] Back arrow visible in chat header (compact mode only) +- [x] Clicking back arrow returns to contact list +- [x] Escape key still returns to contact list +- [x] Single-pane mode unchanged (split layout preserved) +- [x] Keyboard navigation (up/down/enter) still works in contact list +- [x] New DM dialog still works in both modes +- [x] ConversationListPane uses full width in compact mode + +## Files Changed + +| File | Change | Lines affected | +|------|--------|---------------| +| `ConversationListPane.kt` | Remove hardcoded `width(280.dp)` | Line 122 | +| `ChatPane.kt` | Add `onBack` param, wrap header in Row with back arrow | Lines 136-144, 211-231 | +| `DesktopMessagesScreen.kt` | Add `compactMode`, extract `SplitMessagesContent`/`CompactMessagesContent` | Major refactor | +| `DeckColumnContainer.kt` | Pass `compactMode = true` | Line ~208 | + +## Sources + +- **Brainstorm:** `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md` +- `DesktopMessagesScreen.kt:75-213` — current split-pane layout +- `ChatPane.kt:136-144` — current signature; `211-231` — header section +- `ConversationListPane.kt:119-122` — hardcoded width; `95` — modifier param +- `DeckColumnContainer.kt:206-214` — Messages deck routing diff --git a/gradle.properties b/gradle.properties index 8d72c802b7..dd5ff4f5fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 998b19aa04..db9af4a9ea 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.3" +activityCompose = "1.13.0" agp = "9.1.0" android-compileSdk = "36" android-minSdk = "26" @@ -11,16 +11,16 @@ 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" +firebaseBom = "34.11.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.21.1" @@ -29,7 +29,7 @@ jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" kchesslib = "1.0.5" -kotlin = "2.3.10" +kotlin = "2.3.20" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.10.0" @@ -41,34 +41,38 @@ lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3" -media3 = "1.9.2" +media3 = "1.9.3" mockk = "1.14.9" kotlinx-coroutines-test = "1.10.2" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.7" okhttp = "5.3.2" runner = "1.7.0" -rfc3986 = "0.1.2" -secp256k1KmpJniAndroid = "0.22.0" +secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" -spotless = "8.3.0" +slf4j = "2.0.17" +spotless = "8.4.0" tarsosdsp = "2.5" -torAndroid = "0.4.9.5" +torAndroid = "0.4.9.5.1" translate = "17.0.3" -jetbrainsCompose = "1.10.2" +jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" -vico-charts = "2.4.3" +uriReferenceKmp = "1.0" +vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" +vlcj = "4.8.3" +commonsImaging = "1.0.0-alpha6" 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.2" kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" -spmForKmpVersion = "1.4.9" +sqlite = "2.6.2" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } @@ -87,6 +91,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } +androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidxExifinterface" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -103,7 +108,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" } @@ -121,6 +125,10 @@ coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif", version.ref = "coil" coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" } coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } +commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } +uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" } +vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } @@ -157,17 +165,14 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } -rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } 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" } 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" } @@ -176,6 +181,9 @@ androidx-window-core-android = { group = "androidx.window", name = "window-core- kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } +androidx-sqlite-bundled-jvm = { module = "androidx.sqlite:sqlite-bundled-jvm", version.ref = "sqlite" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -189,6 +197,4 @@ 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" } 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 a2dc608f34..1fed30907a 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -1,9 +1,5 @@ -@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 @@ -13,7 +9,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) alias(libs.plugins.vanniktech.mavenPublish) - alias(libs.plugins.frankois944.spmForKmp) } kotlin { @@ -26,7 +21,7 @@ kotlin { } } - androidLibrary { + android { namespace = "com.vitorpamplona.quartz" compileSdk = libs.versions.android.compileSdk @@ -65,31 +60,49 @@ 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) + outputs.files(libsodiumDeviceDefFile, libsodiumSimulatorDefFile) 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") - } + 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( @@ -99,49 +112,39 @@ kotlin { ) } - tasks.named(cinterops.getByName("Clibsodium").interopProcessingTaskName).configure { + tasks.named(cinterops.getByName("clibsodium").interopProcessingTaskName).configure { dependsOn(libsodiumDefFileGeneration) } } - - target.swiftPackageConfig(cinteropName = "swiftbridge") { - minIos = "17" - minMacos = "14" - dependency { - remotePackageVersion( - url = uri("https://github.com/swift-standards/swift-rfc-3986.git"), - packageName = "swift-rfc-3986", - products = { - add("RFC 3986") - }, - version = "0.1.0", - ) - } - } - } - - iosX64 { - binaries.framework { - baseName = xcfName - } } iosArm64 { + binaries.all { + linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium") + } binaries.framework { baseName = xcfName + isStatic = true + binaryOption("bundleId", "com.vitorpamplona.quartz") } } iosSimulatorArm64 { + binaries.all { + linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator") + } binaries.framework { baseName = xcfName + isStatic = true + binaryOption("bundleId", "com.vitorpamplona.quartz") } } // This makes sure that the resource file directory is visible for iOS tests. - val rootDir = "${rootProject.rootDir.path}/quartz/src/iosTest/resources" + val rootDir = "${rootProject.rootDir.path}/quartz/src/commonTest/resources" tasks.withType().configureEach { + maxHeapSize = "4g" environment("TEST_RESOURCES_ROOT", rootDir) } @@ -176,6 +179,13 @@ kotlin { // immutable collections to avoid recomposition implementation(libs.kotlinx.collections.immutable) + + // SQLite KMP driver for event store + api(libs.androidx.sqlite) + implementation(libs.androidx.sqlite.bundled) + + // RFC3986 library(normalizes URLs) + api(libs.uri.reference.kmp) } } @@ -183,6 +193,10 @@ kotlin { dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + + // SQLite bundled driver for tests + api(libs.androidx.sqlite) + implementation(libs.androidx.sqlite.bundled) } } @@ -192,9 +206,6 @@ kotlin { dependsOn(commonMain.get()) dependencies { - // Normalizes URLs - api(libs.rfc3986.normalizer) - // Performant Parser of JSONs into Events api(libs.jackson.module.kotlin) @@ -236,6 +247,9 @@ kotlin { dependencies { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) + + // SQLite bundled driver for JVM tests + implementation(libs.androidx.sqlite.bundled.jvm) } } @@ -255,8 +269,18 @@ kotlin { getByName("androidHostTest") { dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) + + // LibSodium for ChaCha encryption (NIP-44) - Needed for host tests + implementation(libs.lazysodium.java) + implementation(libs.jna) + + // SQLite bundled driver for Host tests + implementation(libs.androidx.sqlite.bundled.jvm) } } @@ -266,7 +290,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") } } @@ -281,10 +314,6 @@ kotlin { } } - val iosX64Main by getting { - dependsOn(iosMain.get()) - } - val iosArm64Main by getting { dependsOn(iosMain.get()) } @@ -299,10 +328,6 @@ kotlin { } } - val iosX64Test by getting { - dependsOn(iosTest.get()) - } - val iosArm64Test by getting { dependsOn(iosTest.get()) } @@ -325,7 +350,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.05.1", + version = "1.06.3", ) // Configure publishing to Maven Central diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 0000000000..d588554f74 --- /dev/null +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@RunWith(AndroidJUnit4::class) +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt index bb522a8b7c..314166c723 100644 --- a/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt +++ b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt @@ -20,7 +20,17 @@ */ package com.vitorpamplona.quartz +import java.util.zip.GZIPInputStream + actual class TestResourceLoader { + actual fun loadDecompressString(file: String): String = + this@TestResourceLoader + .javaClass.classLoader + ?.getResourceAsStream(file) + ?.let { GZIPInputStream(it) } + ?.bufferedReader() + ?.use { it.readText() } ?: throw IllegalArgumentException("Resource not found: $file") + actual fun loadString(file: String): String = this@TestResourceLoader .javaClass.classLoader diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt index f362e4199c..7bfa83a2ff 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt @@ -20,12 +20,38 @@ */ package com.vitorpamplona.quartz.utils +import com.goterl.lazysodium.LazySodium import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.Sodium import com.goterl.lazysodium.SodiumAndroid actual object LibSodiumInstance { - private val libSodium = SodiumAndroid() - private val lazySodium = LazySodiumAndroid(libSodium) + private val libSodium: Sodium = + try { + // If we are running in a host test, SodiumJava might be available. + // SodiumJava uses a ResourceLoader to find the dylib/so/dll in the jar. + Class + .forName("com.goterl.lazysodium.SodiumJava") + .getConstructor() + .newInstance() as Sodium + } catch (_: Exception) { + SodiumAndroid() + } + + private val lazySodium: LazySodium = + if (libSodium is SodiumAndroid) { + LazySodiumAndroid(libSodium) + } else { + // this should only happen on test cases + val sodiumJava = + Class + .forName("com.goterl.lazysodium.SodiumJava") + + Class + .forName("com.goterl.lazysodium.LazySodiumJava") + .getConstructor(sodiumJava) + .newInstance(libSodium) as LazySodium + } actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt( message: ByteArray, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt deleted file mode 100644 index 7d157db80e..0000000000 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.utils - -import androidx.core.net.toUri -import java.net.URLDecoder - -actual class UriParser actual constructor( - uri: String, -) { - val myUri = uri.toUri() - - val fragments: Map by lazy { - myUri.fragment?.ifBlank { null }?.let { keyValuePair -> - keyValuePair.split('&').associate { paramValue -> - val parts = paramValue.split("=", limit = 2) - if (parts.size == 2) { - parts[0] to URLDecoder.decode(parts[1], "UTF-8") - } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" - } - } - } ?: emptyMap() - } - - actual fun scheme(): String? = myUri.scheme - - actual fun host(): String? = myUri.host - - actual fun port(): Int? { - // android.net.Uri.getPort() returns -1 if the port is not set, so we handle that case. - val port = myUri.port - return if (port == -1) null else port - } - - actual fun path(): String? = myUri.path - - actual fun queryParameterNames() = myUri.queryParameterNames - - actual fun getQueryParameter(param: String) = myUri.getQueryParameter(param) - - actual fun fragments(): Map = fragments -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt new file mode 100644 index 0000000000..9e707b75b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.json.JsonNull.content + +@Immutable +class AttestationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) + + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun validity() = tags.validity() + + fun status() = tags.status() + + fun validFrom() = tags.validFrom() + + fun validTo() = tags.validTo() + + fun request() = tags.request() + + fun requestId() = tags.requestId() + + fun requestAddress() = tags.requestAddress() + + fun isRevoked() = status() == AttestationStatus.REVOKED + + fun assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse) + + fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + + fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) + + companion object { + const val KIND = 31871 + const val ALT_DESCRIPTION = "Attestation" + + fun buildEvent( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + about(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + + fun buildReplaceable( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutReplaceable(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + + fun buildAddress( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutAddressable(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..b64090b6fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation + +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag + +fun TagArrayBuilder.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity)) + +fun TagArrayBuilder.status(status: AttestationStatus) = addUnique(StatusTag.assemble(status)) + +fun TagArrayBuilder.validFrom(timestamp: Long) = addUnique(ValidFromTag.assemble(timestamp)) + +fun TagArrayBuilder.validTo(timestamp: Long) = addUnique(ValidToTag.assemble(timestamp)) + +fun TagArrayBuilder.request(request: EventHintBundle) = addUnique(RequestTag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutAddressable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutReplaceable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.about(request: EventHintBundle) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt new file mode 100644 index 0000000000..2b545818e2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation + +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.validity() = firstNotNullOfOrNull(ValidityTag::parse) + +fun TagArray.status() = firstNotNullOfOrNull(StatusTag::parse) + +fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse) + +fun TagArray.validTo() = firstNotNullOfOrNull(ValidToTag::parse) + +fun TagArray.request() = firstNotNullOfOrNull(RequestTag::parse) + +fun TagArray.requestId() = firstNotNullOfOrNull(RequestTag::parseAddressId) + +fun TagArray.requestAddress() = firstNotNullOfOrNull(RequestTag::parseAddress) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt new file mode 100644 index 0000000000..d22000d12a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class RequestTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + fun toTagArray() = assemble(address, relayHint) + + fun toTagIdOnly() = assemble(address, null) + + companion object { + const val TAG_NAME = "request" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR) + + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + fun isTagged( + tag: Array, + address: ApprovedAddressTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + fun parse(tag: Array): ApprovedAddressTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + + val address = Address.parse(tag[1]) ?: return null + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return ApprovedAddressTag(address, relayHint) + } + + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + return Address.parse(tag[1])?.toValue() + } + + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val address = Address.parse(tag[1]) ?: return null + ensure(address.kind != CommunityDefinitionEvent.KIND) { return null } + return address + } + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt new file mode 100644 index 0000000000..424c698db8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class AttestationStatus( + val code: String, +) { + ACCEPTED("accepted"), + REJECTED("rejected"), + VERIFYING("verifying"), + VERIFIED("verified"), + REVOKED("revoked"), +} + +class StatusTag { + companion object { + const val TAG_NAME = "s" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): AttestationStatus? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + AttestationStatus.ACCEPTED.code -> AttestationStatus.ACCEPTED + AttestationStatus.REJECTED.code -> AttestationStatus.REJECTED + AttestationStatus.VERIFYING.code -> AttestationStatus.VERIFYING + AttestationStatus.VERIFIED.code -> AttestationStatus.VERIFIED + AttestationStatus.REVOKED.code -> AttestationStatus.REVOKED + else -> null + } + } + + fun assemble(status: AttestationStatus) = arrayOf(TAG_NAME, status.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidFromTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidFromTag.kt new file mode 100644 index 0000000000..f33c6d7f83 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidFromTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ValidFromTag { + companion object { + const val TAG_NAME = "valid_from" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(time: Long) = arrayOf(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidToTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidToTag.kt new file mode 100644 index 0000000000..ae7832daf2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidToTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ValidToTag { + companion object { + const val TAG_NAME = "valid_to" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(time: Long) = arrayOf(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt new file mode 100644 index 0000000000..72d7a23c5c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.attestation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class Validity( + val code: String, +) { + VALID("valid"), + INVALID("invalid"), +} + +class ValidityTag { + companion object { + const val TAG_NAME = "v" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Validity? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + Validity.VALID.code -> Validity.VALID + Validity.INVALID.code -> Validity.INVALID + else -> null + } + } + + fun assemble(validity: Validity) = arrayOf(TAG_NAME, validity.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt new file mode 100644 index 0000000000..49bd5109fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.proficiency + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class AttestorProficiencyEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun kinds() = tags.kinds() + + fun description() = tags.description() + + companion object { + const val KIND = 11871 + const val ALT_DESCRIPTION = "Attestor Proficiency Declaration" + + fun build( + kinds: List, + description: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + kinds(kinds) + description?.let { desc(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..fc617bb515 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.proficiency + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) + +fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt new file mode 100644 index 0000000000..a6dfab4653 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.proficiency + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.kinds() = mapNotNull(KindTag::parse) + +fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt new file mode 100644 index 0000000000..704d856804 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class AttestorRecommendationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun kinds() = tags.kinds() + + fun description() = tags.description() + + companion object { + const val KIND = 31873 + const val ALT_DESCRIPTION = "Attestor Recommendation" + + fun build( + attestorPubKey: HexKey, + kinds: List, + description: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(attestorPubKey) + kinds(kinds) + description?.let { desc(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..23dfbc7e79 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) + +fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt new file mode 100644 index 0000000000..d784f3d9ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation + +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.kinds() = mapNotNull(KindTag::parse) + +fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt new file mode 100644 index 0000000000..62b5399206 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "desc" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/KindTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/KindTag.kt new file mode 100644 index 0000000000..aae521c71a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/KindTag.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.recommendation.tags + +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class KindTag { + companion object { + const val TAG_NAME = "k" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Kind? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(kind: Kind) = arrayOf(TAG_NAME, kind.toString()) + + fun assemble(kinds: List) = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt new file mode 100644 index 0000000000..f5c646a730 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.request + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.attestations.request.attestorPubKeys +import com.vitorpamplona.quartz.experimental.attestations.request.cashuToken +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.let + +@Immutable +class AttestationRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) + + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun cashuToken() = tags.cashuToken() + + fun assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse) + + fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + + fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) + + companion object { + const val KIND = 31872 + const val ALT_DESCRIPTION = "Attestation Request" + + fun buildEvent( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + about(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + + fun buildReplaceable( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutReplaceable(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + + fun buildAddress( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutAddressable(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..e356deadd2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.request + +import com.vitorpamplona.quartz.experimental.attestations.request.tags.CashuTokenTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag + +fun TagArrayBuilder.attestorPubKeys(pubKeys: List) = addAll(pubKeys.map { PTag.assemble(it, null) }) + +fun TagArrayBuilder.cashuToken(token: String) = addUnique(CashuTokenTag.assemble(token)) + +fun TagArrayBuilder.aboutAddressable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutReplaceable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.about(request: EventHintBundle) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey)) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayExt.kt similarity index 80% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayExt.kt index bdb01fd24c..53e3c2ed05 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayExt.kt @@ -18,8 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.utils +package com.vitorpamplona.quartz.experimental.attestations.request -import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import com.vitorpamplona.quartz.experimental.attestations.request.tags.CashuTokenTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray -actual fun fastFindURLs(text: String): List = UrlDetector(text).detect().map { it.originalUrl } +fun TagArray.cashuToken() = firstNotNullOfOrNull(CashuTokenTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/tags/CashuTokenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/tags/CashuTokenTag.kt new file mode 100644 index 0000000000..583fd23c92 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/tags/CashuTokenTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations.request.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CashuTokenTag { + companion object { + const val TAG_NAME = "cashu_token" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(token: String) = arrayOf(TAG_NAME, token) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt index 3b9fbdd86b..bec27039a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt @@ -27,19 +27,19 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.MinimumTag import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -fun TagArrayBuilder.consensusThreshold(percentage: Double) = addUnique(ConsensusThresholdTag.assemble(percentage)) +fun TagArrayBuilder.consensusThreshold(percentage: Double) = addUnique(ConsensusThresholdTag.assemble(percentage)) -fun TagArrayBuilder.minAmount(value: Long) = addUnique(MinimumTag.assemble(value)) +fun TagArrayBuilder.minAmount(value: Long) = addUnique(MinimumTag.assemble(value)) -fun TagArrayBuilder.maxAmount(value: Long) = addUnique(MaximumTag.assemble(value)) +fun TagArrayBuilder.maxAmount(value: Long) = addUnique(MaximumTag.assemble(value)) -fun TagArrayBuilder.closedAt(timestamp: Long) = addUnique(ClosedAtTag.assemble(timestamp)) +fun TagArrayBuilder.closedAt(timestamp: Long) = addUnique(ClosedAtTag.assemble(timestamp)) -fun TagArrayBuilder.pollOption( +fun TagArrayBuilder.pollOption( index: Int, description: String, ) = add(PollOptionTag.assemble(index, description)) -fun TagArrayBuilder.pollOptions(options: Map) = addAll(options.map { PollOptionTag.assemble(it.key, it.value) }) +fun TagArrayBuilder.pollOptions(options: Map) = addAll(options.map { PollOptionTag.assemble(it.key, it.value) }) -fun TagArrayBuilder.pollOptions(options: List) = addAll(options.map { it.toTagArray() }) +fun TagArrayBuilder.pollOptions(options: List) = addAll(options.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt index 19bf092b5e..b22d81aede 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class PollNoteEvent( +class ZapPollEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -131,9 +131,9 @@ class PollNoteEvent( post: String, options: List, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate { - val tags = TagArrayBuilder() + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val tags = TagArrayBuilder() tags.pollOptions(options) tags.alt(ALT_DESCRIPTION) tags.apply(initializer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt index ff099fae41..d5cdbee526 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.Serializable @Immutable +@Serializable(with = EventKSerializer::class) open class Event( val id: HexKey, val pubKey: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt index b801307a09..99fb60435a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt @@ -41,6 +41,8 @@ expect object OptimizedJsonMapper { fun fromJsonToEventTemplate(json: String): EventTemplate + fun fromJsonToEventList(json: String): List + fun fromJsonToRumor(json: String): Rumor fun toJson(tags: Array>): String diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index f388b1dcdb..9241a884d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -72,6 +72,15 @@ class TagArrayBuilder { return this } + fun addUniqueValueIfNew(tag: Array): TagArrayBuilder { + if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this + val list = tagList.getOrPut(tag[0], ::mutableListOf) + if (list.none { it.valueOrNull() == tag[1] }) { + list.add(tag) + } + return this + } + fun addAll(tag: List>): TagArrayBuilder { tag.forEach(::add) return this @@ -82,6 +91,16 @@ class TagArrayBuilder { return this } + fun addAllUnique(tag: Array>): TagArrayBuilder { + tag.forEach(::addUnique) + return this + } + + fun addAllUniqueValueIfNew(tag: List>): TagArrayBuilder { + tag.forEach(::addUniqueValueIfNew) + return this + } + fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() fun build() = toTypedArray() 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 index d5b79dbf47..93caff876e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -33,12 +33,12 @@ 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.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization.RumorKSerializer import kotlinx.serialization.json.Json @@ -64,6 +64,8 @@ class KotlinSerializationMapper { fun fromJsonToEventTemplate(jsonStr: String): EventTemplate = json.decodeFromString(EventTemplateKSerializer, jsonStr) + fun fromJsonToEventList(jsonStr: String): List = json.decodeFromString(jsonStr) + fun toJson(event: Event): String = json.encodeToString(EventKSerializer, event) fun toJson(tags: TagArray): String = json.encodeToString(TagArrayKSerializer, tags) @@ -107,6 +109,18 @@ class KotlinSerializationMapper { 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}") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index 668c1ff984..6b738aec60 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -interface INostrClient { +interface INostrClient : AutoCloseable { fun connectedRelaysFlow(): StateFlow> fun availableRelaysFlow(): StateFlow> @@ -87,7 +87,7 @@ interface INostrClient { fun activeOutboxCache(url: NormalizedRelayUrl): Set } -object EmptyNostrClient : INostrClient { +class EmptyNostrClient : INostrClient { override fun connectedRelaysFlow() = MutableStateFlow(emptySet()) override fun availableRelaysFlow() = MutableStateFlow(emptySet()) @@ -136,4 +136,6 @@ object EmptyNostrClient : INostrClient { override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + + override fun close() {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 8d40797e3a..a314a0dd85 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -77,11 +78,15 @@ import kotlinx.coroutines.launch */ class NostrClient( private val websocketBuilder: WebsocketBuilder, - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + private val parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ) : INostrClient, - IRelayClientListener { + IRelayClientListener, + AutoCloseable { private val relayPool: RelayPool = RelayPool(websocketBuilder, this) + /** Scope for all subscriptions. */ + private val scope = CoroutineScope(parentScope.coroutineContext + SupervisorJob()) + private val activeRequests: PoolRequests = PoolRequests() private val activeCounts: PoolCounts = PoolCounts() private val eventOutbox: PoolEventOutbox = PoolEventOutbox() @@ -326,4 +331,13 @@ class NostrClient( override fun connectedRelaysFlow() = relayPool.connectedRelays override fun availableRelaysFlow() = relayPool.availableRelays + + override fun close() { + disconnect() + listeners = emptySet() + activeCounts.destroy() + activeRequests.destroy() + eventOutbox.destroy() + scope.cancel() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index b1ea3d05d7..15dd206ff5 100644 --- 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 @@ -65,15 +65,18 @@ suspend fun INostrClient.queryCountSuspend( subscribe(listener) - queryCount(subId = subId, filters = mapOf(relay to listOf(filter))) - val result = - withTimeoutOrNull(timeoutMs) { - resultChannel.receive() + try { + queryCount(subId = subId, filters = mapOf(relay to listOf(filter))) + + withTimeoutOrNull(timeoutMs) { + resultChannel.receive() + } + } finally { + close(subId) + unsubscribe(listener) } - close(subId) - unsubscribe(listener) resultChannel.close() return result diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientReqBypassingRelayLimitsExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientReqBypassingRelayLimitsExt.kt new file mode 100644 index 0000000000..a4a20ab3f0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientReqBypassingRelayLimitsExt.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.coroutineContext +import kotlin.math.min + +/** + * Downloads all pages of events matching [filters] from a single [relay] using + * paginated `until` cursors. + * + * After EOSE the oldest [Event.createdAt] seen in that page minus one becomes the + * next `until`, and the query repeats until the relay returns no new events. + * + * Event counting is tracked per filter using [Filter.match]. A filter is considered + * fulfilled when the number of matching events reaches its [Filter.limit]. Pagination + * stops when all filters with limits are fulfilled or when a page returns no events. + * Filters without a limit are considered unbounded and only stop on empty pages. + * + * @param relay The relay to query. + * @param filters Filters to apply on every page (the `until` field is overwritten per page). + * @param timeoutMs Maximum time to wait for a single page's EOSE before giving up. + * @param onEvent Called for every event received (in page order, after each EOSE). + * @return Total number of events received across all pages. + */ +suspend fun INostrClient.reqBypassingRelayLimits( + relay: NormalizedRelayUrl, + filters: List, + timeoutMs: Long = 30_000L, + onNewPage: ((Long) -> Unit)? = null, + onEvent: (Event) -> Unit, +): Int { + var until: Long? = null + var totalEvents = 0 + + // Track how many matching events each filter has received so far. + val matchCountPerFilter = IntArray(filters.size) + + val subId = newSubId() + + while (true) { + coroutineContext.ensureActive() + + val pagedFilters = + if (until == null) { + filters + } else { + onNewPage?.invoke(until) + filters.map { + it.copy(until = until) + } + } + + // Only include filters that still need more events. + val remainingFilters = + pagedFilters.filterIndexed { index, filter -> + val limit = filter.limit + limit == null || matchCountPerFilter[index] < limit + } + + if (remainingFilters.isEmpty()) break + + val doneChannel = Channel(Channel.CONFLATED) + + var pageCount = 0 + var pageMinTs = Long.MAX_VALUE + + try { + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Check if the relay is returning what we asked before moving forward + var atLeastOne = false + for (i in pagedFilters.indices) { + val limit = pagedFilters[i].limit + if ((limit == null || matchCountPerFilter[i] < limit) && pagedFilters[i].match(event)) { + matchCountPerFilter[i]++ + atLeastOne = true + } + } + if (atLeastOne) { + onEvent(event) + pageCount++ + if (event.createdAt < pageMinTs) { + pageMinTs = event.createdAt + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(Unit) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(Unit) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + doneChannel.trySend(Unit) + } + } + + openReqSubscription(subId, mapOf(relay to remainingFilters), listener) + + withTimeoutOrNull(timeoutMs) { + doneChannel.receive() + } + + close(subId) + doneChannel.close() + } finally { + close(subId) + doneChannel.close() + } + + if (pageCount == 0) break + + totalEvents += pageCount + + // Advance cursor: next page starts just before the oldest event seen. + until = min((until ?: Long.MAX_VALUE) - 1, pageMinTs - 1) + } + + return totalEvents +} + +suspend fun INostrClient.reqBypassingRelayLimits( + relay: String, + filters: List, + timeoutMs: Long = 30_000L, + onNewPage: ((Long) -> Unit)? = null, + onEvent: (Event) -> Unit, +): Int = + reqBypassingRelayLimits( + relay = RelayUrlNormalizer.normalize(relay), + filters = filters, + timeoutMs = timeoutMs, + onNewPage = onNewPage, + onEvent = onEvent, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt index 4af4749eae..9815f8d429 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -98,38 +98,41 @@ suspend fun INostrClient.sendAndWaitForResponseDetailed( } } - subscribe(subscription) + val receivedResults = + try { + subscribe(subscription) - // subscribe before sending the result. - val resultSubscription = - coroutineScope { - val result = - async { - val receivedResults = mutableMapOf() - // The withTimeout block will cancel the coroutine if the loop takes too long - withTimeoutOrNull(timeoutInSeconds * 1000) { - while (receivedResults.size < relayList.size) { - val result = resultChannel.receive() + // subscribe before sending the result. + val resultSubscription = + coroutineScope { + val result = + async { + val receivedResults = mutableMapOf() + // The withTimeout block will cancel the coroutine if the loop takes too long + withTimeoutOrNull(timeoutInSeconds * 1000) { + while (receivedResults.size < relayList.size) { + val result = resultChannel.receive() - val currentResult = receivedResults[result.relay] - // do not override a successful result. - if (currentResult == null || !currentResult) { - receivedResults[result.relay] = result.success + val currentResult = receivedResults[result.relay] + // do not override a successful result. + if (currentResult == null || !currentResult) { + receivedResults[result.relay] = result.success + } + } } + receivedResults } - } - receivedResults + + send(event, relayList) + + result } - send(event, relayList) - - result + resultSubscription.await() + } finally { + unsubscribe(subscription) } - val receivedResults = resultSubscription.await() - - unsubscribe(subscription) - // Clean up the channel resultChannel.close() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt index 1d894cd206..68ee0f621f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt @@ -67,7 +67,7 @@ suspend fun INostrClient.downloadFirstEvent( subscriptionId: String = newSubId(), filters: Map>, ): Event? { - val resultChannel = Channel(UNLIMITED) + val resultChannel = Channel(UNLIMITED) val listener = object : IRequestListener { @@ -79,16 +79,41 @@ suspend fun INostrClient.downloadFirstEvent( ) { resultChannel.trySend(event) } - } - openReqSubscription(subscriptionId, filters, listener) + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + resultChannel.trySend(null) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + resultChannel.trySend(null) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + resultChannel.trySend(null) + } + } val result = - withTimeoutOrNull(30000) { - resultChannel.receive() - } + try { + openReqSubscription(subscriptionId, filters, listener) - close(subscriptionId) + withTimeoutOrNull(30000) { + resultChannel.receive() + } + } finally { + close(subscriptionId) + } resultChannel.close() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt index a1f929def5..abda66eb19 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt @@ -36,7 +36,7 @@ class RelayOfflineTracker( const val TAG = "RelayOfflineTracker" } - val cannotConnectRelays = mutableSetOf() + var cannotConnectRelays = setOf() private val clientListener = object : IRelayClientListener { @@ -45,14 +45,14 @@ class RelayOfflineTracker( pingMillis: Int, compressed: Boolean, ) { - cannotConnectRelays.remove(relay.url) + cannotConnectRelays -= relay.url } override fun onCannotConnect( relay: IRelayClient, errorMessage: String, ) { - cannotConnectRelays.add(relay.url) + cannotConnectRelays += relay.url } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt index c64c0a607f..75b8ff0a49 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt @@ -213,4 +213,10 @@ class PoolCounts { ) { // mark as impossible to get count from this relay } + + fun destroy() { + relayState.clear() + relays.tryEmit(emptySet()) + queries.clear() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index 23ed960272..49b0e2a168 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -28,26 +28,63 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlin.compareTo class PoolEventOutbox { private var eventOutbox = mapOf() val relays = MutableStateFlow(setOf()) - fun updateRelays() { - val myRelays = mutableSetOf() - eventOutbox.values.forEach { - myRelays.addAll(it.relaysLeft()) + fun needsToUpdateRelays(): Boolean { + val currentRelays = relays.value + + var relaysToRemoveCounter = 0 + + currentRelays.forEach { currentRelay -> + if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) { + relaysToRemoveCounter++ + } } - if (relays.value != myRelays) { - relays.tryEmit(myRelays) + var relaysToAddCounter = 0 + eventOutbox.values.forEach { outboxState -> + if (outboxState.relaysRemaining.any { it !in currentRelays }) { + relaysToAddCounter++ + } + } + + return relaysToRemoveCounter > 0 || relaysToAddCounter > 0 + } + + fun updateRelays() { + if (needsToUpdateRelays()) { + relays.update { currentRelays -> + val relaysToRemove = mutableSetOf() + + currentRelays.forEach { currentRelay -> + if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) { + relaysToRemove.add(currentRelay) + } + } + + val relaysToAdd = mutableSetOf() + eventOutbox.values.forEach { outboxState -> + outboxState.relaysRemaining.forEach { relay -> + if (relay !in relaysToAdd && relay !in currentRelays) { + relaysToAdd.add(relay) + } + } + } + + (currentRelays - relaysToRemove) + relaysToAdd + } } } fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set { val myEvents = mutableSetOf() eventOutbox.forEach { (eventId, outboxCache) -> - if (url in outboxCache.relays) { + if (url in outboxCache.relaysRemaining) { myEvents.add(eventId) } } @@ -72,7 +109,12 @@ class PoolEventOutbox { id: HexKey, url: NormalizedRelayUrl, ) { - eventOutbox[id]?.newTry(url) + val waiting = eventOutbox[id] + waiting?.newTry(url) + if (waiting?.isDone() == true) { + eventOutbox = eventOutbox - waiting.event.id + updateRelays() + } } fun newResponse( @@ -84,15 +126,13 @@ class PoolEventOutbox { val waiting = eventOutbox[id] if (waiting != null) { waiting.newResponse(url, success, message) - clear() + if (waiting.isDone()) { + eventOutbox = eventOutbox - waiting.event.id + updateRelays() + } } } - fun clear() { - eventOutbox = eventOutbox.filter { !it.value.isDone() } - updateRelays() - } - // -------------------------- // State management functions // -------------------------- @@ -143,9 +183,14 @@ class PoolEventOutbox { errorMessage: String, ) { eventOutbox.forEach { - if (relay in it.value.relays) { + if (relay in it.value.relaysRemaining) { newResponse(it.key, relay, false, errorMessage) } } } + + fun destroy() { + eventOutbox = emptyMap() + relays.tryEmit(emptySet()) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index bb2407501f..f75f3aa6ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -26,35 +26,37 @@ import com.vitorpamplona.quartz.utils.TimeUtils class PoolEventOutboxState( val event: Event, - var relays: Set, + var relaysRemaining: Set, ) { - private var tries = mapOf() + private var failures = mapOf() fun updateRelays(newRelays: Set) { - relays = newRelays + relaysRemaining = newRelays } - fun isDone(url: NormalizedRelayUrl) = tries[url]?.isDone() ?: false + fun isDone() = relaysRemaining.isEmpty() - fun isDone() = relays.all { isDone(it) } + fun relaysLeft(): Set = relaysRemaining - fun relaysLeft(): Set = relays.filterTo(mutableSetOf()) { !isDone(it) } - - fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url) + fun isSupposedToGo(url: NormalizedRelayUrl) = url in relaysRemaining fun forEachUnsentEvent( url: NormalizedRelayUrl, run: (url: Event) -> Unit, ) = if (isSupposedToGo(url)) run(event) else null - fun remainingRelays() = relays.filterTo(mutableSetOf(), ::isSupposedToGo) + fun remainingRelays() = relaysRemaining fun newTry(url: NormalizedRelayUrl) { - val currentTries = tries[url] + val currentTries = failures[url] if (currentTries != null) { currentTries.addTriedTime(TimeUtils.now()) + if (currentTries.isDone()) { + relaysRemaining = relaysRemaining - url + failures = failures - url + } } else { - tries = tries + (url to Tries(listOf(TimeUtils.now()))) + failures = failures + (url to Tries(listOf(TimeUtils.now()))) } } @@ -63,38 +65,44 @@ class PoolEventOutboxState( success: Boolean, message: String, ) { - val currentTries = tries[url] - if (currentTries != null) { - currentTries.addResponse(Response(success, message)) + val currentTries = failures[url] + if (success || message.shouldDiscard()) { + relaysRemaining = relaysRemaining - url + failures = failures - url } else { - tries = tries + ( - url to - Tries( - listOf(TimeUtils.now() - 1), - listOf(Response(success, message)), - ) - ) + if (currentTries != null) { + currentTries.addResponse(message) + } else { + failures = failures + ( + url to + Tries( + listOf(TimeUtils.now() - 1), + listOf(message), + ) + ) + } } } + fun String.shouldDiscard() = + this.startsWith("replaced:") || + this.startsWith("pow:") || + this.startsWith("deleted:") || + this.startsWith("invalid:") + // Tries 3 times class Tries( var tries: List = listOf(), - var responses: List = listOf(), + var responses: List = listOf(), ) { - fun isDone() = responses.any { it.success } || responses.size > 2 || tries.size > 3 + fun isDone() = responses.size > 2 || tries.size > 3 - fun addResponse(r: Response) { - responses += r + fun addResponse(msg: String) { + responses += msg } fun addTriedTime(tried: Long) { tries += tried } } - - class Response( - val success: Boolean, - val message: String, - ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index c0e41864e0..0ea0c49d76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -265,11 +265,15 @@ class PoolRequests { errorMessage: String, ) { relayState.forEach { subId, state -> - desiredSubListeners.get(subId)?.onCannotConnect( - message = errorMessage, - relay = url, - forFilters = state.lastKnownFilterStates(url), - ) + // These are all my subs.. need to figure out which relays have them + val subs = desiredSubs.get(subId) + if (subs != null && url in subs.keys) { + desiredSubListeners.get(subId)?.onCannotConnect( + relay = url, + message = errorMessage, + forFilters = state.lastKnownFilterStates(url), + ) + } } } @@ -329,4 +333,11 @@ class PoolRequests { // They are the same don't do anything. } } + + fun destroy() { + relayState.clear() + desiredSubs.clear() + desiredSubListeners.clear() + desiredRelays.tryEmit(emptySet()) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index fdd0c60ef7..835499361f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -42,6 +42,8 @@ class RelayStats( override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat() } + fun snapshot(): Map = innerCache.snapshot() + fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen") private val clientListener = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt index 410753b063..49ce72567f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.MessageKSerializer +import kotlinx.serialization.Serializable +@Serializable(with = MessageKSerializer::class) interface Message : OptimizedSerializable { fun label(): String } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt index b7cb4c7a63..0a46a5dda8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.CommandKSerializer +import kotlinx.serialization.Serializable +@Serializable(with = CommandKSerializer::class) interface Command : OptimizedSerializable { fun label(): String diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt new file mode 100644 index 0000000000..9a65df954c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Defines custom behavior for this relay. + */ +interface IRelayPolicy { + fun onConnect(send: (Message) -> Unit) + + /** + * Evaluates whether an incoming EVENT command should be accepted. + * + * @param cmd The event the client wants to publish. + * @return [PolicyResult.Accepted] to store the event, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: EventCmd): PolicyResult + + /** + * Evaluates a REQ command, optionally rewriting the filter list. + * + * @param cmd The filters from the REQ command. + * @return [PolicyResult.Accepted] with an optional replacement filter list, or [PolicyResult.Rejected]. + */ + fun accept(cmd: ReqCmd): PolicyResult + + /** + * Evaluates a COUNT command, optionally rewriting the filter list. + * + * @param cmd The filters from the COUNT command. + * @return [PolicyResult.Accepted] to allow counting, or [PolicyResult.Rejected] with a reason. + */ + fun accept(cmd: CountCmd): PolicyResult + + /** + * Evaluates whether an incoming AUTH command should be accepted. + * + * @param cmd The event the client wants to auth. + * @return [PolicyResult.Accepted] to log in, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: AuthCmd): PolicyResult + + /** + * Filters a live event before it is forwarded to a subscriber. + * + * Called for each event that matches a subscription's filters. Return + * true to deliver the event, false to suppress it for this session. + * + * @param event The event about to be sent. + */ + fun canSendToSession(event: Event): Boolean = true + + operator fun plus(other: IRelayPolicy) = listOf(this, other) +} + +sealed interface PolicyResult { + class Accepted( + val cmd: T, + ) : PolicyResult + + class Rejected( + val reason: String, + ) : PolicyResult +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt new file mode 100644 index 0000000000..4268dcef39 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * A reactive event store that combines historical data retrieval with live event streaming. + * + * This class wraps an [IEventStore] to provide real-time updates. When a [query] is executed, + * it first replays all matching historical events from the underlying store, signals the + * End of Stored Events (EOSE), and then continues to stream matching new events as they + * are inserted. + * + * @property store The underlying persistent storage for events. + */ +class LiveEventStore( + private val store: IEventStore, +) { + private val newEventStream = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, // Optional: adjust for backpressure + onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior + ) + + fun insert(event: Event) { + store.insert(event) + newEventStream.tryEmit(event) + } + + suspend fun query( + filters: List, + onEach: (Event) -> Unit, + onEose: () -> Unit, + ) { + // 1. Replay stored events matching filters. + store.query(filters, onEach) + + // 2. Signal end of stored events. + onEose() + + // 3. Stream live events until cancelled. + newEventStream.collect { newEvent -> + if (filters.any { it.match(newEvent) }) { + onEach(newEvent) + } + } + } + + fun count(filters: List) = store.count(filters) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt new file mode 100644 index 0000000000..78651df5ef --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlin.coroutines.CoroutineContext + +/** + * Represents a Nostr relay server that manages client connections, event storage, and verification. + * + * This class acts as the central coordinator for a relay server, handling the lifecycle of [RelaySession]s + * and providing access to the underlying event store. + * + * @param store The [IEventStore] backing this relay. + * @param policyBuilder Controls requirements for relay commands. + */ +class NostrServer( + private val store: IEventStore, + private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy }, + private val parentContext: CoroutineContext = SupervisorJob(), +) { + private val subStore = LiveEventStore(store) + + /** Scope for all subscriptions. */ + private val scope = CoroutineScope(parentContext + SupervisorJob()) + + /** Active client sessions keyed by an opaque connection id. */ + private val connections = LargeCache() + + /** + * Registers a new client connection. + * + * @param send Callback the server uses to send JSON messages to this client. + * Implementations must be safe to call from any coroutine. + */ + fun connect(send: (String) -> Unit) = + RelaySession( + policy = policyBuilder(), + store = subStore, + scope = scope, + onSend = send, + onClose = { session -> + connections.remove(session.hashCode()) + }, + ).also { session -> + connections.put(session.hashCode(), session) + } + + /** + * Shuts down the server, cancelling all subscriptions and sessions. + */ + fun shutdown() { + connections.forEach { _, session -> session.cancelAllSubscriptions() } + connections.clear() + scope.cancel() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt new file mode 100644 index 0000000000..2a384d8a68 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Represents an active session between a Nostr client and the relay. + * Each one of these is a connection that can hold many subscriptions + */ +class RelaySession( + private val store: LiveEventStore, + val policy: IRelayPolicy, + private val scope: CoroutineScope, + private val onSend: (String) -> Unit, + private val onClose: (RelaySession) -> Unit, +) : AutoCloseable { + private val subscriptions = LargeCache() + + private fun addSubscription( + subId: String, + job: Job, + ) = subscriptions.put(subId, job) + + private fun cancelSubscription(subId: String): Boolean = + subscriptions.remove(subId)?.let { + it.cancel() + true + } ?: false + + fun cancelAllSubscriptions() { + subscriptions.forEach { _, job -> job.cancel() } + subscriptions.clear() + } + + fun send(message: Message) { + try { + onSend(OptimizedJsonMapper.toJson(message)) + } catch (e: Exception) { + Log.w("ClientSession", "Failed to send to ${e.message}") + } + } + + override fun close() { + cancelAllSubscriptions() + onClose(this) + } + + /** + * Processes a raw JSON message from a client. + * + * Parses the message as a NIP-01 command and dispatches it. + */ + suspend fun receive(command: String) { + val cmd = + try { + OptimizedJsonMapper.fromJsonToCommand(command) + } catch (_: Exception) { + send(NoticeMessage("error: could not parse message")) + return + } + + if (!cmd.isValid()) { + send(NoticeMessage("error: invalid command")) + return + } + + when (cmd) { + is AuthCmd -> handleAuth(cmd) + is EventCmd -> handleEvent(cmd) + is ReqCmd -> handleReq(cmd) + is CloseCmd -> handleClose(cmd) + is CountCmd -> handleCount(cmd) + else -> send(NoticeMessage("error: unsupported command ${cmd.label()}")) + } + } + + // -- NIP-42: AUTH --------------------------------------------------------- + private fun handleAuth(cmd: AuthCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + send(OkMessage(cmd.event.id, true, "")) + } + + // -- NIP-01: REQ ---------------------------------------------------------- + private fun handleReq(cmd: ReqCmd) { + // Cancel any existing subscription with the same id (NIP-01 spec). + cancelSubscription(cmd.subId) + + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.subId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val job = + scope.launch { + try { + store.query( + filters = filters, + onEach = { event -> + if (policy.canSendToSession(event)) { + send(EventMessage(cmd.subId, event)) + } + }, + onEose = { send(EoseMessage(cmd.subId)) }, + ) + } catch (_: kotlinx.coroutines.CancellationException) { + // Subscription was closed – this is expected. + } + } + + addSubscription(cmd.subId, job) + } + + // -- NIP-01: CLOSE -------------------------------------------------------- + private fun handleClose(cmd: CloseCmd) { + val cancelled = cancelSubscription(cmd.subId) + if (!cancelled) { + send(ClosedMessage(cmd.subId, "error: no such subscription")) + } + } + + // -- NIP-01: EVENT -------------------------------------------------------- + private fun handleEvent(cmd: EventCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + try { + store.insert(cmd.event) + send(OkMessage(cmd.event.id, true, "")) + } catch (e: Exception) { + send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) + } + } + + // -- NIP-45: COUNT -------------------------------------------------------- + private fun handleCount(cmd: CountCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.queryId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val total = store.count(filters) + + send(CountMessage(cmd.queryId, CountResult(total))) + } + + init { + policy.onConnect(::send) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt index 2027b5106e..fe7429a43e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt @@ -18,31 +18,30 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal +package com.vitorpamplona.quartz.nip01Core.relay.server.policies -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult -class UserProfileReportsFeedFilter( - val user: User, -) : AdditiveFeedFilter() { - override fun feedKey(): String = user.pubkeyHex +/** + * Allows all commands without authentication. This is the default policy. + */ +object EmptyPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) { } - override fun feed(): List = sort(innerApplyFilter(user.reportsOrNull()?.all() ?: emptyList())) + override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) - override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) - private fun innerApplyFilter(collection: Collection): Set = - collection - .filterTo(mutableSetOf()) { - it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true - } + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd) - override fun limit() = 400 + override fun canSendToSession(event: Event) = true } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt new file mode 100644 index 0000000000..525bfa144c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Requires authentication for all EVENT, REQ, and COUNT commands. + * Replicates the previous `requireAuth = true` behavior. + */ +open class FullAuthPolicy( + val relay: NormalizedRelayUrl, +) : IRelayPolicy { + /** The challenge string sent to this client for NIP-42 authentication. */ + val challenge: String = RandomInstance.randomChars(32) + + /** Set of pubkeys that have successfully authenticated on this session. */ + val authenticatedUsers = mutableSetOf() + + /** Returns true if at least one pubkey has authenticated. */ + fun isAuthenticated(): Boolean = authenticatedUsers.isNotEmpty() + + override fun onConnect(send: (Message) -> Unit) { + send(AuthMessage(challenge)) + } + + override fun accept(cmd: AuthCmd): PolicyResult { + val event = cmd.event + + if (event.isExpired()) { + return PolicyResult.Rejected("invalid: auth event expired") + } + + if (!TimeUtils.withinTenMinutes(event.createdAt)) { + return PolicyResult.Rejected("invalid: created_at is too far from the current time") + } + + if (event.challenge() != challenge) { + return PolicyResult.Rejected("invalid: challenge does not match") + } + + if (event.relay() != relay) { + return PolicyResult.Rejected("invalid: relay url does not match") + } + + authenticatedUsers.add(event.pubKey) + + return PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: EventCmd): PolicyResult = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: ReqCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: CountCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt new file mode 100644 index 0000000000..67b3b0f1ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +class PolicyStack( + vararg policies: IRelayPolicy, +) : IRelayPolicy { + val policies = policies.toList() + + override fun onConnect(send: (Message) -> Unit) { + policies.forEach { it.onConnect(send) } + } + + override fun accept(cmd: EventCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: ReqCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: CountCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: AuthCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + private inline fun runPolicies( + initialCmd: T, + operation: (IRelayPolicy, T) -> PolicyResult, + ): PolicyResult { + var currentCmd = initialCmd + for (policy in policies) { + val result = operation(policy, currentCmd) + if (result is PolicyResult.Rejected) return result + currentCmd = (result as PolicyResult.Accepted).cmd + } + return PolicyResult.Accepted(currentCmd) + } + + override fun canSendToSession(event: Event): Boolean = policies.all { it.canSendToSession(event) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt new file mode 100644 index 0000000000..283fda6707 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Allows all commands without authentication. This is the default policy. + */ +object VerifyPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt index 1eebac72f1..b387cbd567 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.signers +import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable @@ -27,8 +28,12 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventTemplateKSerializer import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.Serializable +@Immutable +@Serializable(with = EventTemplateKSerializer::class) class EventTemplate( val createdAt: Long, val kind: Int, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt similarity index 95% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 5f38c460f7..1d3d74efdb 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter interface IEventStore { - fun insert(event: Event): Boolean + fun insert(event: Event) interface ITransaction { - fun insert(event: Event): Boolean + fun insert(event: Event) } fun transaction(body: ITransaction.() -> Unit) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt similarity index 92% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt index f0c6e9f71c..4181a34d30 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection class AddressableModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE UNIQUE INDEX addressable_idx @@ -57,7 +57,7 @@ class AddressableModule : IModule { ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt similarity index 94% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index f04844b652..1b50598c1a 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent class DeletionRequestModule( - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { fun rejectDeletedEventsSQLTemplate(): String = @@ -58,7 +58,7 @@ class DeletionRequestModule( * deleted by ID or ATag including GiftWraps that * must be checked against the p-tag (pubkey_owner_hash) */ - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ") db.execSQL( """ @@ -75,13 +75,13 @@ class DeletionRequestModule( ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} fun insert( event: Event, - db: SQLiteDatabase, + db: SQLiteConnection, ) { if (event is DeletionEvent) { val idValues = event.deleteEventIds() @@ -103,7 +103,7 @@ class DeletionRequestModule( pubkey: HexKey, idValues: List, addresses: List
, - hasher: TagNameValueHasher, + hasher: com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher, ): List { val owner = hasher.hash(pubkey) val idParams = idValues.joinToString(",") { "?" } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt similarity index 89% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt index 06e2c59ec7..748975b39d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection class EphemeralModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { // Rejects all ephemeral events. db.execSQL( """ @@ -38,7 +38,7 @@ class EphemeralModule : IModule { ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt similarity index 76% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index c40992ed79..75ef7ce718 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -28,10 +28,10 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent class EventIndexesModule( - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE TABLE event_headers ( @@ -128,7 +128,7 @@ class EventIndexesModule( ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS event_tags") db.execSQL("DROP TABLE IF EXISTS event_headers") } @@ -151,10 +151,9 @@ class EventIndexesModule( fun insert( event: Event, - db: SQLiteDatabase, + db: SQLiteConnection, ): Long { val hasher = hasher(db) - val stmt = db.compileStatement(sqlInsertHeader) val kindLong = event.kind.toLong() val pubkeyHash = hasher.hash(event.pubKey) @@ -168,52 +167,56 @@ class EventIndexesModule( val eTagHash = hasher.hashETag(event.id) - stmt.bindString(1, event.id) - stmt.bindString(2, event.pubKey) - stmt.bindLong(3, event.createdAt) - stmt.bindLong(4, kindLong) - stmt.bindString(5, OptimizedJsonMapper.toJson(event.tags)) - stmt.bindString(6, event.content) - stmt.bindString(7, event.sig) - if (event is AddressableEvent) { - val dTag = event.dTag() - stmt.bindString(8, dTag) - stmt.bindLong(9, eventOwnerHash) - stmt.bindLong(10, eTagHash) - stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) - } else { - stmt.bindNull(8) - stmt.bindLong(9, eventOwnerHash) - stmt.bindLong(10, eTagHash) - stmt.bindNull(11) - } - - val headerId = stmt.executeInsert() - - val stmtTags = db.compileStatement(sqlInsertTags) - - // sorting helps SQLLite by avoiding - // rebalancing the tree every new insert - val indexableTags = ArrayList() - for (idx in event.tags.indices) { - if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) { - indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) + db.prepare(sqlInsertHeader).use { stmt -> + stmt.bindText(1, event.id) + stmt.bindText(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.bindLong(4, kindLong) + stmt.bindText(5, OptimizedJsonMapper.toJson(event.tags)) + stmt.bindText(6, event.content) + stmt.bindText(7, event.sig) + if (event is AddressableEvent) { + val dTag = event.dTag() + stmt.bindText(8, dTag) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) + } else { + stmt.bindNull(8) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindNull(11) } + stmt.step() } - indexableTags.sort() - indexableTags.forEach { - stmtTags.bindLong(1, headerId) - stmtTags.bindLong(2, it) - stmtTags.bindLong(3, event.createdAt) - stmtTags.bindLong(4, kindLong) - stmtTags.bindLong(5, pubkeyHash) - stmtTags.executeInsert() + + val headerId = db.lastInsertRowId() + + db.prepare(sqlInsertTags).use { stmtTags -> + // sorting helps SQLLite by avoiding + // rebalancing the tree every new insert + val indexableTags = ArrayList() + for (idx in event.tags.indices) { + if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) { + indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) + } + } + indexableTags.sort() + indexableTags.forEach { + stmtTags.bindLong(1, headerId) + stmtTags.bindLong(2, it) + stmtTags.bindLong(3, event.createdAt) + stmtTags.bindLong(4, kindLong) + stmtTags.bindLong(5, pubkeyHash) + stmtTags.step() + stmtTags.reset() + } } return headerId } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_tags") db.execSQL("DELETE FROM event_headers") } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt similarity index 90% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 4bff2b9d4e..3f85e755f9 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -20,18 +20,17 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore class EventStore( - context: Context, dbName: String? = "events.db", - val relayUrl: String? = "wss://quartz.local", + relayUrl: String? = "wss://quartz.local", val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IEventStore { - val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy) + val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relayUrl, indexStrategy) override fun insert(event: Event) = store.insertEvent(event) @@ -61,5 +60,5 @@ class EventStore( override fun deleteExpiredEvents() = store.deleteExpiredEvents() - override fun close() = store.close() + override fun close() = store.connection.close() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt similarity index 84% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt index 5359be543f..99baf1cace 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip40Expiration.expiration class ExpirationModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE TABLE event_expirations ( @@ -51,7 +51,7 @@ class ExpirationModule : IModule { ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS event_expirations") } @@ -64,18 +64,19 @@ class ExpirationModule : IModule { fun insert( event: Event, headerId: Long, - db: SQLiteDatabase, + db: SQLiteConnection, ) { val exp = event.expiration() if (exp != null && exp > 0) { - val stmt = db.compileStatement(insertExpiration) - stmt.bindLong(1, headerId) - stmt.bindLong(2, exp) - stmt.executeInsert() + db.prepare(insertExpiration).use { stmt -> + stmt.bindLong(1, headerId) + stmt.bindLong(2, exp) + stmt.step() + } } } - val deleteExpiredEvents = + val deleteExpiredEventsSQL = """ DELETE FROM event_headers WHERE row_id IN ( @@ -84,11 +85,11 @@ class ExpirationModule : IModule { ); """.trimIndent() - fun deleteExpiredEvents(db: SQLiteDatabase) { - db.compileStatement(deleteExpiredEvents).execute() + fun deleteExpiredEvents(db: SQLiteConnection) { + db.prepare(deleteExpiredEventsSQL).use { it.step() } } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_expirations") } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt similarity index 85% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt index a0b32a150b..a7884a224b 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip50Search.SearchableEvent @@ -30,7 +30,7 @@ class FullTextSearchModule : IModule { val eventHeaderRowIdName = "event_header_row_id" val contentName = "content" - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { val ftsVersion = FullTextSearchModule().versionFinder(db) db.execSQL( """ @@ -53,7 +53,7 @@ class FullTextSearchModule : IModule { ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS $tableName") } @@ -66,17 +66,18 @@ class FullTextSearchModule : IModule { fun insert( event: Event, headerId: Long, - db: SQLiteDatabase, + db: SQLiteConnection, ) { if (event is SearchableEvent) { - val stmt = db.compileStatement(insertFTS) - stmt.bindLong(1, headerId) - stmt.bindString(2, event.indexableContent()) - stmt.executeInsert() + db.prepare(insertFTS).use { stmt -> + stmt.bindLong(1, headerId) + stmt.bindText(2, event.indexableContent()) + stmt.step() + } } } - fun versionFinder(db: SQLiteDatabase): Int = + fun versionFinder(db: SQLiteConnection): Int = try { try { db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)") @@ -90,7 +91,7 @@ class FullTextSearchModule : IModule { 3 } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_fts") } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt similarity index 88% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt index 0f239f8538..90a1051d91 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IModule.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection interface IModule { - fun create(db: SQLiteDatabase) + fun create(db: SQLiteConnection) - fun drop(db: SQLiteDatabase) + fun drop(db: SQLiteConnection) - fun deleteAll(db: SQLiteDatabase) + fun deleteAll(db: SQLiteConnection) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt similarity index 100% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt similarity index 80% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index c876c7b962..86113f6176 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -20,20 +20,21 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.explainQuery import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where import com.vitorpamplona.quartz.utils.EventFactory class QueryBuilder( val fts: FullTextSearchModule, - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, val indexStrategy: IndexingStrategy, ) { // ------------ @@ -41,23 +42,23 @@ class QueryBuilder( // ------------ fun query( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runQuery(toSql(filter, hasher(db))) fun query( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (T) -> Unit, ) = db.runQuery(toSql(filter, hasher(db)), onEach) fun query( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runQuery(toSql(filters, hasher(db))) fun query( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (T) -> Unit, ) = db.runQuery(toSql(filters, hasher(db)), onEach) @@ -66,23 +67,23 @@ class QueryBuilder( // --------------------------- fun rawQuery( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runRawQuery(toSql(filter, hasher(db))) fun rawQuery( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (RawEvent) -> Unit, ) = db.runRawQuery(toSql(filter, hasher(db)), onEach) fun rawQuery( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): List = db.runRawQuery(toSql(filters, hasher(db))) fun rawQuery( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, onEach: (RawEvent) -> Unit, ) = db.runRawQuery(toSql(filters, hasher(db)), onEach) @@ -92,7 +93,7 @@ class QueryBuilder( fun planQuery( filter: Filter, hasher: TagNameValueHasher, - db: SQLiteDatabase, + db: SQLiteConnection, ): String { val query = toSql(filter, hasher) return db.explainQuery(query.sql, query.args.toTypedArray()) @@ -101,7 +102,7 @@ class QueryBuilder( fun planQuery( filters: List, hasher: TagNameValueHasher, - db: SQLiteDatabase, + db: SQLiteConnection, ): String { val query = toSql(filters, hasher) return db.explainQuery(query.sql, query.args.toTypedArray()) @@ -184,62 +185,74 @@ class QueryBuilder( ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""} """.trimIndent() - private fun SQLiteDatabase.runQuery(query: QuerySpec): List = - rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - ArrayList(cursor.count).apply { - while (cursor.moveToNext()) { - add(cursor.toEvent()) - } + private fun SQLiteConnection.runQuery(query: QuerySpec): List = + prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) } + val results = ArrayList() + while (stmt.step()) { + results.add(stmt.toEvent()) + } + results } - private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List = - rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - ArrayList(cursor.count).apply { - while (cursor.moveToNext()) { - add(cursor.toRawEvent()) - } + private fun SQLiteConnection.runRawQuery(query: QuerySpec): List = + prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) } + val results = ArrayList() + while (stmt.step()) { + results.add(stmt.toRawEvent()) + } + results } - private inline fun SQLiteDatabase.runQuery( + private inline fun SQLiteConnection.runQuery( query: QuerySpec, onEach: (T) -> Unit, - ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - while (cursor.moveToNext()) { - onEach(cursor.toEvent()) + ) = prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + while (stmt.step()) { + onEach(stmt.toEvent()) } } - private inline fun SQLiteDatabase.runRawQuery( + private inline fun SQLiteConnection.runRawQuery( query: QuerySpec, onEach: (RawEvent) -> Unit, - ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> - while (cursor.moveToNext()) { - onEach(cursor.toRawEvent()) + ) = prepare(query.sql).use { stmt -> + query.args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + while (stmt.step()) { + onEach(stmt.toRawEvent()) } } - private fun Cursor.toEvent() = + private fun SQLiteStatement.toEvent() = EventFactory.create( - getString(0).intern(), - getString(1).intern(), + getText(0), + getText(1), getLong(2), getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(getString(4)), - getString(5), - getString(6), + OptimizedJsonMapper.fromJsonToTagArray(getText(4)), + getText(5), + getText(6), ) - private fun Cursor.toRawEvent() = + private fun SQLiteStatement.toRawEvent() = RawEvent( - getString(0), - getString(1), + getText(0), + getText(1), getLong(2), getInt(3), - getString(4), - getString(5), - getString(6), + getText(4), + getText(5), + getText(6), ) // -------------- @@ -247,7 +260,7 @@ class QueryBuilder( // ------------- fun count( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val newFilter = filter.toFilterWithDTags() @@ -277,27 +290,30 @@ class QueryBuilder( fun count( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args) } - private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") + private fun SQLiteConnection.countEverything() = runCount("SELECT count(*) as count FROM event_headers") - private fun SQLiteDatabase.countIn( + private fun SQLiteConnection.countIn( rowIdQuery: String, args: List, ) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args) - private fun SQLiteDatabase.runCount( + private fun SQLiteConnection.runCount( sql: String, args: List = emptyList(), ): Int = - rawQuery(sql, args.toTypedArray()).use { cursor -> - cursor.moveToNext() - cursor.getInt(0) + prepare(sql).use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + stmt.step() + stmt.getInt(0) } // -------------- @@ -305,7 +321,7 @@ class QueryBuilder( // ------------- fun delete( filter: Filter, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) @@ -318,17 +334,25 @@ class QueryBuilder( fun delete( filters: List, - db: SQLiteDatabase, + db: SQLiteConnection, ): Int { val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) } - private fun SQLiteDatabase.runDelete( + private fun SQLiteConnection.runDelete( sql: String, args: List = emptyList(), - ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + ): Int { + prepare("DELETE FROM event_headers WHERE row_id IN ($sql)").use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + stmt.step() + } + return changes() + } // --------------------------------- // Prepare unions of all the filters @@ -448,7 +472,9 @@ class QueryBuilder( where { // the order should match indexes // ids reduce the filter the most - filter.ids?.let { equalsOrIn("event_headers.id", it) } + filter.ids?.let { + equalsOrIn("event_headers.id", it) + } // it's quite rare to have 2 tags in the filter, but possible nonDTagsIn.keys.forEachIndexed { index, tagName -> @@ -476,18 +502,25 @@ class QueryBuilder( } else { "event_tagsAll${index}_$valueIndex.tag_hash" } - equals(column, hasher.hash(tagName, tagValue)) } } // range search is bad but most of the time these are up the top with few elements. if (reverseLookup) { - filter.kinds?.let { equalsOrIn("event_tags.kind", it) } - filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) } + filter.kinds?.let { + equalsOrIn("event_tags.kind", it) + } + filter.authors?.let { + equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) + } - filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) } - filter.until?.let { lessThanOrEquals("event_tags.created_at", it) } + filter.since?.let { + greaterThanOrEquals("event_tags.created_at", it) + } + filter.until?.let { + lessThanOrEquals("event_tags.created_at", it) + } // there are indexes for these, starting with tags. filter.tags?.forEach { (tagName, tagValues) -> @@ -496,8 +529,12 @@ class QueryBuilder( } } } else { - filter.kinds?.let { equalsOrIn("event_headers.kind", it) } - filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + filter.kinds?.let { + equalsOrIn("event_headers.kind", it) + } + filter.authors?.let { + equalsOrIn("event_headers.pubkey", it) + } // there are indexes for these, starting with tags. filter.tags?.forEach { (tagName, tagValues) -> @@ -506,8 +543,12 @@ class QueryBuilder( } } - filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } - filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + filter.since?.let { + greaterThanOrEquals("event_headers.created_at", it) + } + filter.until?.let { + lessThanOrEquals("event_headers.created_at", it) + } // no need to add the replaceable because query_by_kind_pubkey_created already covers it val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false @@ -561,18 +602,30 @@ class QueryBuilder( where { // the order should match indexes // ids reduce the filter the most - ids?.let { equalsOrIn("event_headers.id", it) } + ids?.let { + equalsOrIn("event_headers.id", it) + } match(fts.tableName, search) - kinds?.let { equalsOrIn("event_headers.kind", it) } - authors?.let { equalsOrIn("event_headers.pubkey", it) } + kinds?.let { + equalsOrIn("event_headers.kind", it) + } + authors?.let { + equalsOrIn("event_headers.pubkey", it) + } // there are indexes for these, starting with tags. - dTags?.let { equalsOrIn("event_headers.d_tag", it) } + dTags?.let { + equalsOrIn("event_headers.d_tag", it) + } - since?.let { greaterThanOrEquals("event_headers.created_at", it) } - until?.let { lessThanOrEquals("event_headers.created_at", it) } + since?.let { + greaterThanOrEquals("event_headers.created_at", it) + } + until?.let { + lessThanOrEquals("event_headers.created_at", it) + } // if this is a dTag filter, it is likely that all kinds are addressables // and so force the use of the addressable index @@ -618,16 +671,28 @@ class QueryBuilder( where { // the order should match indexes // ids reduce the filter the most - ids?.let { equalsOrIn("id", it) } + ids?.let { + equalsOrIn("id", it) + } - kinds?.let { equalsOrIn("kind", it) } - authors?.let { equalsOrIn("pubkey", it) } + kinds?.let { + equalsOrIn("kind", it) + } + authors?.let { + equalsOrIn("pubkey", it) + } // there are indexes for these, starting with tags. - dTags?.let { equalsOrIn("d_tag", it) } + dTags?.let { + equalsOrIn("d_tag", it) + } - since?.let { greaterThanOrEquals("created_at", it) } - until?.let { lessThanOrEquals("created_at", it) } + since?.let { + greaterThanOrEquals("created_at", it) + } + until?.let { + lessThanOrEquals("created_at", it) + } // if this is a dTag filter, it is likely that all kinds are addressables // and so force the use of the addressable index @@ -678,7 +743,8 @@ class QueryBuilder( val search: String? = null, ) { fun isSimpleSearch() = - search != null && search.isNotEmpty() && + search != null && + search.isNotEmpty() && (nonDTagsIn == null || nonDTagsIn.isEmpty()) && (nonDTagsAll == null || nonDTagsAll.isEmpty()) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt similarity index 85% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt index 8d2d307ee9..4b2d6bf278 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt @@ -20,25 +20,29 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection fun SQLiteEventStore.explainQuery( sql: String, args: Array = emptyArray(), -) = readableDatabase.explainQuery(sql, args.map { it.toString() }.toTypedArray()) +) = connection.explainQuery(sql, args.map { it.toString() }.toTypedArray()) -fun SQLiteDatabase.explainQuery( +fun SQLiteConnection.explainQuery( sql: String, args: Array = emptyArray(), ): String = - rawQuery("EXPLAIN QUERY PLAN $sql", args).use { cursor -> + prepare("EXPLAIN QUERY PLAN $sql").use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + val treeIndex = mutableMapOf() val rootNodes = mutableListOf() - while (cursor.moveToNext()) { - val id = cursor.getInt(0) - val parentId = cursor.getInt(1) - val detail = cursor.getString(3) + while (stmt.step()) { + val id = stmt.getInt(0) + val parentId = stmt.getInt(1) + val detail = stmt.getText(3) val line = PlanNode(detail) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md similarity index 98% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/README.md rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md index b82888b111..deee5cee37 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md @@ -87,7 +87,7 @@ It is initialized with a `SQLiteDatabase` instance, and it manages the underlyin To initialize the `EventStore` in your Application class: ```kotlin -val eventStore = EventStore(context, "dbname.db", relayUrlIdentifier) +val eventStore = EventStore("dbname.db", relayUrlIdentifier) ``` ### Querying Events diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt similarity index 92% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt index 558e174c95..4ed253b13e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection class ReplaceableModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE UNIQUE INDEX replaceable_idx @@ -54,7 +54,7 @@ class ReplaceableModule : IModule { ) } - override fun drop(db: SQLiteDatabase) {} + override fun drop(db: SQLiteConnection) {} - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt similarity index 88% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt index aafb548e7b..c85022bd74 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -20,14 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent class RightToVanishModule( - val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val hasher: (db: SQLiteConnection) -> TagNameValueHasher, ) : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL( """ CREATE TABLE event_vanish ( @@ -88,7 +88,7 @@ class RightToVanishModule( ) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS event_vanish") } @@ -102,18 +102,19 @@ class RightToVanishModule( event: Event, relayUrl: String?, headerId: Long, - db: SQLiteDatabase, + db: SQLiteConnection, ) { if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { - val stmt = db.compileStatement(insertRTV) - stmt.bindLong(1, headerId) - stmt.bindLong(2, hasher(db).hash(event.pubKey)) - stmt.bindLong(3, event.createdAt) - stmt.executeInsert() + db.prepare(insertRTV).use { stmt -> + stmt.bindLong(1, headerId) + stmt.bindLong(2, hasher(db).hash(event.pubKey)) + stmt.bindLong(3, event.createdAt) + stmt.step() + } } } - override fun deleteAll(db: SQLiteDatabase) { + override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_vanish") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionExt.kt new file mode 100644 index 0000000000..0d5a8464cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionExt.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +inline fun SQLiteStatement.use(block: (SQLiteStatement) -> T): T { + try { + return block(this) + } finally { + close() + } +} + +fun SQLiteConnection.execSQL(sql: String) { + prepare(sql).use { it.step() } +} + +fun SQLiteConnection.execSQL( + sql: String, + args: Array, +) { + prepare(sql).use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindAny(index + 1, arg) + } + stmt.step() + } +} + +fun SQLiteConnection.lastInsertRowId(): Long = + prepare("SELECT last_insert_rowid()").use { stmt -> + stmt.step() + stmt.getLong(0) + } + +fun SQLiteConnection.deleteRows( + table: String, + whereClause: String, + args: Array, +): Int { + execSQL("DELETE FROM $table WHERE $whereClause", args) + return changes() +} + +fun SQLiteConnection.changes(): Int = + prepare("SELECT changes()").use { stmt -> + stmt.step() + stmt.getInt(0) + } + +inline fun SQLiteConnection.transaction(body: SQLiteConnection.() -> T): T { + execSQL("BEGIN IMMEDIATE TRANSACTION") + try { + val result = body() + execSQL("END TRANSACTION") + return result + } catch (e: Throwable) { + execSQL("ROLLBACK TRANSACTION") + throw e + } +} + +fun SQLiteStatement.bindAny( + index: Int, + value: Any, +) { + when (value) { + is String -> bindText(index, value) + is Long -> bindLong(index, value) + is Int -> bindInt(index, value) + is Double -> bindDouble(index, value) + is ByteArray -> bindBlob(index, value) + else -> bindText(index, value.toString()) + } +} + +inline fun SQLiteConnection.rawQuery( + sql: String, + args: List, + block: (SQLiteStatement) -> T, +): T = + prepare(sql).use { stmt -> + args.forEachIndexed { index, arg -> + stmt.bindText(index + 1, arg) + } + block(stmt) + } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt similarity index 67% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 48c4d52469..604c671c12 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -20,11 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import android.database.sqlite.SQLiteConstraintException -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper -import androidx.core.database.sqlite.transaction +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteException +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind @@ -35,22 +34,31 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.EventFactory import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext class SQLiteEventStore( - val context: Context, + val driver: SQLiteDriver = BundledSQLiteDriver(), val dbName: String? = "events.db", val relayUrl: String? = null, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), -) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { +) { companion object { const val DATABASE_VERSION = 2 } + val connection: SQLiteConnection by lazy { + openAndConfigure() + } + val seedModule = SeedModule() val fullTextSearchModule = FullTextSearchModule() - val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy) + val eventIndexModule = + EventIndexesModule( + seedModule::hasher, + indexStrategy, + ) val replaceableModule = ReplaceableModule() val addressableModule = AddressableModule() @@ -60,7 +68,12 @@ class SQLiteEventStore( val expirationModule = ExpirationModule() val rightToVanishModule = RightToVanishModule(seedModule::hasher) - val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy) + val queryBuilder = + QueryBuilder( + fullTextSearchModule, + seedModule::hasher, + indexStrategy, + ) val modules = listOf( @@ -75,39 +88,56 @@ class SQLiteEventStore( fullTextSearchModule, ) - override fun onConfigure(db: SQLiteDatabase) { - super.onConfigure(db) + private fun openAndConfigure(): SQLiteConnection { + val db = driver.open(dbName ?: ":memory:") // 32MB memory cache db.execSQL("PRAGMA cache_size=-32000;") // makes sure the FKs are sane - db.setForeignKeyConstraintsEnabled(true) + db.execSQL("PRAGMA foreign_keys = ON;") // SQLite implements mutations by appending them to a log, which it occasionally // compacts into the database. This is called Write-Ahead Logging (WAL) - db.enableWriteAheadLogging() + db.execSQL("PRAGMA journal_mode = WAL;") // The DB can be corrupted if the OS is shutdown before sync, which generally // doesn't happen on Android db.execSQL("PRAGMA synchronous = OFF;") + + val currentVersion = getUserVersion(db) + if (currentVersion == 0) { + onCreate(db) + setUserVersion(db, DATABASE_VERSION) + } else if (currentVersion < DATABASE_VERSION) { + onUpgrade(db, currentVersion, DATABASE_VERSION) + setUserVersion(db, DATABASE_VERSION) + } + + return db } - fun dbSizeMB(): Int { - val f1 = context.getDatabasePath(dbName) - val f2 = context.getDatabasePath("$dbName-wal") - val total = f1.length() + f2.length() - return (total / (1024 * 1024)).toInt() + private fun getUserVersion(db: SQLiteConnection): Int = + db.prepare("PRAGMA user_version").use { stmt -> + stmt.step() + stmt.getInt(0) + } + + private fun setUserVersion( + db: SQLiteConnection, + version: Int, + ) { + db.execSQL("PRAGMA user_version = $version") } - override fun onCreate(db: SQLiteDatabase) { + fun onCreate(db: SQLiteConnection) { modules.forEach { it.create(db) } } - override fun onUpgrade( - db: SQLiteDatabase, + fun onUpgrade( + db: SQLiteConnection, oldVersion: Int, newVersion: Int, ) { @@ -125,15 +155,14 @@ class SQLiteEventStore( } fun clearDB() { - val db = writableDatabase - modules.reversed().forEach { it.deleteAll(db) } + modules.reversed().forEach { it.deleteAll(connection) } } suspend fun vacuum() { // 1. ANALYZE: Collects statistics about tables and indices // to help the query planner optimize queries. withContext(Dispatchers.IO) { - writableDatabase.execSQL("VACUUM") + connection.execSQL("VACUUM") } } @@ -141,13 +170,13 @@ class SQLiteEventStore( // 2. VACUUM: Rebuilds the database file, reclaiming unused space // and reducing fragmentation. withContext(Dispatchers.IO) { - writableDatabase.execSQL("ANALYZE") + connection.execSQL("ANALYZE") } } private fun innerInsertEvent( event: Event, - db: SQLiteDatabase, + db: SQLiteConnection, ) { val headerId = eventIndexModule.insert(event, db) deletionModule.insert(event, db) @@ -156,83 +185,84 @@ class SQLiteEventStore( rightToVanishModule.insert(event, relayUrl, headerId, db) } - fun insertEvent(event: Event): Boolean { - if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event") - if (event.kind.isEphemeral()) return false + fun insertEvent(event: Event) { + if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.kind.isEphemeral()) return - writableDatabase.transaction { + connection.transaction { innerInsertEvent(event, this) } - return true } inner class Transaction( - val db: SQLiteDatabase, + val db: SQLiteConnection, ) : IEventStore.ITransaction { - override fun insert(event: Event): Boolean { - if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event") - if (event.kind.isEphemeral()) return false + override fun insert(event: Event) { + if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.kind.isEphemeral()) return innerInsertEvent(event, db) - return true } } fun transaction(body: Transaction.() -> Unit) { - writableDatabase.transaction { + connection.transaction { with(Transaction(this)) { body() } } } - fun query(filter: Filter): List = queryBuilder.query(filter, readableDatabase) + fun query(filter: Filter): List = queryBuilder.query(filter, connection) - fun query(filters: List): List = queryBuilder.query(filters, readableDatabase) + fun query(filters: List): List = queryBuilder.query(filters, connection) fun query( filter: Filter, onEach: (T) -> Unit, - ) = queryBuilder.query(filter, readableDatabase, onEach) + ) = queryBuilder.query(filter, connection, onEach) fun query( filters: List, onEach: (T) -> Unit, - ) = queryBuilder.query(filters, readableDatabase, onEach) + ) = queryBuilder.query(filters, connection, onEach) - fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, readableDatabase) + fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, connection) - fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, readableDatabase) + fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, connection) fun rawQuery( filter: Filter, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filter, readableDatabase, onEach) + ) = queryBuilder.rawQuery(filter, connection, onEach) fun rawQuery( filters: List, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filters, readableDatabase, onEach) + ) = queryBuilder.rawQuery(filters, connection, onEach) - fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase) + fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(connection), connection) - fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase) + fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(connection), connection) - fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase) + fun count(filter: Filter): Int = queryBuilder.count(filter, connection) - fun count(filters: List): Int = queryBuilder.count(filters, readableDatabase) + fun count(filters: List): Int = queryBuilder.count(filters, connection) fun delete(filter: Filter) { - queryBuilder.delete(filter, writableDatabase) + queryBuilder.delete(filter, connection) } fun delete(filters: List) { - queryBuilder.delete(filters, writableDatabase) + queryBuilder.delete(filters, connection) } - fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id)) + fun delete(id: HexKey): Int { + connection.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) + return connection.changes() + } - fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) + fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(connection) } class RawEvent( @@ -246,8 +276,8 @@ class RawEvent( ) { fun toEvent() = EventFactory.create( - id.intern(), - pubKey.intern(), + id, + pubKey, createdAt, kind, OptimizedJsonMapper.fromJsonToTagArray(jsonTags), diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt similarity index 80% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt index 63ef636302..a997497a2c 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt @@ -20,18 +20,19 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.utils.RandomInstance class SeedModule : IModule { - override fun create(db: SQLiteDatabase) { + override fun create(db: SQLiteConnection) { db.execSQL("CREATE TABLE seeds (seed_value INTEGER)") val insertSeed = "INSERT INTO seeds (seed_value) VALUES (?)" - val stmt = db.compileStatement(insertSeed) - stmt.bindLong(1, RandomInstance.long()) - stmt.executeInsert() + db.prepare(insertSeed).use { stmt -> + stmt.bindLong(1, RandomInstance.long()) + stmt.step() + } // Prevent updates to maintain immutability db.execSQL( @@ -65,19 +66,19 @@ class SeedModule : IModule { ) } - fun getSeed(db: SQLiteDatabase): Long = - db.rawQuery("SELECT seed_value FROM seeds LIMIT 1", null).use { - it.moveToFirst() + fun getSeed(db: SQLiteConnection): Long = + db.prepare("SELECT seed_value FROM seeds LIMIT 1").use { + it.step() it.getLong(0) } - override fun drop(db: SQLiteDatabase) { + override fun drop(db: SQLiteConnection) { db.execSQL("DROP TABLE IF EXISTS seeds") } - override fun deleteAll(db: SQLiteDatabase) {} + override fun deleteAll(db: SQLiteConnection) {} private var hasherCache: TagNameValueHasher? = null - fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it } + fun hasher(db: SQLiteConnection): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt similarity index 100% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt similarity index 86% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt index 956d73417d..557c0a6df8 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -20,71 +20,71 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql -sealed class Condition { +sealed interface Condition { data class Raw( val condition: String, - ) : Condition() + ) : Condition data class Equals( val column: String, val value: Any?, - ) : Condition() + ) : Condition data class NotEquals( val column: String, val value: Any?, - ) : Condition() + ) : Condition data class GreaterThan( val column: String, val value: Any, - ) : Condition() + ) : Condition data class GreaterThanOrEquals( val column: String, val value: Any, - ) : Condition() + ) : Condition data class LessThan( val column: String, val value: Any, - ) : Condition() + ) : Condition data class LessThanOrEquals( val column: String, val value: Any, - ) : Condition() + ) : Condition data class Like( val column: String, val value: String, - ) : Condition() + ) : Condition data class Match( val table: String, val value: String, - ) : Condition() + ) : Condition data class IsNull( val column: String, - ) : Condition() + ) : Condition data class IsNotNull( val column: String, - ) : Condition() + ) : Condition data class In( val column: String, val values: List, - ) : Condition() + ) : Condition data class And( val conditions: List, - ) : Condition() + ) : Condition data class Or( val conditions: List, - ) : Condition() + ) : Condition - class Empty : Condition() + class Empty : Condition } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt similarity index 98% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt index 2c8544fe5f..be9bf59c04 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -28,7 +28,10 @@ class SqlSelectionBuilder( fun build(): WhereClause { selectionArgs.clear() // Clear previous args for a fresh build val conditions = buildCondition(condition) - return WhereClause(conditions, selectionArgs) + return WhereClause( + conditions, + selectionArgs, + ) } /** diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt similarity index 72% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt index d4b4db55bb..c3e8f14f8f 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -23,56 +23,83 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql class WhereClauseBuilder { private val conditions = mutableListOf() - fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) } + fun raw(condition: String) = + apply { + conditions.add(Condition.Raw(condition)) + } fun equals( column: String, value: Any?, - ) = apply { conditions.add(Condition.Equals(column, value)) } + ) = apply { + conditions.add(Condition.Equals(column, value)) + } fun notEquals( column: String, value: Any?, - ) = apply { conditions.add(Condition.NotEquals(column, value)) } + ) = apply { + conditions.add(Condition.NotEquals(column, value)) + } fun greaterThan( column: String, value: Any, - ) = apply { conditions.add(Condition.GreaterThan(column, value)) } + ) = apply { + conditions.add(Condition.GreaterThan(column, value)) + } fun greaterThanOrEquals( column: String, value: Any, - ) = apply { conditions.add(Condition.GreaterThanOrEquals(column, value)) } + ) = apply { + conditions.add(Condition.GreaterThanOrEquals(column, value)) + } fun lessThan( column: String, value: Any, - ) = apply { conditions.add(Condition.LessThan(column, value)) } + ) = apply { + conditions.add(Condition.LessThan(column, value)) + } fun lessThanOrEquals( column: String, value: Any, - ) = apply { conditions.add(Condition.LessThanOrEquals(column, value)) } + ) = apply { + conditions.add(Condition.LessThanOrEquals(column, value)) + } fun like( column: String, pattern: String, - ) = apply { conditions.add(Condition.Like(column, pattern)) } + ) = apply { + conditions.add(Condition.Like(column, pattern)) + } fun match( table: String, pattern: String, - ) = apply { conditions.add(Condition.Match(table, pattern)) } + ) = apply { + conditions.add(Condition.Match(table, pattern)) + } - fun isNull(column: String) = apply { conditions.add(Condition.IsNull(column)) } + fun isNull(column: String) = + apply { + conditions.add(Condition.IsNull(column)) + } - fun isNotNull(column: String) = apply { conditions.add(Condition.IsNotNull(column)) } + fun isNotNull(column: String) = + apply { + conditions.add(Condition.IsNotNull(column)) + } fun isIn( column: String, values: List, - ) = apply { conditions.add(Condition.In(column, values)) } + ) = apply { + conditions.add(Condition.In(column, values)) + } fun equalsOrIn( column: String, @@ -119,7 +146,11 @@ class WhereClauseBuilder { } fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { - val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty() + val condition = + WhereClauseBuilder() + .apply(block) + .buildAnd() ?: Condition + .Empty() return SqlSelectionBuilder(condition).build() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt index 2446661ed5..91d38f0aed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt @@ -52,6 +52,12 @@ class ReferenceTag { fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url)) - fun assemble(urls: List): List> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) } + fun assemble(urls: List): List> = + urls + .mapTo(HashSet()) { + HttpUrlFormatter.normalize(it) + }.map { + arrayOf(TAG_NAME, it) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index 72abf7546a..4d4a44aa82 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -301,7 +301,8 @@ class NamecoinNameResolver( pubkey = rootMatch.content } - firstEntry != null && firstEntry.value is JsonPrimitive && + firstEntry != null && + firstEntry.value is JsonPrimitive && isValidPubkey((firstEntry.value as JsonPrimitive).content) -> { resolvedLocalPart = firstEntry.key pubkey = (firstEntry.value as JsonPrimitive).content diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt index ad56ebffa6..c4b864ec5f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt @@ -20,15 +20,25 @@ */ package com.vitorpamplona.quartz.nip10Notes.content -import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter -import com.vitorpamplona.quartz.utils.fastFindURLs +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.startsWithAny +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -fun findURLs(text: String) = fastFindURLs(text) +val rejectSchemes = + listOf( + DualCase("ftp:"), + DualCase("ftps:"), + DualCase("ws:"), + DualCase("wss:"), + DualCase("nostr:"), + DualCase("blossom:"), + ) -fun buildUrlRefs(urls: List): List> = - urls - .mapTo(HashSet()) { url -> - HttpUrlFormatter.normalize(url) - }.map { - arrayOf("r", it) +fun findURLs(text: String) = + UrlDetector(text).detect().mapNotNull { + if (it.originalUrl.startsWithAny(rejectSchemes)) { + null + } else { + it.originalUrl } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 7a5bf1d0f8..5fc0de6e7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -31,18 +31,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -fun TagArrayBuilder.quote(tag: QTag) = add(tag.toTagArray()) +fun TagArrayBuilder.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray()) -fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it.toTagArray() }) +fun TagArrayBuilder.quotes(tag: List) = addAllUniqueValueIfNew(tag.map { it.toTagArray() }) fun TagArrayBuilder.quote(entity: Entity) = when (entity) { - is NNote -> add(entity.toQuoteTagArray()) - is NEvent -> add(entity.toQuoteTagArray()) - is NAddress -> add(entity.toQuoteTagArray()) - is NEmbed -> add(entity.toQuoteTagArray()) - is NPub -> add(entity.toQuoteTagArray()) - is NProfile -> add(entity.toQuoteTagArray()) + is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray()) else -> this } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt index 05cb6e21f0..4ee11947c6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -27,6 +27,26 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord /** * High-level NIP-47 Wallet Connect client. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt index 947bbecf82..739351ca16 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -23,6 +23,29 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse /** * High-level NIP-47 Wallet Connect server (wallet service). 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 53eb83d7f6..73a106017e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -32,10 +32,22 @@ import kotlinx.serialization.Serializable class Nip47WalletConnect { companion object { + fun fix(uri: String): String { + var newUri = uri + + // POCO Phones seem to remove the + sign from the scheme + if (uri.startsWith("nostr walletconnect")) { + newUri = uri.replaceFirst("nostr walletconnect", "nostr+walletconnect") + } else if (uri.startsWith("amethyst walletconnect")) { + newUri = uri.replaceFirst("amethyst walletconnect", "amethyst+walletconnect") + } + + return newUri + } + fun parse(uri: String): Nip47URINorm { // nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=...&lud16=user@example.com - - val url = UriParser(uri) + val url = UriParser(fix(uri)) if (url.scheme() != "nostrwalletconnect" && url.scheme() != "nostr+walletconnect" && url.scheme() != "amethyst+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt index a1befc2da8..59a160871f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.cache import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request class NostrWalletConnectRequestCache( signer: NostrSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt index b883d3a737..c291c2c988 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.cache import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response class NostrWalletConnectResponseCache( signer: NostrSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt index 452c99b2f4..d98476399c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt index a75ba45e80..ebabe68a79 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt index 42ca7529a1..3691d3dc10 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcNotificationEvent.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcNotificationEvent.kt index d5884bbb19..825b4c8942 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcNotificationEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt new file mode 100644 index 0000000000..472972cb6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +// Helper function to convert JsonElement to standard Kotlin types recursively +fun JsonElement.toAnyValue(): Any = + when (this) { + is JsonPrimitive -> { + if (isString) { + content + } else { + content.toBooleanStrictOrNull() ?: content.toDoubleOrNull() ?: content.toLongOrNull() ?: content + } + } + + is JsonObject -> { + toAnyMap() + } + + is JsonArray -> { + map { it.toAnyValue() } + } + } + +fun JsonObject.toAnyMap(): Map = entries.associate { it.key to it.value.toAnyValue() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt index 811e46fe44..e3588f77d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -20,22 +20,25 @@ */ 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 com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedData +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object Nip47NotificationKSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -44,7 +47,44 @@ object Nip47NotificationKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Notification, - ): Unit = throw UnsupportedOperationException("NIP-47 Notification serialization not supported") + ) { + 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 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 index 691627409c..b58dbd182f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -20,46 +20,54 @@ */ 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 com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonNull.content import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object Nip47RequestKSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -68,7 +76,159 @@ object Nip47RequestKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Request, - ): Unit = throw UnsupportedOperationException("NIP-47 Request serialization not supported") + ) { + 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 @@ -100,6 +260,7 @@ object Nip47RequestKSerializer : KSerializer { PayInvoiceParams( invoice = it["invoice"]?.jsonPrimitive?.content, amount = it["amount"]?.jsonPrimitive?.longOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), ) }, ) @@ -135,6 +296,7 @@ object Nip47RequestKSerializer : KSerializer { description = it["description"]?.jsonPrimitive?.content, description_hash = it["description_hash"]?.jsonPrimitive?.content, expiry = it["expiry"]?.jsonPrimitive?.longOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), ) }, ) @@ -194,6 +356,7 @@ object Nip47RequestKSerializer : KSerializer { budget_renewal = it["budget_renewal"]?.jsonPrimitive?.content, expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, isolated = it["isolated"]?.jsonPrimitive?.booleanOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), ) }, ) 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 index 7c124de3cd..30922c5439 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -20,38 +20,45 @@ */ 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 com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object Nip47ResponseKSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -60,7 +67,154 @@ object Nip47ResponseKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Response, - ): Unit = throw UnsupportedOperationException("NIP-47 Response serialization not supported") + ) { + 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 @@ -162,6 +316,26 @@ object Nip47ResponseKSerializer : KSerializer { 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( @@ -178,6 +352,7 @@ object Nip47ResponseKSerializer : KSerializer { 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(), ) } @@ -260,6 +435,7 @@ object Nip47ResponseKSerializer : KSerializer { 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, ) }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Notification.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Notification.kt index 2c1bbdfc36..6622c219ce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Notification.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.rpc import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt index cef350602a..0a27468342 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.rpc enum class NwcErrorCode { RATE_LIMITED, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt index afc6474ba4..94d3470f14 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.rpc object NwcMethod { const val PAY_INVOICE = "pay_invoice" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransaction.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransaction.kt index adff64b56c..6d3a5888a6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransaction.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.rpc object NwcTransactionType { const val INCOMING = "incoming" @@ -62,7 +62,7 @@ class NwcTransaction( var expires_at: Long? = null, var settled_at: Long? = null, var settle_deadline: Long? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) { fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index a275ac4695..88de9de01d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.rpc import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt index 26e5711a3e..a0b3867d69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.rpc import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable @@ -31,7 +31,7 @@ abstract class Request( class PayInvoiceParams( var invoice: String? = null, var amount: Long? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) class PayInvoiceMethod( @@ -74,7 +74,7 @@ class MakeInvoiceParams( var description: String? = null, var description_hash: String? = null, var expiry: Long? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) class MakeInvoiceMethod( @@ -131,7 +131,10 @@ class ListTransactionsMethod( type: String? = null, unpaid_outgoing: Boolean? = null, unpaid_incoming: Boolean? = null, - ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type)) + ): ListTransactionsMethod = + ListTransactionsMethod( + ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type), + ) } } @@ -233,7 +236,7 @@ class CreateConnectionParams( var budget_renewal: String? = null, var expires_at: Long? = null, var isolated: Boolean? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) class CreateConnectionMethod( @@ -249,7 +252,7 @@ class CreateConnectionMethod( budgetRenewal: String? = null, expiresAt: Long? = null, isolated: Boolean? = null, - metadata: Any? = 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/rpc/Response.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index 623f0cee6a..7f8669fda2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip47WalletConnect +package com.vitorpamplona.quartz.nip47WalletConnect.rpc import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable @@ -105,7 +105,7 @@ class GetInfoSuccessResponse( val block_hash: String? = null, val methods: List? = null, val notifications: List? = null, - val metadata: Any? = null, + val metadata: Map? = null, val lud16: String? = null, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt index a8e28b6f8e..a96ca7fd38 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag @@ -47,7 +48,12 @@ class CalendarRSVPEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun status() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parse) fun statusValue() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parseValue) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt index 5ff212d0c3..c1ca788297 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt @@ -20,14 +20,19 @@ */ package com.vitorpamplona.quartz.nip59Giftwrap.rumors +import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization.RumorKSerializer import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.utils.EventFactory +import kotlinx.serialization.Serializable +@Immutable +@Serializable(with = RumorKSerializer::class) class Rumor( val id: HexKey?, val pubKey: HexKey?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt index 997e9921c7..4dd69fbd45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -217,9 +217,12 @@ object ChessStateReconstructor { return fen1 == fen2 // Fallback to exact match } - return parts1[0] == parts2[0] && // Board position - parts1[1] == parts2[1] && // Active color - parts1[2] == parts2[2] && // Castling rights + return parts1[0] == parts2[0] && + // Board position + parts1[1] == parts2[1] && + // Active color + parts1[2] == parts2[2] && + // Castling rights parts1[3] == parts2[3] // En passant } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt index 0eb536cd3c..ac68687b6a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt @@ -20,18 +20,26 @@ */ package com.vitorpamplona.quartz.nip64Chess.jester +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable /** - * JSON content structure for Jester events + * JSON content structure for Jester events. + * + * Fields marked @EncodeDefault are always serialized even when they match + * the default value. This is required for jester.nyo.dev compatibility — + * jester's isStartGameEvent checks arrayEquals(json.history, []) which + * fails if the field is absent. */ +@OptIn(ExperimentalSerializationApi::class) @Serializable data class JesterContent( - val version: String = "0", + @EncodeDefault val version: String = "0", val kind: Int, - val fen: String = JesterProtocol.FEN_START, + @EncodeDefault val fen: String = JesterProtocol.FEN_START, val move: String? = null, - val history: List = emptyList(), + @EncodeDefault val history: List = emptyList(), val nonce: String? = null, // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) val playerColor: String? = null, // "white" or "black" - challenger's color choice diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt index 66f140b471..ca48ffadab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -21,6 +21,5 @@ package com.vitorpamplona.quartz.nip89AppHandlers.clientTag import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -fun Event.client() = tags.zapraiserAmount() +fun Event.client() = tags.client() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt index 7af90417f6..5403ffca59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) -fun TagArrayBuilder.client( +fun TagArrayBuilder.client( name: String, address: AddressHint, ) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4679e46654..0239da5a80 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.quartz.utils +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -38,7 +42,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher @@ -76,10 +80,10 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip47WalletConnect.NwcInfoEvent -import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -185,6 +189,10 @@ class EventFactory { AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) AppRecommendationEvent.KIND -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) AppSpecificDataEvent.KIND -> AppSpecificDataEvent(id, pubKey, createdAt, tags, content, sig) + AttestationEvent.KIND -> AttestationEvent(id, pubKey, createdAt, tags, content, sig) + AttestationRequestEvent.KIND -> AttestationRequestEvent(id, pubKey, createdAt, tags, content, sig) + AttestorRecommendationEvent.KIND -> AttestorRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AttestorProficiencyEvent.KIND -> AttestorProficiencyEvent(id, pubKey, createdAt, tags, content, sig) AudioHeaderEvent.KIND -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) @@ -282,7 +290,7 @@ class EventFactory { PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) - PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) + ZapPollEvent.KIND -> ZapPollEvent(id, pubKey, createdAt, tags, content, sig) PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig) PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt index 3503c2edf0..a0bc76d7f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt @@ -20,12 +20,47 @@ */ package com.vitorpamplona.quartz.utils -expect object Rfc3986 { - fun normalize(uri: String): String +import io.kotlingeekdev.urireference.URIReference - fun isValidUrl(url: String): Boolean +object Rfc3986 { + fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() - fun normalizeAndRemoveFragment(url: String): String + fun isValidUrl(url: String): Boolean = + runCatching { + URIReference.parse(url) + }.isSuccess - fun host(url: String): String + fun normalizeAndRemoveFragment(url: String): String = + URIReference + .parse(url) + .normalize()!! + .toStringNoFragment() + .internIfPossible() + + fun host(url: String): String = + URIReference + .parse(url) + .host + ?.value + .toString() +} + +fun URIReference.toStringSchemeHost(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (authority != null) sb.append("//").append(authority.toString()) + + return sb.toString() +} + +fun URIReference.toStringNoFragment(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (host != null) sb.append("//").append(host.toString()) + if (path != null) sb.append(path) + if (query != null) sb.append("?").append(query) + + return sb.toString() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index c8fcbde7db..90ca1272c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -24,6 +24,7 @@ object TimeUtils { const val TEN_SECONDS = 10 const val ONE_MINUTE = 60 const val FIVE_MINUTES = 5 * ONE_MINUTE + const val TEN_MINUTES = 10 * ONE_MINUTE const val FIFTEEN_MINUTES = 15 * ONE_MINUTE const val ONE_HOUR = 60 * ONE_MINUTE const val EIGHT_HOURS = 8 * ONE_HOUR @@ -70,4 +71,9 @@ object TimeUtils { fun ninetyDaysFromNow() = now() + NINETY_DAYS fun oneYearAgo() = now() - ONE_YEAR + + fun withinTenMinutes(time: Long): Boolean { + val now = now() + return time > now - TEN_MINUTES && time < now + TEN_MINUTES + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt index 538d9ea323..46927f771d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt @@ -228,11 +228,17 @@ class Url( return null } + val startIndex = urlMarker.indexOf(part) + if (startIndex < 0 || startIndex >= originalUrl.length) { + return null + } + val nextPart = nextExistingPart(part) return if (nextPart == null) { - originalUrl.substring(urlMarker.indexOf(part)) + originalUrl.substring(startIndex) } else { - originalUrl.substring(urlMarker.indexOf(part), urlMarker.indexOf(nextPart)) + val endIndex = urlMarker.indexOf(nextPart) + originalUrl.substring(startIndex, minOf(endIndex, originalUrl.length)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt index ebe64927c0..d911471726 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt @@ -298,11 +298,22 @@ class DomainNameReader( topLevelLength = currentLabelLength } - var lastWasAscii: Boolean? = null + var lastWasAscii: Boolean? = + if (current.isNullOrEmpty()) { + null + } else { + val last = current.last() + if (isDot(last)) { + null + } else { + last.code < INTERNATIONAL_CHAR_START + } + } var isAscii = false while (!done && !reader.eof()) { val curr: Char = reader.read() + isAscii = curr.code < INTERNATIONAL_CHAR_START if (lastWasAscii == null) { lastWasAscii = isAscii @@ -765,7 +776,7 @@ class DomainNameReader( * The start of the utf character code table which indicates that this character is an international character. * Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name. */ - private const val INTERNATIONAL_CHAR_START = 192 + const val INTERNATIONAL_CHAR_START = 192 /** * The maximum length of each label in the domain name. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index f572721a51..0c1f1fa8fe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.UrlMarker import com.vitorpamplona.quartz.utils.urldetector.UrlPart +import com.vitorpamplona.quartz.utils.urldetector.detection.DomainNameReader.Companion.INTERNATIONAL_CHAR_START import kotlin.math.max import kotlin.text.deleteRange @@ -84,6 +85,9 @@ class UrlDetector( var length = 0 var position = 0 + var lastWasAscii: Boolean? = null + var isAscii = false + // until end of string read the contents while (!reader.eof()) { // read the next char to process. @@ -96,6 +100,7 @@ class UrlDetector( } readEnd(ReadEndState.InvalidUrl) length = 0 + lastWasAscii = null } '%' -> { @@ -116,6 +121,7 @@ class UrlDetector( length = 0 } } + lastWasAscii = null } '\u3002', '\uFF0E', '\uFF61', '.' -> { @@ -125,6 +131,7 @@ class UrlDetector( readEnd(ReadEndState.InvalidUrl) } length = 0 + lastWasAscii = null } '@' -> { @@ -136,6 +143,7 @@ class UrlDetector( } length = 0 } + lastWasAscii = null } '[' -> { @@ -153,6 +161,7 @@ class UrlDetector( reader.seek(beginning) } length = 0 + lastWasAscii = null } '/' -> { @@ -180,15 +189,29 @@ class UrlDetector( hasScheme = readHtml5Root() length = buffer.length } + lastWasAscii = null } ':' -> { // add the ":" to the url and check for scheme/username buffer.append(curr) length = processColon(length) + lastWasAscii = null } else -> { + isAscii = curr.code < INTERNATIONAL_CHAR_START + if (lastWasAscii == null) { + lastWasAscii = isAscii + } else if (isAscii != lastWasAscii) { + // threat changes in char as a space + if (buffer.isNotEmpty() && hasScheme) { + reader.goBack() + readDomainName(buffer.substring(length)) + } + length = 0 + } + buffer.append(curr) } } diff --git a/quartz/src/commonTest/kotlin/android/util/Log.kt b/quartz/src/commonTest/kotlin/android/util/Log.kt index ba75a2cab1..eb9574b088 100644 --- a/quartz/src/commonTest/kotlin/android/util/Log.kt +++ b/quartz/src/commonTest/kotlin/android/util/Log.kt @@ -24,6 +24,12 @@ import kotlin.jvm.JvmStatic class Log { companion object { + @JvmStatic + fun isLoggable( + tag: String?, + msg: Int?, + ): Boolean = true + @JvmStatic fun d( tag: String?, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt index 633888a45a..6f0c5a11c7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -21,5 +21,7 @@ package com.vitorpamplona.quartz expect class TestResourceLoader() { + fun loadDecompressString(file: String): String + fun loadString(file: String): String } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt new file mode 100644 index 0000000000..bc89df39f4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -0,0 +1,536 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerAuthTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val pubkey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/") + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String = hexId(1), + kind: Int = 1, + createdAt: Long = 1000L, + content: String = "hello", + tags: Array> = emptyArray(), + ) = Event(id, pubkey, createdAt, kind, tags, content, sig) + + /** + * Creates a server using the given dispatcher so coroutines run eagerly + * in tests (UnconfinedTestDispatcher). + */ + private fun createServer( + dispatcher: CoroutineDispatcher, + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { FullAuthPolicy(relayUrl) }, + ): NostrServer = + NostrServer( + store = store, + policyBuilder = policyBuilder, + parentContext = dispatcher, + ) + + private suspend fun RelaySession.insert(event: Event) { + val cmd = EventCmd(event) + this.receive(OptimizedJsonMapper.toJson(cmd)) + } + + /** Collects sent JSON messages for a connection. */ + private class MessageCollector { + val messages = mutableListOf() + + val sendCallback: (String) -> Unit = { messages.add(it) } + + /** + * Parses messages that can be round-tripped (EVENT, EOSE, NOTICE, + * CLOSED). OkMessage and CountMessage serialization uses formats + * incompatible with the client-side deserializer, so check those + * via [rawMessagesContaining]. + */ + fun parsedEventMessages() = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun rawMessagesContaining(label: String) = messages.filter { it.contains("\"$label\"") } + } + + /** + * Builds a kind 22242 auth event for testing. Because verify = { true }, + * the id and signature don't need to be real. + */ + private fun authEvent( + challenge: String, + relay: String = relayUrl.url, + pubKey: String = pubkey, + createdAt: Long = TimeUtils.now(), + ) = RelayAuthEvent( + id = hexId(99), + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("relay", relay), + arrayOf("challenge", challenge), + ), + content = "", + sig = sig, + ) + + private fun authJson(event: RelayAuthEvent): String { + val cmd = AuthCmd(event) + return OptimizedJsonMapper.toJson(cmd) + } + + private fun authJson(event: Event) = """["AUTH",${event.toJson()}]""" + + @Test + fun authChallengeIsSentOnRequest() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + + server.shutdown() + } + + @Test + fun authSucceedsWithValidEvent() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) + assertTrue(session.policy.authenticatedUsers.contains(pubkey)) + + server.shutdown() + } + + @Test + fun authFailsWithWrongChallenge() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = authEvent(challenge = "wrong-challenge") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("challenge")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongRelay() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge, relay = "wss://wrong.relay.com/") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("relay url")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithExpiredTimestamp() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = + authEvent( + challenge = msg.challenge, + createdAt = TimeUtils.now() - 1200L, // 20 minutes ago + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("created_at")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongKind() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Create an event with wrong kind (1 instead of 22242) + val event = + Event( + id = hexId(99), + pubKey = pubkey, + createdAt = TimeUtils.now(), + kind = 1, + tags = + arrayOf( + arrayOf("relay", relayUrl.url), + arrayOf("challenge", msg.challenge), + ), + content = "", + sig = sig, + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("NOTICE") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("error")) + assertTrue(okMessages[0].contains("could not parse message")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun multipleUsersCanAuthenticate() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // First user authenticates + val event1 = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(event1)) + + // Second user authenticates on the same session + val event2 = authEvent(challenge = msg.challenge, pubKey = pubkey2) + session.receive(authJson(event2)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[1].contains("\"true\"")) + + val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers + assertEquals(2, authedPubkeys.size) + assertTrue(authedPubkeys.contains(pubkey)) + assertTrue(authedPubkeys.contains(pubkey2)) + + server.shutdown() + } + + // -- NIP-42: requireAuth --------------------------------------------------- + + @Test + fun requireAuthRejectsEventWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsReqWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsCountWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["COUNT","q1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthAllowsCommandsAfterAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate first + val authEv = authEvent(challenge = msg.challenge) + session.receive(authJson(authEv)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + // Now EVENT should work + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val allOk = collector.rawMessagesContaining("OK") + assertEquals(2, allOk.size) + assertTrue(allOk[1].contains("\"true\"")) + + // REQ should work + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val eoseMessages = collector.rawMessagesContaining("EOSE") + assertTrue(eoseMessages.isNotEmpty()) + + server.shutdown() + } + + @Test + fun noAuthRequiredAllowsCommandsWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { EmptyPolicy }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // EVENT should work without auth when using OpenPolicy + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + server.shutdown() + } + + // -- Custom AuthPolicy tests ----------------------------------------------- + + @Test + fun customPolicyRejectsSpecificEventKinds() = + runTest { + // Policy that blocks kind 4 (DMs) from unauthenticated users. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = + if (cmd.event.kind == 4 && authenticatedUsers.isEmpty()) { + PolicyResult.Rejected("auth-required: kind 4 events require authentication") + } else { + PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { policy }) + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + // Kind 1 should be accepted without auth + val note = testEvent(hexId(1), kind = 1) + session.receive("""["EVENT",${note.toJson()}]""") + assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) + + // Kind 4 should be rejected without auth + val dm = testEvent(hexId(2), kind = 4) + session.receive("""["EVENT",${dm.toJson()}]""") + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[1].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun customPolicyRewritesFilters() = + runTest { + // Policy that restricts kind 4 queries to the authed user's own messages. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult { + val hasDmFilter = cmd.filters.any { it.kinds?.contains(4) == true } + if (!hasDmFilter) return PolicyResult.Accepted(cmd) + if (authenticatedUsers.isEmpty()) { + return PolicyResult.Rejected("auth-required: kind 4 requires auth") + } + // Rewrite: restrict to authed user's pubkey as author + val rewritten = + cmd.filters.map { filter -> + if (filter.kinds?.contains(4) == true) { + filter.copy(authors = authenticatedUsers.toList()) + } else { + filter + } + } + return PolicyResult.Accepted(ReqCmd(cmd.subId, rewritten)) + } + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(store = store, dispatcher = dispatcher, policyBuilder = { policy }) + + // Insert DMs from two different authors + store.insert(testEvent(hexId(1), kind = 4, createdAt = 100L)) // from pubkey + store.insert( + Event(hexId(2), pubkey2, 200L, 4, emptyArray(), "secret", sig), + ) // from pubkey2 + + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate as pubkey + val auth = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(auth)) + + // Query kind 4 — policy should rewrite to only return pubkey's events + session.receive("""["REQ","sub1",{"kinds":[4]}]""") + + val events = collector.parsedEventMessages().filterIsInstance() + assertEquals(1, events.size) + assertEquals(pubkey, events[0].event.pubKey) + + server.shutdown() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt new file mode 100644 index 0000000000..165bfb0e47 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String = hexId(1), + kind: Int = 1, + createdAt: Long = 1000L, + content: String = "hello", + tags: Array> = emptyArray(), + ) = Event(id, pubkey, createdAt, kind, tags, content, sig) + + /** + * Creates a server using the given dispatcher so coroutines run eagerly + * in tests (UnconfinedTestDispatcher). + */ + private fun createServer( + dispatcher: kotlinx.coroutines.CoroutineDispatcher, + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, + ): NostrServer = + NostrServer( + store = store, + policyBuilder = policyBuilder, + parentContext = dispatcher, + ) + + private suspend fun RelaySession.insert(event: Event) { + val cmd = EventCmd(event) + this.receive(OptimizedJsonMapper.toJson(cmd)) + } + + /** Collects sent JSON messages for a connection. */ + private class MessageCollector { + val messages = mutableListOf() + + val sendCallback: (String) -> Unit = { messages.add(it) } + + /** + * Parses messages that can be round-tripped (EVENT, EOSE, NOTICE, + * CLOSED). OkMessage and CountMessage serialization uses formats + * incompatible with the client-side deserializer, so check those + * via [rawMessagesContaining]. + */ + fun parsedEventMessages() = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun rawMessagesContaining(label: String) = messages.filter { it.contains("\"$label\"") } + } + + // -- EVENT command --------------------------------------------------------- + + @Test + fun eventCommandStoresAndRespondsOk() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + + val c1 = server.connect(collector.sendCallback) + + val event = testEvent() + val eventJson = """["EVENT",${event.toJson()}]""" + c1.receive(eventJson) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + // Event should be in store + val stored = store.query(Filter(ids = listOf(event.id))) + assertEquals(1, stored.size) + + server.shutdown() + } + + @Test + fun duplicateEventReturnsOkFalse() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + + val c1 = server.connect(collector.sendCallback) + + val event = testEvent() + val eventJson = """["EVENT",${event.toJson()}]""" + c1.receive(eventJson) + c1.receive(eventJson) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[1].contains("\"false\"")) + + server.shutdown() + } + + // -- REQ command ----------------------------------------------------------- + + @Test + fun reqReturnsStoredEventsAndEose() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + + // Pre-populate store + store.insert(testEvent(hexId(1), kind = 1, createdAt = 100L)) + store.insert(testEvent(hexId(2), kind = 1, createdAt = 200L)) + store.insert(testEvent(hexId(3), kind = 4, createdAt = 300L)) + + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + val parsed = collector.parsedEventMessages() + val events = parsed.filterIsInstance() + val eose = parsed.filterIsInstance() + + assertEquals(2, events.size) + assertEquals(1, eose.size) + assertEquals("sub1", eose[0].subId) + + // Events should be newest first + assertTrue(events[0].event.createdAt >= events[1].event.createdAt) + + server.shutdown() + } + + @Test + fun reqWithLimitRespectsLimit() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + + for (i in 1..10) { + store.insert(testEvent(hexId(i), createdAt = i.toLong())) + } + + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val reqJson = """["REQ","sub1",{"limit":3}]""" + c1.receive(reqJson) + + val events = collector.parsedEventMessages().filterIsInstance() + assertEquals(3, events.size) + + server.shutdown() + } + + // -- Live subscription ----------------------------------------------------- + + @Test + fun liveSubscriptionReceivesNewEvents() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + // Subscribe to kind 1 + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + // After REQ, we should have EOSE + val countAfterEose = collector1.messages.size + + // Now store a new event — should be pushed to subscription + c2.insert(testEvent(hexId(1), kind = 1)) + + val newMessages = collector1.messages.drop(countAfterEose) + assertTrue(newMessages.isNotEmpty()) + assertTrue(newMessages[0].contains("\"EVENT\"")) + + server.shutdown() + } + + @Test + fun liveSubscriptionFiltersNonMatchingEvents() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + val countAfterEose = collector1.messages.size + + // Store a kind 4 event — should NOT match kind 1 subscription + c2.insert(testEvent(hexId(1), kind = 4)) + + assertEquals(countAfterEose, collector1.messages.size) + + server.shutdown() + } + + // -- CLOSE command --------------------------------------------------------- + + @Test + fun closeStopsSubscription() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + val reqJson = """["REQ","sub1",{"kinds":[1]}]""" + c1.receive(reqJson) + + // Close the subscription + val closeJson = """["CLOSE","sub1"]""" + c1.receive(closeJson) + + val countAfterClose = collector1.messages.size + + // New events should NOT reach this subscription + c2.insert(testEvent(hexId(1), kind = 1)) + + assertEquals(countAfterClose, collector1.messages.size) + + server.shutdown() + } + + @Test + fun replacingSubscriptionCancelsOld() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + // First subscription for kind 1 + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") + + // Replace with kind 4 + c1.receive("""["REQ","sub1",{"kinds":[4]}]""") + + val countAfterReplace = collector1.messages.size + + // Kind 1 events should not match anymore + c2.insert(testEvent(hexId(1), kind = 1)) + + assertEquals(countAfterReplace, collector1.messages.size) + + // Kind 4 events should match + c2.insert(testEvent(hexId(2), kind = 4)) + + val newMessages = collector1.messages.drop(countAfterReplace) + + assertTrue(newMessages.isNotEmpty()) + assertTrue(newMessages[0].contains("\"EVENT\"")) + assertTrue(newMessages[0].contains(hexId(2))) + + server.shutdown() + } + + // -- COUNT command (NIP-45) ------------------------------------------------ + + @Test + fun countReturnsMatchingEventCount() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + + store.insert(testEvent(hexId(1), kind = 1)) + store.insert(testEvent(hexId(2), kind = 1)) + store.insert(testEvent(hexId(3), kind = 4)) + + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val countJson = """["COUNT","q1",{"kinds":[1]}]""" + c1.receive(countJson) + + val countMessages = collector.rawMessagesContaining("COUNT") + assertEquals(1, countMessages.size) + assertTrue(countMessages[0].contains("\"count\":2")) + + server.shutdown() + } + + // -- Disconnect ------------------------------------------------------------ + + @Test + fun disconnectCancelsAllSubscriptions() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher) + val collector1 = MessageCollector() + val collector2 = MessageCollector() + + val c1 = server.connect(collector1.sendCallback) + val c2 = server.connect(collector2.sendCallback) + + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") + c1.receive("""["REQ","sub2",{"kinds":[4]}]""") + + c1.close() + + val countAfterDisconnect = collector1.messages.size + + c2.insert(testEvent(hexId(1), kind = 1)) + c2.insert(testEvent(hexId(2), kind = 4)) + + assertEquals(countAfterDisconnect, collector1.messages.size) + assertEquals(2, collector2.messages.size) + + server.shutdown() + } + + // -- Invalid messages ------------------------------------------------------ + + @Test + fun invalidJsonReturnsNotice() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val c1 = server.connect(collector.sendCallback) + c1.receive("not valid json") + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("NOTICE")) + + server.shutdown() + } +} diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt similarity index 90% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index eb6628aa3e..12c1209b59 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -20,15 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.fail -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class AddressableTest : BaseDBTest() { val signer = NostrSignerSync() @@ -76,18 +75,12 @@ class AddressableTest : BaseDBTest() { db.assertQuery(version3, Filter(ids = listOf(version3.id))) - try { + assertFailsWith { db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } - try { + assertFailsWith { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } db.assertQuery(version3, Filter(ids = listOf(version3.id))) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt similarity index 69% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt index f2fa60ceb6..1475b91e7b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import junit.framework.TestCase +import kotlin.test.assertEquals fun EventStore.assertQuery( expected: T?, @@ -31,12 +31,12 @@ fun EventStore.assertQuery( val queryResult = query(filter) val countResult = count(filter) if (expected == null) { - TestCase.assertEquals(0, queryResult.size) - TestCase.assertEquals(0, countResult) + assertEquals(0, queryResult.size) + assertEquals(0, countResult) } else { - TestCase.assertEquals(1, queryResult.size) - TestCase.assertEquals(1, countResult) - TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + assertEquals(1, queryResult.size) + assertEquals(1, countResult) + assertEquals(expected.toJson(), queryResult.first().toJson()) } } @@ -46,10 +46,10 @@ fun EventStore.assertQuery( ) { val queryResult = query(filter) val countResult = count(filter) - TestCase.assertEquals(expected.size, queryResult.size) - TestCase.assertEquals(expected.size, countResult) + assertEquals(expected.size, queryResult.size) + assertEquals(expected.size, countResult) expected.forEachIndexed { index, event -> - TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + assertEquals(event.toJson(), queryResult[index].toJson()) } } @@ -60,12 +60,12 @@ fun SQLiteEventStore.assertQuery( val queryResult = query(filter) val countResult = count(filter) if (expected == null) { - TestCase.assertEquals(0, queryResult.size) - TestCase.assertEquals(0, countResult) + assertEquals(0, queryResult.size) + assertEquals(0, countResult) } else { - TestCase.assertEquals(1, queryResult.size) - TestCase.assertEquals(1, countResult) - TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + assertEquals(1, queryResult.size) + assertEquals(1, countResult) + assertEquals(expected.toJson(), queryResult.first().toJson()) } } @@ -75,9 +75,9 @@ fun SQLiteEventStore.assertQuery( ) { val queryResult = query(filter) val countResult = count(filter) - TestCase.assertEquals(expected.size, queryResult.size) - TestCase.assertEquals(expected.size, countResult) + assertEquals(expected.size, queryResult.size) + assertEquals(expected.size, countResult) expected.forEachIndexed { index, event -> - TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + assertEquals(event.toJson(), queryResult[index].toJson()) } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt similarity index 91% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt index 2dbc1d860c..8ab0e902c8 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import org.junit.After -import org.junit.Before +import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlin.test.AfterTest +import kotlin.test.BeforeTest open class BaseDBTest { private lateinit var dbs: MutableMap @@ -36,10 +36,8 @@ open class BaseDBTest { useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy """.trimIndent() - @Before + @BeforeTest fun setup() { - val context = ApplicationProvider.getApplicationContext() - val booleans = listOf(true, false) dbs = mutableMapOf() @@ -58,7 +56,6 @@ open class BaseDBTest { ) dbs[indexStrategy.name()] = EventStore( - context = context, dbName = null, indexStrategy = indexStrategy, ) @@ -68,7 +65,7 @@ open class BaseDBTest { } } - @After + @AfterTest fun tearDown() { dbs.forEach { it.value.close() } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt similarity index 98% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index 8dba4ac609..56216d2db5 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -28,9 +28,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class BasicTest : BaseDBTest() { val signer = NostrSignerSync() diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt similarity index 89% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 50af62947f..9ad77c82a3 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -20,19 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher +import com.vitorpamplona.quartz.nip01Core.store.sqlite.explainQuery import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase -import junit.framework.TestCase.fail -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class DeletionTest : BaseDBTest() { val signer = NostrSignerSync() @@ -61,12 +62,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(note2, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -107,12 +104,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(null, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -146,12 +139,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(null, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -192,12 +181,8 @@ class DeletionTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(wrap1.id))) db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @@ -218,7 +203,7 @@ class DeletionTest : BaseDBTest() { val explainer = db.store.explainQuery(sql) if (db.indexStrategy.indexTagsWithKindAndPubkey) { - TestCase.assertEquals( + assertEquals( """ |$sql |└── SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) @@ -226,7 +211,7 @@ class DeletionTest : BaseDBTest() { explainer, ) } else { - TestCase.assertEquals( + assertEquals( """ |$sql |└── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) @@ -245,10 +230,13 @@ class DeletionTest : BaseDBTest() { pubkey = "key1", idValues = listOf("ca29c211f", "ca29c211d"), addresses = emptyList(), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE @@ -275,10 +263,13 @@ class DeletionTest : BaseDBTest() { listOf( Address(30000, "key1", "a"), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE ( @@ -309,10 +300,13 @@ class DeletionTest : BaseDBTest() { Address(30000, "key1", "c"), Address(30000, "key1", "d"), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE ( @@ -345,10 +339,13 @@ class DeletionTest : BaseDBTest() { Address(30001, "key2", "e"), Address(30001, "key2", "f"), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE ( @@ -387,10 +384,13 @@ class DeletionTest : BaseDBTest() { Address(10001, "key2", ""), Address(10001, "key2", ""), ), - hasher = TagNameValueHasher(0), + hasher = + TagNameValueHasher( + 0, + ), ).first() - TestCase.assertEquals( + assertEquals( """ DELETE FROM event_headers WHERE diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt similarity index 88% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt index e9c589bfe7..fcd90af04b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase.fail -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertFailsWith class ExpirationTest : BaseDBTest() { val signer = NostrSignerSync() @@ -58,7 +59,9 @@ class ExpirationTest : BaseDBTest() { db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) - Thread.sleep(2000) + runBlocking { + delay(2000) + } db.deleteExpiredEvents() @@ -78,11 +81,8 @@ class ExpirationTest : BaseDBTest() { }, ) - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert expired events") - } catch (e: Exception) { - assertTrue(e is SQLiteConstraintException) } } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt similarity index 99% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt index 196dd769ca..0c5a27283b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import org.junit.Test +import kotlin.test.Test class FilterMatcherTest : BaseDBTest() { val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt similarity index 55% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index 5947c9cdb1..82a982c1d1 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -20,47 +20,38 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import android.database.sqlite.SQLiteException -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.fasterxml.jackson.module.kotlin.readValue +import androidx.sqlite.SQLiteException +import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log -import org.junit.After -import org.junit.Before -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import java.util.zip.GZIPInputStream -import kotlin.system.measureTimeMillis +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) class LargeDBTests { companion object { - fun getEventDB(): List { - // This file includes duplicates - val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json") - - return JacksonMapper.mapper.readValue>( - GZIPInputStream(fullDBInputStream), + fun getEventDB(): List = + OptimizedJsonMapper.fromJsonToEventList( + TestResourceLoader().loadDecompressString("nostr_vitor_startup_data.json"), ) - } - val events = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + val events by + lazy { + getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + } } private lateinit var db: EventStore - @Before + @BeforeTest fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) + db = EventStore(null) } - @After + @AfterTest fun tearDown() { db.close() } @@ -69,13 +60,7 @@ class LargeDBTests { fun insertHeavyEvent() { events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> try { - val measure = - measureTimeMillis { - db.insert(event) - } - if (measure > 1) { - println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") - } + db.insert(event) } catch (e: SQLiteException) { Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") } @@ -83,17 +68,10 @@ class LargeDBTests { } @Test - @Ignore("Not testing") fun insertDatabase() { events.forEach { event -> try { - val measure = - measureTimeMillis { - db.insert(event) - } - if (measure > 1) { - println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") - } + db.insert(event) } catch (e: SQLiteException) { Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt similarity index 96% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index f9f3090423..29c41ee4ea 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -23,15 +23,14 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import junit.framework.TestCase -import junit.framework.TestCase.assertEquals -import org.junit.Assert -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals class QueryAssemblerTest : BaseDBTest() { val hasher = TagNameValueHasher(0) @@ -39,16 +38,16 @@ class QueryAssemblerTest : BaseDBTest() { val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) + fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.connection) - fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) + fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.connection) @Test fun testEmpty() = forEachDB { db -> val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexEventsByCreatedAtAlone) { - Assert.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -57,7 +56,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(Filter()), ) } else { - Assert.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -81,7 +80,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsWithKindAndPubkey) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -99,7 +98,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -126,7 +125,7 @@ class QueryAssemblerTest : BaseDBTest() { val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexEventsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -136,7 +135,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY $orderBy @@ -160,7 +159,7 @@ class QueryAssemblerTest : BaseDBTest() { "created_at DESC" } if (db.indexStrategy.indexEventsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -187,7 +186,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -234,7 +233,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexEventsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -254,14 +253,15 @@ class QueryAssemblerTest : BaseDBTest() { │ │ └── SCAN (subquery-1) │ ├── UNION USING TEMP B-TREE │ │ ├── CO-ROUTINE (subquery-3) - │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ │ └── USE TEMP B-TREE FOR ORDER BY │ │ └── SCAN (subquery-3) │ └── UNION USING TEMP B-TREE │ ├── CO-ROUTINE (subquery-5) - │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) - │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 + │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ └── USE TEMP B-TREE FOR ORDER BY │ └── SCAN (subquery-5) ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) @@ -270,7 +270,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -291,14 +291,15 @@ class QueryAssemblerTest : BaseDBTest() { │ │ └── SCAN (subquery-1) │ ├── UNION USING TEMP B-TREE │ │ ├── CO-ROUTINE (subquery-3) - │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ │ └── USE TEMP B-TREE FOR ORDER BY │ │ └── SCAN (subquery-3) │ └── UNION USING TEMP B-TREE │ ├── CO-ROUTINE (subquery-5) - │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) - │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 + │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ └── USE TEMP B-TREE FOR ORDER BY │ └── SCAN (subquery-5) ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) @@ -320,7 +321,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE kind = "3" @@ -331,7 +332,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE kind = "3" @@ -360,7 +361,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -400,7 +401,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "3") AND (d_tag = "") @@ -411,7 +412,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "3") AND (d_tag = "") @@ -436,7 +437,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -467,7 +468,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -485,7 +486,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -518,7 +519,7 @@ class QueryAssemblerTest : BaseDBTest() { ), ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -550,7 +551,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -568,7 +569,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -606,7 +607,7 @@ class QueryAssemblerTest : BaseDBTest() { ) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -625,7 +626,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -651,7 +652,7 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(ids = listOf(key1)) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" @@ -661,7 +662,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" @@ -678,7 +679,7 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300) if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) @@ -690,7 +691,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) @@ -709,26 +710,26 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(authors = listOf(key1, key2, key3), search = "keywords") if (db.indexStrategy.useAndIndexIdOnOrderBy) { - TestCase.assertEquals( + assertEquals( """ SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) ORDER BY event_headers.created_at DESC, event_headers.id ASC - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) ORDER BY event_headers.created_at DESC - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), @@ -742,13 +743,13 @@ class QueryAssemblerTest : BaseDBTest() { forEachDB { db -> val filter = Filter(kinds = listOf(1, 1111, 10000), search = "keywords") val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "event_headers.created_at DESC, event_headers.id ASC" else "event_headers.created_at DESC" - TestCase.assertEquals( + assertEquals( """ SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_fts MATCH "keywords") AND (event_headers.kind IN ("1", "1111", "10000")) ORDER BY $orderBy - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), @@ -762,7 +763,7 @@ class QueryAssemblerTest : BaseDBTest() { val filter = Filter(tagsAll = mapOf("p" to listOf(key1, key2))) val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (db.indexStrategy.indexTagsByCreatedAtAlone) { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -771,8 +772,8 @@ class QueryAssemblerTest : BaseDBTest() { ON event_headers.row_id = filtered.row_id ORDER BY $orderBy ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) - │ ├── SEARCH event_tagsAll0_1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) + │ ├── SEARCH event_tagsAll0_1 USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── SEARCH event_tags USING INDEX fk_event_tags_header_id (event_header_row_id=?) │ └── USE TEMP B-TREE FOR DISTINCT ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) @@ -781,7 +782,7 @@ class QueryAssemblerTest : BaseDBTest() { db.explain(filter), ) } else { - TestCase.assertEquals( + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( @@ -999,8 +1000,7 @@ class QueryAssemblerTest : BaseDBTest() { SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) ORDER BY created_at DESC, id ASC - ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - └── USE TEMP B-TREE FOR ORDER BY + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(), db.explain(filter), ) @@ -1010,8 +1010,7 @@ class QueryAssemblerTest : BaseDBTest() { SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) ORDER BY created_at DESC - ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - └── USE TEMP B-TREE FOR ORDER BY + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(), db.explain(filter), ) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt similarity index 90% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index ec0626e3f3..5f0ee192db 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.explainQuery import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.fail -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class ReplaceableTest : BaseDBTest() { val signer = NostrSignerSync() @@ -76,18 +76,12 @@ class ReplaceableTest : BaseDBTest() { db.assertQuery(version3, Filter(ids = listOf(version3.id))) - try { + assertFailsWith { db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } - try { + assertFailsWith { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) } db.assertQuery(version3, Filter(ids = listOf(version3.id))) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt similarity index 86% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt index 6dc5b0c1ac..36be8e9b59 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -20,16 +20,15 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.sqlite.SQLiteConstraintException +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.TimeUtils -import junit.framework.TestCase.fail -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertFailsWith class RightToVanishTest : BaseDBTest() { val signer = NostrSignerSync() @@ -59,12 +58,8 @@ class RightToVanishTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) - // trying to insert again should fail. - try { + assertFailsWith { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) @@ -107,11 +102,8 @@ class RightToVanishTest : BaseDBTest() { db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) // trying to insert again should fail. - try { + assertFailsWith { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) } db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt similarity index 99% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index ae6a595ea6..786d1a7568 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.Test +import kotlin.test.Test class SearchTest : BaseDBTest() { companion object { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt index 0d526ab172..02fbdc7b45 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.nip10Notes.urls import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.utils.fastFindURLs import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -31,7 +30,7 @@ class UrlsDetectorTest { @Test fun detectUrlNumber() { - val detectedLinks = fastFindURLs(testSentence) + val detectedLinks = findURLs(testSentence) assertEquals(2, detectedLinks.size) } @@ -41,4 +40,14 @@ class UrlsDetectorTest { assertContains(detectedLinks, "https://mysite.xyz") assertContains(detectedLinks, "https://myblog.xyz") } + + /** + * Regression test for PR #1907: the Japanese phrase "今北産業" must not crash the URL + * detector with a StringIndexOutOfBoundsException and must return no URLs. + */ + @Test + fun doesNotCrashOnJapaneseText() { + val detectedLinks = findURLs("今北産業") + assertEquals(0, detectedLinks.size) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt index 77a45f5a56..73b6b12012 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt @@ -34,7 +34,7 @@ class ConvertExceptionsTest { NostrSignerRemote.fromBunkerUri( "bunker://${"a".repeat(64)}?relay=wss://r.com", NostrSignerInternal(KeyPair()), - EmptyNostrClient, + EmptyNostrClient(), ) @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt index 75447e1be0..d0061c2f9a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt @@ -30,7 +30,7 @@ import kotlin.test.assertNull class FromBunkerUriTest { private val signer = NostrSignerInternal(KeyPair()) - private val client = EmptyNostrClient + private val client = EmptyNostrClient() private val validHex = "a".repeat(64) @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt index 95432f61fc..946b8fb2da 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -101,6 +101,8 @@ private class TrackingNostrClient : INostrClient { override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + + override fun close() {} } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index 4b70331eb0..bfbbe8ac4e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -21,6 +21,37 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionState +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt index 271c2783b2..07fa5ac130 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt @@ -24,6 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -86,29 +90,6 @@ class LnZapPaymentRequestEventTest { assertNull(event.encryptionScheme()) } - @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 testDecryptPayInvoiceRequest() = runTest { @@ -156,32 +137,6 @@ class LnZapPaymentRequestEventTest { assertEquals("test payment", decrypted.params?.description) } - @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) - } - @Test fun testCanDecrypt() = runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt index 39fd0f56b8..3d6760e3b2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt index 82d55cc645..74a5d023e2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.utils.DeterministicSigner import com.vitorpamplona.quartz.utils.nsecToKeyPair import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt index f8806444b1..7da485fb8a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -20,6 +20,13 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcBudgetRenewal +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionState +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt index 3b392eb55d..f8609aa151 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt index 5b43e7bf9f..8fbaa3a31c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -21,6 +21,22 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index ff7ad12a79..21680a2197 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -21,6 +21,23 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt new file mode 100644 index 0000000000..2cf13477b9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.urldetector + +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Regression tests for PR #1907: StringIndexOutOfBoundsException in [Url.getPart] when + * processing the Japanese text "今北産業". + */ +class UrlTest { + /** + * Regression: detecting URLs in "今北産業" must not throw and must return no URLs. + */ + @Test + fun detectingImakitaSangyoDoesNotThrow() { + val urls = UrlDetector("今北産業").detect() + assertEquals(0, urls.size) + } + + /** + * Regression: constructing a Url with "今北産業" as the original string and a HOST + * marker at 0 with PORT at the string length (simulating a trimmed trailing character) + * must not throw StringIndexOutOfBoundsException when accessing any property. + * + * "今北産業" has length 4. PORT at 4 == length triggers the startIndex >= length guard + * added to getPart() in PR #1907. + */ + @Test + fun urlPropertiesDoNotThrowForImakitaSangyoWithOutOfRangeMarker() { + val marker = UrlMarker() + marker.setIndex(UrlPart.HOST, 0) + marker.setIndex(UrlPart.PORT, 4) // == "今北産業".length + val url = marker.createUrl("今北産業") + + assertNotNull(url.scheme) + assertNotNull(url.host) + assertNotNull(url.path) + assertNotNull(url.query) + assertNotNull(url.fragment) + assertEquals(443, url.port) // getPart(PORT) returns null → -1 + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 9e73fd43f6..7bdf5afc0f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -739,6 +739,37 @@ class UriDetectionTest { runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net") } + @Test + fun testBrokenCaseInProduction() { + runTest("今北産業") + runTest("http://test.com今北産業", "http://test.com") + runTest("ftp://test.com今北産業", "ftp://test.com") + runTest("test.com今北産業", "test.com") + runTest("wss://test.com今北産業", "wss://test.com") + runTest("blossom:test.com今北産業", "blossom:test.com") + runTest("nostr:test.com今北産業", "nostr:test.com") + runTest("nostr:test今北産業", "nostr:test") + runTest("nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com", "http://test.com") + runTest("今北産業ftp://test.com", "ftp://test.com") + runTest("今北産業test.com", "test.com") + runTest("今北産業wss://test.com", "wss://test.com") + runTest("今北産業blossom:test.com", "blossom:test.com") + runTest("今北産業nostr:test.com", "nostr:test.com") + runTest("今北産業nostr:test", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com今北産業", "http://test.com") + runTest("今北産業ftp://test.com今北産業", "ftp://test.com") + runTest("今北産業test.com今北産業", "test.com") + runTest("今北産業wss://test.com今北産業", "wss://test.com") + runTest("今北産業blossom:test.com今北産業", "blossom:test.com") + runTest("今北産業nostr:test.com今北産業", "nostr:test.com") + runTest("今北産業nostr:test今北産業", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + } + @Test fun testFullText() { val text = diff --git a/quartz/src/commonTest/resources/bip39.vectors.json b/quartz/src/commonTest/resources/bip39.vectors.json new file mode 100644 index 0000000000..cf02fbb922 --- /dev/null +++ b/quartz/src/commonTest/resources/bip39.vectors.json @@ -0,0 +1,124 @@ +{ + "english": [ + [ + "00000000000000000000000000000000", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + ], + [ + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + "legal winner thank year wave sausage worth useful legal winner thank yellow", + "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607" + ], + [ + "80808080808080808080808080808080", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8" + ], + [ + "ffffffffffffffffffffffffffffffff", + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069" + ], + [ + "000000000000000000000000000000000000000000000000", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent", + "035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa" + ], + [ + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will", + "f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd" + ], + [ + "808080808080808080808080808080808080808080808080", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always", + "107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65" + ], + [ + "ffffffffffffffffffffffffffffffffffffffffffffffff", + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when", + "0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528" + ], + [ + "0000000000000000000000000000000000000000000000000000000000000000", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8" + ], + [ + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title", + "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87" + ], + [ + "8080808080808080808080808080808080808080808080808080808080808080", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless", + "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f" + ], + [ + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", + "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad" + ], + [ + "77c2b00716cec7213839159e404db50d", + "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge", + "b5b6d0127db1a9d2226af0c3346031d77af31e918dba64287a1b44b8ebf63cdd52676f672a290aae502472cf2d602c051f3e6f18055e84e4c43897fc4e51a6ff" + ], + [ + "b63a9c59a6e641f288ebc103017f1da9f8290b3da6bdef7b", + "renew stay biology evidence goat welcome casual join adapt armor shuffle fault little machine walk stumble urge swap", + "9248d83e06f4cd98debf5b6f010542760df925ce46cf38a1bdb4e4de7d21f5c39366941c69e1bdbf2966e0f6e6dbece898a0e2f0a4c2b3e640953dfe8b7bbdc5" + ], + [ + "3e141609b97933b66a060dcddc71fad1d91677db872031e85f4c015c5e7e8982", + "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic", + "ff7f3184df8696d8bef94b6c03114dbee0ef89ff938712301d27ed8336ca89ef9635da20af07d4175f2bf5f3de130f39c9d9e8dd0472489c19b1a020a940da67" + ], + [ + "0460ef47585604c5660618db2e6a7e7f", + "afford alter spike radar gate glance object seek swamp infant panel yellow", + "65f93a9f36b6c85cbe634ffc1f99f2b82cbb10b31edc7f087b4f6cb9e976e9faf76ff41f8f27c99afdf38f7a303ba1136ee48a4c1e7fcd3dba7aa876113a36e4" + ], + [ + "72f60ebac5dd8add8d2a25a797102c3ce21bc029c200076f", + "indicate race push merry suffer human cruise dwarf pole review arch keep canvas theme poem divorce alter left", + "3bbf9daa0dfad8229786ace5ddb4e00fa98a044ae4c4975ffd5e094dba9e0bb289349dbe2091761f30f382d4e35c4a670ee8ab50758d2c55881be69e327117ba" + ], + [ + "2c85efc7f24ee4573d2b81a6ec66cee209b2dcbd09d8eddc51e0215b0b68e416", + "clutch control vehicle tonight unusual clog visa ice plunge glimpse recipe series open hour vintage deposit universe tip job dress radar refuse motion taste", + "fe908f96f46668b2d5b37d82f558c77ed0d69dd0e7e043a5b0511c48c2f1064694a956f86360c93dd04052a8899497ce9e985ebe0c8c52b955e6ae86d4ff4449" + ], + [ + "eaebabb2383351fd31d703840b32e9e2", + "turtle front uncle idea crush write shrug there lottery flower risk shell", + "bdfb76a0759f301b0b899a1e3985227e53b3f51e67e3f2a65363caedf3e32fde42a66c404f18d7b05818c95ef3ca1e5146646856c461c073169467511680876c" + ], + [ + "7ac45cfe7722ee6c7ba84fbc2d5bd61b45cb2fe5eb65aa78", + "kiss carry display unusual confirm curtain upgrade antique rotate hello void custom frequent obey nut hole price segment", + "ed56ff6c833c07982eb7119a8f48fd363c4a9b1601cd2de736b01045c5eb8ab4f57b079403485d1c4924f0790dc10a971763337cb9f9c62226f64fff26397c79" + ], + [ + "4fa1a8bc3e6d80ee1316050e862c1812031493212b7ec3f3bb1b08f168cabeef", + "exile ask congress lamp submit jacket era scheme attend cousin alcohol catch course end lucky hurt sentence oven short ball bird grab wing top", + "095ee6f817b4c2cb30a5a797360a81a40ab0f9a4e25ecd672a3f58a0b5ba0687c096a6b14d2c0deb3bdefce4f61d01ae07417d502429352e27695163f7447a8c" + ], + [ + "18ab19a9f54a9274f03e5209a2ac8a91", + "board flee heavy tunnel powder denial science ski answer betray cargo cat", + "6eff1bb21562918509c73cb990260db07c0ce34ff0e3cc4a8cb3276129fbcb300bddfe005831350efd633909f476c45c88253276d9fd0df6ef48609e8bb7dca8" + ], + [ + "18a2e1d81b8ecfb2a333adcb0c17a5b9eb76cc5d05db91a4", + "board blade invite damage undo sun mimic interest slam gaze truly inherit resist great inject rocket museum chief", + "f84521c777a13b61564234bf8f8b62b3afce27fc4062b51bb5e62bdfecb23864ee6ecf07c1d5a97c0834307c5c852d8ceb88e7c97923c0a3b496bedd4e5f88a9" + ], + [ + "15da872c95a13dd738fbf50e427583ad61f18fd99f628c417a61cf8343c90419", + "beyond stage sleep clip because twist token leaf atom beauty genius food business side grid unable middle armed observe pair crouch tonight away coconut", + "b15509eaa2d09d3efd3e006ef42151b30367dc6e3aa5e44caba3fe4d3e352e65101fbdb86a96776b91946ff06f8eac594dc6ee1d3e82a42dfe1b40fef6bcc3fd" + ] + ] +} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/github_amethyst.html b/quartz/src/commonTest/resources/github_amethyst.html new file mode 100644 index 0000000000..b57d66d036 --- /dev/null +++ b/quartz/src/commonTest/resources/github_amethyst.html @@ -0,0 +1,7868 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub - vitorpamplona/amethyst: Nostr client for Android + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ Skip + to content + + + + + + + + + + + + +
+
+ + + + + + + +
+ +
+ + +
+ + + +
+ + + + + +
+
+
+ + + + + + +
+ + +

vitorpamplona/amethyst

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

Folders and + files

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

+ Latest commit

+
+   +
+
+
+

+ History

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

+
+ .github +
+

+
+
+
+
+ +
+

+
+ .github +
+

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

+
+ .idea +
+

+
+
+
+
+ +
+

+
+ .idea +
+

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

+
+ app +
+

+
+
+
+
+ +
+

+
+ app +
+

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

+
+ benchmark +
+

+
+
+
+
+ +
+

+
+ benchmark +
+

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

+
+ commons +
+

+
+
+
+
+ +
+

+
+ commons +
+

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

+
+ docs +
+

+
+
+
+
+ +
+

+
+ docs +
+

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

+
+ git-hooks +
+

+
+
+
+
+ +
+

+
+ git-hooks +
+

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

+
+ gradle +
+

+
+
+
+
+ +
+

+
+ gradle +
+

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

+
+ quartz +
+

+
+
+
+
+ +
+

+
+ quartz +
+

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

+
+ spotless +
+

+
+
+
+
+ +
+

+
+ spotless +
+

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

+ +

+
+
+
+
+ +
+

+ +

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

+ +

+
+
+
+
+ +
+

+ +

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

+
+ LICENSE +
+

+
+
+
+
+ +
+

+
+ LICENSE +
+

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

+ +

+
+
+
+
+ +
+

+ +

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

+
+ README.md +
+

+
+
+
+
+ +
+

+
+ README.md +
+

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

+ +

+
+
+
+
+ +
+

+ +

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

+ +

+
+
+
+
+ +
+

+ +

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

+ +

+
+
+
+
+ +
+

+ +

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

+ +

+
+
+
+
+ +
+

+ +

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

+
+ gradlew +
+

+
+
+
+
+ +
+

+
+ gradlew +
+

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

+ +

+
+
+
+
+ +
+

+ +

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

+ +

+
+
+
+
+ +
+

+ +

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

+ Repository files + navigation

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

+ Amethyst

+ +
+

+ Nostr + Client for + Android

+ +
+

Join the + social network you + control.

+

GitHub downloads + PlayStore downloads +

+

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

+

+ Download and + Install

+ +
+

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

+
+

+ Supported + Features

+ +
+

+

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

+ Privacy + and Information + Permanence

+ +
+

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

+

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

+

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

+

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

+

+ Development + Overview

+ +
+

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

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

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

+

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

+

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

+

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

+

+ Setup

+ +
+

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

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

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

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

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

+

+ Building

+ +
+

Build the + app:

+
+
./gradlew assembleDebug
+
+

+ Testing

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

+ Linting

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

+ Installing on + device

+ +
+

For the + F-Droid build:

+
+
./gradlew installFdroidDebug
+
+

For the Play + build:

+
+
./gradlew installPlayDebug
+
+

+ Deploying

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

Using + the + Quartz library

+ +
+

Setup JitPack.io + to your build file

+

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

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

Add the + dependency

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

+ Contributing

+ +
+

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

+

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

+

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

+

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

+

+ Screenshots

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

+ Contributors

+ +
+ + + +

MIT + License

+ +
+
Copyright (c) 2023 Vitor Pamplona
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+
+
+
+
+ +
+
+ + +
+
+ +
+
+
+
+

About

+ +

+ Nostr client for Android +

+ +

Topics

+ + +

Resources

+ + + +

License

+ + + + + + + + + +

Stars

+ + +

Watchers

+ + +

Forks

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

+ + Packages + +

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

+ + Contributors + 58 +

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

Languages

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

Footer

+ + +
+
+ + + + + © 2024 GitHub, Inc. + +
+ + +
+
+ + + + + + + + + + + + + + + +
+ +
+
+ + diff --git a/quartz/src/iosTest/resources/nip44.vectors.json b/quartz/src/commonTest/resources/nip44.vectors.json similarity index 100% rename from quartz/src/iosTest/resources/nip44.vectors.json rename to quartz/src/commonTest/resources/nip44.vectors.json diff --git a/quartz/src/commonTest/resources/nostr_vitor_short.json b/quartz/src/commonTest/resources/nostr_vitor_short.json new file mode 100644 index 0000000000..ffb46ce7f1 --- /dev/null +++ b/quartz/src/commonTest/resources/nostr_vitor_short.json @@ -0,0 +1,13261 @@ +[ + { + "content": "Do you know which relay has your old info? ", + "created_at": 1690301795, + "id": "fc0e838994bb66a8249aea78e883c6e98f98b93296fb5209e9e9bab54477fe3d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "326a0f267def66d2733ae0d90fcc8cb52de711e5e93561fc90bcd9291f4cad00f135fa65470a52a1dfa3e758595a0057d6c3407f791f052836a263a95fe0f85f", + "tags": [ + [ + "e", + "e414229203fa467f99b4a20f84584e4389b9d22ae7203556d0c0da9012ac321d", + "", + "root" + ], + [ + "e", + "0eb3cb5cc57e25bf08590da5fcd7370b30b74ed2e3fcf4e909d7c03c4d3b7dbc", + "wss://nostr-pub.wellorder.net/", + "reply" + ], + [ + "p", + "eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05" + ], + [ + "p", + "d9dba0e072bdb353dfb0020de159126af47e69e133ea91bbd48e8bede37320e2" + ], + [ + "p", + "eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05" + ] + ] + }, + { + "content": "Some relays still have your old profile.\n\nIf you were using a given relay set in the past, changed your profile there (so all of them got updated) then removed a few relays from the list and updated your profile again, the relays you removed did not receive the update. \n\nSo, if apps are using those relays, they get your old version. ", + "created_at": 1690301420, + "id": "4ef323e0e32b6025b5e7c59e78f4ed0145805fbab9b95247357a10379ede375d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "83caf220a6da34754d147f844f1d56ae61020bb14d2bab38b0498b94037c0ee153121f4e0c99e8be353f096cf72bfcaa6a04b5bacda50df06ffb96faa63b3357", + "tags": [ + [ + "e", + "e414229203fa467f99b4a20f84584e4389b9d22ae7203556d0c0da9012ac321d", + "", + "root" + ], + [ + "e", + "e428765c0b240bb6fc8baa027db1252c0a98ca5bb822bd81f763730374a0be0c", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "d9dba0e072bdb353dfb0020de159126af47e69e133ea91bbd48e8bede37320e2" + ], + [ + "p", + "eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05" + ] + ] + }, + { + "content": "Not that I know of. No bounties as well. \n\nIt doesn't look like people have any interest in this. ", + "created_at": 1690301261, + "id": "d5cce4e3b7a6cf4d2fec27cecb12e8e7f71951fa0fceef56fdf1b834c382843c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a4c39738640bbd6bfd10b949f4f641b964a539977bffe5c4028bb549fba99f32737b2605936979f76745fe3b9db7f1be5b083b0b74b50b8717d3dc5b855ba04c", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "root" + ], + [ + "e", + "e673f98336da0b6884567adcf72dd6b4d86ab16eb036c1c6d6b859110b9233a6", + "wss://nostr.mom/", + "reply" + ], + [ + "p", + "eff0899a8d3e8ed7d7524b86f5a7077c1ec39ee305c191738b29b0bbfa20fe42" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "qZOB2WyD9zuBMmN1tNBhojLDTNJtmsUOIqg2Ld86lyLioL3UDNiTT7jr1D0UdxVuRuGrwaLG4dKDedXWx3s5Bo2CKDBJ0w6fEE43TCqP88yZMjjDwjqLN+a8O1/aKEgP?iv=rwL3fBSM4pvyyMJZpWVGcQ==", + "created_at": 1690298384, + "id": "48a4acebd543263bd867fa41754dcb443c7816a91cd200dce977dc6e6d269060", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9341cdb84ec7338fcf579abfaa484489aeb8858b97c3c8399d0dd4ee26a7932109b4660f9cca5809b8023a714dc38a15c745a2de51792f3773df10382405ec8c", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "AhyW0o7KjGgdrLZcI866xQ==?iv=oVtmzuGFBaBQ616f1JzXBg==", + "created_at": 1690298365, + "id": "c2763bebb1521a0d34a331785bf6d6a4f267f2fd093e4240cf309aad439efcad", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "834c99b2bdaa2a974b44907c2c8457c7ca63c929bf1f19fad960f8abacfb3f1d9b064c918ed4e7f326c88fa80c8b5d7cd85c8e675fac299206a5b9fa75baedec", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "{\"f412192fdc846952c75058e911d37a7392aa7fd2e727330f4344badc92fb8a22\":1690289907,\"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5\":1690289924,\"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb\":1688330562,\"d9185164dbaf1e36a856b8f8759f8f4fb35942da22bac7afc131c910314b1f6c\":1688750583,\"645681b9d067b1a362c4bee8ddff987d2466d49905c26cb8fec5e6fb73af5c84\":1688750587,\"89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c\":1688750589,\"4ff652622cbe22d93e3a0ce2487e86736a9a209724a7328c59bc29b064a42926\":1688750592,\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\":1688750595,\"9989500413fb756d8437912cc32be0730dbe1bfc6b5d2eef759e1456c239f905\":1688750599,\"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322\":1688750600,\"339e87a4ad5b32a7fa88c6a56a65a53bcfbea8595a86b5d87201a39b0408c29c\":1688750603,\"975d0ba9c395954e0d06a801aa27bbd7a631d9a9a8a461df191328cd1505419c\":1688750604}", + "created_at": 1690289924, + "id": "63407a41d95e8e78c33b0d88a329331968a1b6a8f0f3c8c5d1a5b0761d2d5923", + "kind": 30078, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "98e4157674a0fb090a91ffeacc8cecfc5f17df26310252b7bce000cf4e7aade32ba3611ad3ab6a9988fe0c0e951f64a1135d9ce0a07de24187b3a8ab2e5276c3", + "tags": [ + [ + "d", + "read-mark-map" + ] + ] + }, + { + "content": "TL4qxgYec/DKRDTc1GIS0utLHiswcT/c03jZvtSimCECbf1LQBKUlmgRUwQJqgAhmrFDjYD88goSVYyR+OkZ/K2mIC3K8sbYrnvkk93KA6k=?iv=Idl20mo4wuDM+NPMfnvayw==", + "created_at": 1690289826, + "id": "8556d3f5a7bb23f79f5749aefc75b4febd17877fd525ff9b5afab65854e17908", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "49edc54a022ece3f2826f347793042745be713bd4841efa1883dfa4fb35281d429aed42d2e1606077c2cb50dab1e9efd41510e16cabde309bcf57381e7926e5a", + "tags": [ + [ + "p", + "ad5eb2f02a967459a97d041a44cac14cdffe0394ca6fae48b7b354eb1c4fae18" + ], + [ + "lamport", + "6360" + ] + ] + }, + { + "content": "UKV5cwhYK7gCH5bnwoAwkQRrqmCMCfJMBeFzZw9qsco=?iv=aiK4ONfev1EUOMrB7cJ8hg==", + "created_at": 1690289810, + "id": "6fb5ba413998496d6efc3ad69e7673d91320327f6a54b3c049695f995d532194", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "aa039c210a32e919d1ccb7dbb4434265aac723f898a1729222cc7eaeee48e49399c92e67528ffd20c1a863783c64bdb1a60e8de56d4de6429f41c0b03993cae0", + "tags": [ + [ + "p", + "ad5eb2f02a967459a97d041a44cac14cdffe0394ca6fae48b7b354eb1c4fae18" + ], + [ + "lamport", + "6359" + ] + ] + }, + { + "content": "Yep. The amount of memory rotating in the background when you scrool is quite large. We are constantly working to reduce it, but a battle ", + "created_at": 1690289456, + "id": "ff08047596ad0eb7be11cdf66789b4982728d3522838aa7c79b0f173d9196e28", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "38c554b203d4128ab15681d149307cadd250a705f43cfe2c49c4bb454c0a82900ca6371aa214ab1e8e58af5bd47438f6d688b519bcd42cb6b23acf083d6893bf", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "s5pf5nJ1HZSJtnJye6/gZT2H1Ksq0ZOddenz1xOCD76rVJlKpGiXibeIBcGmmJ+M?iv=lgE2cTIHfsdJp2RIFA9kCA==", + "created_at": 1690289261, + "id": "9f48eaddca03a88dd02257a51fffdfc6e58847691e6449b46eda116826d3ee32", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3765866916e6b9ee662b28d81d570265f0e116d5aeab50aad4d2ed503c938a5c4261f6e5712c7dc8e63cc5bfa6f86efad3a0572c285104299fd9945e82555b93", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "uDIFFjFeuxLWlBZ2QBqcxf6GnfFxadrtdwciH7laVOx85iQ9XAOrDbJaOWIllu95KxXuI7SK8bttv66qQjCQ+Q==?iv=47N/FrAOij7INTY45GngLw==", + "created_at": 1690288560, + "id": "29e0784695da2615a4a83a5e55036c9e749cd4cf98324d6b4e49aa20ac3927db", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5ce3335af0da098ae483bfb758ff4c4ac355bf82721cce3587edb9912440044be28651b1a2f72fdcf05561c221c4e1c9f70bce5e4098e6095ca5cffa5a6d0e4f", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "NwfzYhOIbMzbc6dDEbnWWhgPEz89V2smVNkOMdc20k5QKjQWvE83O1UGviFWquSsgGv3GQrqCCi4EW9IQbhXvg==?iv=1xfL0/+De1Tl0PVON5sTMw==", + "created_at": 1690288549, + "id": "294991508f9fe42234c3937d85db77478389a28a2e1c8817c7be7189dd452fdc", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0894f96fb95f158e727189cb3d4e0bdf59b4b4ac065e8972e7b44e94b0816a8330fd8175ee2a52260231fecc81b5429c4a3418f926bc5c548c10e110468d67f2", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Uisi4OKioPOCqI/A0gDNKynLNV+ej0aNCY70P/vjgu21hbMRD1DCw/N7sRO7PeeE?iv=QNIoWu01HPHSdRUdeQYf+w==", + "created_at": 1690288547, + "id": "0600aa6b0e40e5057d6ff85b4d0eefd86bf661a109a0ea5e30a616f8a7509eb9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e1726e0ca65b41dbd13d6c46248d6beb9f7d28eb8a03a50c6f7ff55cee77c85fc7209a2306fda5b849c5f3127420c862d5849b0c5695d48acbb21c5ac651f8ed", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690288395, + "id": "ac2fb0c9b72a6fefe60262fbce6eb8740380b7f964200cb8efdd2e72fcb1ddb0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6df6aeef98c21a0508a788cbe6a2a6825e5cc69e57dd843dc6e6d4ce2c28dd042947723fda3d8b24489305d86b1e81f9f66b390d6f7dabf9742cd3775e17e53f", + "tags": [ + [ + "e", + "2cc5b6e012c5a64fcf580fc1b53bed25ed0d7f785fd896744524b1a114dcc86e" + ], + [ + "p", + "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720" + ] + ] + }, + { + "content": "tRYUKww0df3YH/uVQVDVuZMuxAwVlPxkCInhfjLdaJcaLOaP4nanBVJSiN/Mwd4TqDRIiENmB0VFvfICFVuSC9JWVRA5RXKwhNUvJA8lFhqyYZKJ7hu7vIv/pBsYmoodZkmIOm1k62krq6xcnMab9l5AeqgAGyWmPOM0nZM+B5LFp9s5iqc5uly52wpT4HFrbNEO7Tnp7yg5Zzrrl6wwUcmvgMf9ZdJHUusgy0y449WOglVIZI7CouMgjH4tfxbTr7jpZEb+Bl8PPMBRylEV0qniBEd46TAYTHZW7QOJRnSP18vMfESf+kjM9KbiQJhyOY8FKIMJ2/ofUjCiOlWhlME29JykleKz70KmsrJNA0bQH3MfzQ0M9qBdvSdpD0pPIRc9hFmAmPuSOQpb1k/+lF+lcNQc2SPdj3NOS0L6BialMn8fwWtpuBHFtdRJIj5WrHpgnmXxeZ2ES40KZUwA6izlW+YOYI8RtlkqxsrmOWg=?iv=diRnj/9ih+6dFgT9qGMIdg==", + "created_at": 1690287796, + "id": "856d60b6545084c4d8a450d8f079dddcb3bcff54aed5df0d84eb63aec1f9ce6e", + "kind": 30078, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e7e2a040ec7c4fa8a931005b3fb2880c927107de970165d4869a403a2b3fcadd858ca4ddbfe6305d15b208fbafa15f0341217af5cb6f965aa4194c9b8a2c06a3", + "tags": [ + [ + "d", + "coracle/last_checked/v1" + ], + [ + "client", + "coracle" + ] + ] + }, + { + "content": "I am not raising money because I can't make the numbers work for investors. But we do take donations to build the team. :) ", + "created_at": 1690287547, + "id": "8a359c03413c06340f034cde37528c4a0bf49cc2b42e571ee806c86abe93ff1b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a08449b21dc283a0effc8a0ee4ad3254ed1905e0e5f53963b101a927d05cd8b7b5718d04ec9909e34986d743e5db272b0541b6b86b1749696f22883b2afad7ef", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "root" + ], + [ + "e", + "4705531ded4c3dff81ff3435c28f2112cc789593fc6afe6d9ad2335b2619245b", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "ohhh good catch. ", + "created_at": 1690287166, + "id": "ffd37a3e6504bb5171ba0c201019628dea6d282b12c71e3a7e35e20ae4de538c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8cbf2035954db635d65b039d549c36f2111a6e8c5c831db5e5a3eed0260009462660e8d0d3923b48141f1b35366854890a3fbda91e1360d242175cecf0a96a7c", + "tags": [ + [ + "e", + "56887d01eebd3727ffc37ca54eb67b6e8427db4b03e7c003683931f5cbde82c2", + "", + "root" + ], + [ + "e", + "8154235a2796bdab6c7c0cc0777a8cc6fb6bdcb19607a5d1f901d02494648743", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ], + [ + "p", + "de14fe62f97e09429581f9e8fec3170f3ce5e7936a2134bf70c87c5ff229e53a" + ] + ] + }, + { + "content": "We don't have server. The main issue is that now the app uses up to 1GB to keep all the content the app needs. Most old phones will definitely struggle with that level of memory use. ", + "created_at": 1690286996, + "id": "5b3a569ab61dc0d88da2a93564de9da3e6f42c8479bb93a522370501ecd08140", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "515101902a9168031260d2cc889bef1a1e6b4e499397e7cfba08799b7503b81cbf3e4a9147a0cbd4cc9fcc252a66330c428e9a6ae4dd6751c551cf9dc1cd6c5e", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "reply" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "+", + "created_at": 1690286957, + "id": "0c97361806dd60f9b6f5a0ffaa25da846d5e4d4717287551530b665db9d39302", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3770d5924fb47cbad974ea6be874dab133a90176e3a62502aff7c8a1ad32ed39a29387c88a1270f78a2d25e01cb926f6fde2b0f3a63fc2356b26e5295b03c3ce", + "tags": [ + [ + "e", + "4705531ded4c3dff81ff3435c28f2112cc789593fc6afe6d9ad2335b2619245b" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "I am not sure why you would say so. Any event can be part of a community, including long-form content. ", + "created_at": 1690286892, + "id": "f65330aab9339f7c814b763a571c02a2c00de96e321e803a09e08140ec941138", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5f391228b9a47d83978a304f0505a16b594ffe48fcb872e8bd69489557306b38cf926603f955e0ed742baabf59dcafd2934919cea4c600e285eebf239d42dfc0", + "tags": [ + [ + "e", + "9dd8ff6c397f19373006037c894b02f35da2b69ac12f62ec008889c9571dbd08", + "", + "reply" + ], + [ + "p", + "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720" + ] + ] + }, + { + "content": "Well, Primal and others don't load everything we do. They just offer the main feed. That's the main difference. Amethyst can use up to 1GB of memory in heavy use. Many light compose apps will work just fine. They are definitely heavier than other UI frameworks for 2+year old phones but the issue happens when we try to add the all components we have on the screen. ", + "created_at": 1690286508, + "id": "07bdab60f70082150f6b50c8f9166244f7fefa4087b95409328c595cd17dce53", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "993391d5757ae264346c8db656d4fcfdec396a63c780d012e767a608087a673ceb262b64a9c0bd12ff18140a1d80b79645759e019c13a969e681092e6c0537c2", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "Probably because your are following a hashtag or a community. Those show up in your feed as well. If so, there is an identifier of the community or hashtag in the first row, together with your name ", + "created_at": 1690285153, + "id": "aae6aa51e943bdd8f9f09b2babbbf02397ca2c7e9c99dfa0042876ded4073ef1", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3b79f668ec10f8fd8d2c7ae7a0a075c57e764440a4d0cb92b8a823941418180af07577f6fd46b9b8459c6cb64151176abea129261af861468fd44ecebc566847", + "tags": [ + [ + "e", + "7e374164ec625c01d4efce693a8d7a13225fd87bbf4a439390fd995ba077b038", + "", + "reply" + ], + [ + "p", + "2ebcd815589195d4fe00e1996323d41c3a455641d3065d7b82e68e574f0c0f48" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690285082, + "id": "6b4c1249dcbebb07e1642ff72384a709ddaace107d98b34ee87028cf2ee151bb", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "71409c88d13a42740a17249bf2a5300c875b7a8fad471fc6097b5de1b9234db339c1f160d4156a661abb871a7e9f97015048735ef44b1af3c95517eaa8b143fc", + "tags": [ + [ + "e", + "69ddeaa2cbbf8954e8bfca9c58354512461de79691e0e57870b40cf13139aefa" + ], + [ + "p", + "e6ce6154325b54e69c9478c46cf1284e093f790e658b6a70f9df6eed275e3444" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690283168, + "id": "1edacc7f9f091e63b8cd59498b98cc28c997b4381a343efbe67b5f0010f594a4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0c20f045b2cf70e5479ae1fea72bd4f766eb3cf5bc62a544466d907c14f212fcac3af581d4440f4c2d99dcb98eadad206e05dfcecc984aa940a133226d2401ff", + "tags": [ + [ + "e", + "6c766ae83204902ae112190f418eafd35fc977f8b64367a13d7a526fc86bbd73" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "I am also doing a local database for the app. That will allow us to delete most of the unused local cache. ", + "created_at": 1690283062, + "id": "3a7b49772ed9223c3d5ec1d0aafe05739aca6b4b0a87da760f78d820a678cfef", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bc768ccf0a6525851f30cd453bf78d1d26658422f56d08ea216132d15b11b773ff30f7612a126f14e16cf03b29b8d34b487a6d59f51802a58ff834af27617a4e", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "It could be the live data as well. Live data runs on the main thread.vwe could switch to Flow objects, which are heavier, but don't switch to main and back all the time. ", + "created_at": 1690283001, + "id": "cfb933727089ceb7735f9c7794217338f354cdd67421ee4582bbd108b8b875a2", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9c5e74d3ddcdab7244866c27a71c54d78f82a95ddb74764a42a08a18c88299d9eae6c02627a68e8bd66ea867c3425aa4b6d2e272eef28e41883aa6b805b8372f", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "", + "created_at": 1690252421, + "id": "e17e96546af73ea49281aec14d8833cdc9a6c8758eb499eca64890d5e16dff1f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "01e3edbdabf4d7612977af8de5afe3f8ee53c0fd63bc211f2b7cae0c9e04af11ef21449a260bb3945e16f6999c1975d87b6f8cd52e74284083a90716ee3814fd", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_902a3245d00d506efc193b25f25f32ee994c365bbd73a515.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c8d8226166e4c54ed87aa1151229301230a0b9936ada748ce2b02a3a2fc28cbc" + ], + [ + "size", + "844802" + ] + ] + }, + { + "content": "Are you using the latest (10.7)? It's about memory. The app is using up to 1Gb of ram these days. ", + "created_at": 1690251933, + "id": "85094e157439a0ede8ba0354b9be60aabb4d952bc3baa5838c1049f1e1f62b7d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8c43251aeb9eba5bc6a265a8060eb24ac4e699661eec156e33c1cd48678c13442d15ed3f29fdf6d3395317892d11a854853e9529dc0eb4113fd1a23723dbde88", + "tags": [ + [ + "e", + "5778111b09d78ecec02c9b7f52feccd655b8fbb049c40c380c03eead049bf7ea", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7a7cfab852cd457336ec2126c2bcd8b2c77d569054fae53009d1f4631bfa1448" + ] + ] + }, + { + "content": "jKgFvim4lNx7zC2l/93q5rHvdracu8Xk75I0FQYdEba+7pX0RonHyDQajS3aXk4iksLl0NYyf0Ky68+K6MlTRerRcFnW2bKuZIOcHqlw5sG79MOy+m3yCawj4jgPQy04OkPv8zK6H0wvQncApLFZlqPy5mzP59tW3LNkgoAoR7xG8Zbup70CGHs5LXuCD6Usxm0drk1Os5x3WtTXfssP7pRIZfTc5SSCDyVz7NHA/euXDNGcv+1Ru0CdnVQAv0Ke/WSuMGvW8ALb2hMkh2QRwZeRwQXLQnCZKkA/aK91l1TKjLR/P/cubbGJwdpQs00bdppd5OaImjGmCJnZfPBcVfi98om+9Z135G3mjSl1AiuQgVk1RpEPvQYE3yFrkWjDOq3vQSohGyoPDPzyQYW5vweFqmbp+ON21F4TQJBZ7pEwKXmF279hMi6vqttmsAuzLzKmmLXnUxgzI4jDHShJrAdjt/FQNsVb2JFzqOAWYVQnvxSTYhGS2wLc1/vTNkHcVWsIBoAGtWrPA5VBI6zQmI/CzmczPAOiTK+l/k+LyluKeKK7hR2GFL6dUmopswOTiSQHAHuJ7CtmwT0GnKFKvBzjWgeQ5u7H7W/jVfC1zB+GxZfa3auo2kCSIg8YfwE3mkKEZ9tns6cQ3Tgoag5csfGD8U3dJ3Cpa8VmTUXtn7italkQFOp5UUeqtBVKo0mYSMJg5uxf+ZC6P1oRsAl9g7La6Rc3t9DpkVB8mTS6d+0mwaKEUnn3NJbbzd4pecO4LOF1zid8At4gQQSUrUug79Vc0VmwYfltd1njirgMrDWZuM4G4RcgYYQ3i7ZfsP+fo5PZ8kVKBO+S8bNpIf2GWolaGi7IncYh9/PJkz1dn+cmpBwlBbAmKTUjxwaJErlBi8YvnYeSEL0o35d7YYEwW6rkgz8oJfsqSguBMWgJHnMLkzSWbwpZuB202dp9olsYOVjPyQKwSvgbukqtQxDqRDHg0gr48tS7WSZJXhI1gETLKg2QTgE6hgXGQhNhPAE22AT0AhT7YsWks/6+wuc6AFEnH+/PnjuR3N89G1hTjV6W/8B4wQdYBeHxCwWlNnCfXUIZnaKN7PXGfXJdWUl3RLXO9+Sr3CqVHjW7GrOEmEjGu1/iUehcxnk5Mfz1vNbnPVSe0n9dyBEoZYFi6+Tkk9MWBzUenniPIJkoFMR5vn5z98GlhxYCafG6SdXkZDUkHjnp2kDBP6+h8LSA+qwa2BxPyGhSleN9NQWC82PRYVtVtI1w3l3gCqnXLyQnidHY5fkkL8gmqUOgyT+FgAT/O6+a7mhyvOvYZLZh2Mt0iM5p+/15d90VTq+mC9eltDdBsa4esj/bxIe0rhGOlvcB4IMhYRKTJ3pDvTizdH7Ox2jmXl/kLIbAEsAzC45CnJJo6TP5KjzeJa0kJu6MGvuE34VjsGWiOb7Q9CTlsvBlptA7n5WhXEjA42/yd2E0Qdbp/vTQZ+lHnOLn3QTCG771btfc5hXep2Oenmw85Quqzin8lgxuEU9ysWLD7Kwk1U1sI3p70dL5m2EU8xWm/dx74pAU3RjjwCBWZcwGwoEPde0FAl7NSTaudZh8BJ6p6BW4GP/zlHAVS+6RwOhDdeRFBOGhuVSUc6ehTYCXiRdYOS1famN/bGK3NlzP/1m2qJ3tfiZ31wXJoBWoy4O+DxnPkjNUhk8sr2uHDU+ILu3Ut5D4fLXepKYZDI/2r1qxxkf1ZuoDYO+mz+FBGG6Onf3CE184Vmgp7ajjCJ+wa1FP14utnfj0wQA2Cn7G/Fy+pBAbrJXiUDAOPXKt9iXwni46yY7Akfov5vVPnL059YEGVByfwDBDccTIkcJL1uc5qnLxdSZiQyjKjhH5v0V8x2z+TxhR02luWJnSVHLo7ltRpkSLehNAuTC0Pou5HUeVZ/LmVZWc72NINUlGDvJ/hYJf3I2ozMrffebSHXX9B34fTX16MCS/KhbAqVwk/ucV59fkmKOuic2sSAdJAV8f2r22jy3lGR4Z1L0SJj8k0PZUq3HgGw0m9lWaJ4xmS4BjAzuzjpJ/UoWXe4k8zJRKWb0p736BBAYIGk5aTUBKC1AYtk0fl+avgYNstvpRpDp9LH3Cri9OD+4pxruNRf+vT5gfOo3AoDmUr186Ny261zK0WdvO1UMfELDYDsBXyWNOq4ZuNGjwKTMQbMK9TlZ+fu+lPrzNIhoW5Lo9QqyHdKjnjaK6D44CLJQELb7/x5bEe0LFAlX3K2jby6/8DHJy/WjQtlixBba7G6wNeAtI5FZ3m4uYvCFK7hA51c2oxDbYu2QOmsWfqdQmUP5Cn9gWXIEGWkXnu4W7bxBQk5H6fB3QxE5YNgnC3UGkxk8vWHnkYGnVhzfXI/LPcQLi6/8lEdLiOcjQLbdoQckkvgXjOucoL3Kcy5nYrAz1Z266ksLrSvd01fw6uswVRrB9ltWxk06J4PSeoXUm/FOhaTZB0mia91geu8VBDXl+nHhINbbQ6FAuGLB7dDJx8KDW9PbTVnXBQJ2M1WvR2oNSxVsMmhiL6ojYJGLQG2sB8fCQPwyOZE93K+cCeQN8ensuxYvJliMaGlxzb/9wrGd2BsgIK2lRrYCZLv7P8a02m/BD78ekYGVT1At81u/d34OYS5vtOV6dkCfDRaCM0t+aT/JxusTF/jCHpjV/Mt7lAGS17I45GwkXIudaJNwUEDmaAO0TUkGtMBYphdbp9mhu6Iw6dFTzS2VeYbpiDkKmhhwI5/F+9dzlo7ffIQDJAl8ih7NrW/SJzs4cpSGupTQ3975bw2aYleforZMgg50kcjC8FJxDaygwZtxOZ7Ux3f6kceIbftpAx3u2KCZg/xa+sM45rokkceUoBp+8gNerJjAhY0pTfaCYFoNAGiYaxH+qi6Y7GWCKJLt2aVPQEjmvzbExPGohGieJsPRblfL2cCUj1/JNRdC+EOGZtSOyfWDCCS+tIU/3eWiHYXVtQjbyoBKiR08kLBzFnyYRtncplnWnVc8WbbXs4WABQbHR3ITwLuOx7qLZIvUCG8F8ZI3tLgdWEfALBFnLkhioJsDMPSER/YT00zQZb4U7lrjyqD8bCz4WtWi3NXYJ9cKydoc7LD63h4YIeoWHKXo4BXEzyeN4V4DrPaZ9EjbwkKqfzZke2KsOV9bo1TN+X7DmMxStEW5kT7D4NUlpJRH0LD4zMuZpOUr88eX3rsUCSA8Pbj9qU1R513LKmE4gvzpBwgDcINqNQGGT5YBfYpThiE+osR3dsR7xoN0aSmmHg3jG3vDS9VapaRp0v0ZsMvXVlKwvbWKSkrBb141gzewjoIs4OuKyszi/vXq3ZegbA6vLxqiP+NNHKze8wLlZAeFTB/4LZ/RAfk7WJVTfwRdxq/5khhM8FnEEtrG380KvAWwZ1iyFJoxQlZRB3tQDHf56YxI3ge10Mh0smCs1PR9LIraJDtNpp2C0L4lP7plp604rotOjdC9DkawLippygcz4vSyI92yxYm+9ve6iZcCdow0/oMY+xjDqxOJgrbKoTWKPxKYKJZky50t5tmdPYl8pFgIUASOlqiFZnve55Jb2cm+JBDlr0NnkxV1+NzOcRZxCKCW1uJcIxwje1M8VyDdPmUMqjZ7hMojWqkrYUFygpPagIAIFDBfWfDqTIMp5e0sapGEkpIewr3XQzAlJ/GYxMnwR4RQmKcxk5EPZzA+VujPWIxQ1leQc9fdykFkDjjH0rkJnUmuNGIHOjd9PLcnj9eAZe8pE+SdBVG4SH4LGoz2EGGBubOHKD+JSPwe1ePupLGLY6Peh+v67gXGFpoykP9mS6t5JZj3NCssq2F0pB4lO7nPt3eTO5eyJ6XwTq8L6y8C9ZbjqpyZq4Z/cAn7JJ1pm1vLaaqeMTXGPoS5ubDqHR86+MiJ78vWLhyImNIiOSDpBnt7afhFeiSttOvhdodRdivB3e+aOdMW/tuy/pw2WLdSYMvN4cDC0Uizv2dxcmYaXiZhNaTQuGIHl8xTj4nOnK53vDXaTLOwTFURD3IEop4KtA0xlufI/fSpuiioJLnDizClkkc8Tg/XL1juIIArTMaGkgTh5gedD7l4heEZWhuY2A97TGpPazjytXOVAI5LHI6Ey1Vvqxu+ha8gLD+fVVRbTfYFGhjTMt6RNVG0LLKndwhpJXIWrn9mG9x1KX956kHXfmqoC68ex4jDkBEPySjATpiJLgiusAzwO6EMzHsNZw3h/HKaKooF+b+teNFMzoR1lHiy6dB6dXUIkGdzttTvBn27t7DpiAwMwxMQWEqWnqVfMbiVyf6ohaeFX3P2vmXKs6Dgn0ChTa+ud1o/RmDd+mT7/ybaNdhwOEgRjipzrHY5aJnGyauU9oPd0q3RCAWp7k9UlUk+c/WTHBPGnJ+X2WG/ovx8re+j0+7VeeRlpKIy4l+koywFhWlJD30fBMhKC0k30m+dwPwEsD5MNMqGuAkjUoKKMUAizNA8EGs+fMu2+DAVD+40l80JV//EN3PEx7zB/RvRNXmNlLKijCRbM6qyPmvRPOB3ShoEpt21buskMgu75vGSt+jMpnU5AALvXDwbRKcMMrz3KXiptjU/cumda3JBsSRtxhxrDLlJHj76L6qVWOnok+zrRu2rXeKO0+1KBfRGxSPGmPLVY1e6qlvCnH8boUHmUVz+I9BzwlgTlvuMtglmav2II3hkrskp2YspPnRFenHTyum9EfL3mcjO/bMfCR3eAZ0rK+GMZprKiFxXwFvMi6nIQCKWWI3jBsC08DNgXkVUdHVSLDN0TefKxlp0iomxShQ9J8LbDI7aK/TUsORjM4pA3fBnHK/2u6bI+4QXPJggAYWm7852sF3kwdvYwoo2ag7JGJ3S/9/cqvapMfohEdiCH0aJfquysoEVEZP2TsalEAAWxi0ZmwoyMmFvblZp4Fjfc1WGGdnsG0yssOOJBxXjldGl8bCEsF5donFY/O3OyEFSMUMcIdK7uC0+acuhVeHGn2D3eGax66HAclvrOwCyzCzKUuy1iXTYbUfNohw7LKPC7mxJwDHDr5P5rS+fQGWizEUMNLlOYkRinPaCvUPOEpVkQdmMkrTohAiZKVl2EsayIrdMyPn1yhMvzHsNI9jKZnFtnZg+UqQxwyTyn0wW4PAATbvJIG2FJL//eoV4LSMlUJLFiEvzZ/V5ZyFm1rmlq6g7zJ5DbiBiN+2vroTM5+fVa6IK6BcNqVLEay0KNV6y+5PR0irNX5atZ/nPeZe7/bojOmwihr7Gf/ZTDM1wvkld3LaVhyPqfBG/qm7VBV45PSX2NYVehdS6cIvnsJY8+KDq2l1PC7xfC2yfGGupizb63tPXQ+qDBYpxkletE/Nw5topEzuf0rzB59zR4tg1inRseUM2829Ncw4qOaVuN+ngVGBNsqHw4HB6UBEYWfwONJ/vnaAqJoXP2uaeBHUTXxt/yVK/62bHQ+utTpSS6+slHDXYlnyeTKrNlss8hHNGYlym2i5vA1lyA5/wgCWN/+sD+xWHKAvFzW2X5HYXDmCWFAwXDVFGZp9It4djOxgPGVNdVDFYlkfRo1aW/xAwx8o+OmmrX0SiOx9F0ygYudCuQegzZHQ4EQtDyhOzU9nerDOUzOguSzEwdlyZ4GyeyMNMhVX9Qc/s8OCDIFIV4wWOo9R8KWbK2l7/EXkpWjggfrZoAswVpwKwrMrA74svNLKSuVly/UVfTNBf3D1aLg5IXvvr/5Y203698EkvB+OmL4Dd4X1SQD3xW6UEUrTHOmWSroJ/lxihx5SswDWj9LAerClMAulIOBwhuiv68Q00G9d/idg8qo/W+UBwtXB4ou2VS/ImqKByv4JWZaA+oUiCknmxU8afNe4iTYb0vomJE/DuZcIpumX2m/622sM9OTs0kNPmqrWw8nj7nn1b5UkCGc3Jbc9dkVv+aD4+8T97sck5l09tJY7IVcgnFUQzvPOrWOc8V825DYEbqR59/t3lNNkrxU98UCCE6888fE9l8kemB9xvXgcYmcBwEWLUjSARZy7W9HfecH2j3VCe/MLoOeia8ZAvKHHoY1yF9V2w0BjxRTH6tZyFlIEAMC0+pbs1KV9usbgRnV3hFjphwNx8gO0CKOuloXsarbEscbhl0UKVNlac8EHweccsW0SvXLAQo1isAaXStmGpTrV8ZZBGACOQyiOugBY3N0E7PmPmgMHaRx5F6AjEb+YFB7/H5rP+cc9/RShmngnkeqi69lEVuWd0Dl/rjUwLMMULeV9eW5Vc1NXsfS8ILp1OGBTuHEnHJwsMeP11lYOpx8LSeMnhNj7s+VafleReAdUW2ZfcF3JF2maU8pSUeC99Hdns6AzGs/d0hHTzhjz5frQFPmLLtMPRbARnCmVM+Hfmf5/nRRZYraT9h5Zld36AMxZV9UEbE31EqM7vd9aPb6CzVIMGv+XyzFW56hrLehg0KR1R852rBuy0dsnuwz4Gby0UlPoWILxhsRN9o910XQxpIDXx+aZ16LUpBr5nN7X24/AkNilCbrpBbxO2iDX/Ylnx2KwKnIKdQ1ounLBoNsMr6PLrk7s7RScVGDpwYuOqKbxRpHtCA5qTQ8OVNBQ50nRHftDTjjw+8svNIL8eXuqKCsbNf0JEW9ATW6zK/tpzJcnJ70Je9f2enkHoJzW1TYK07dzPf7Znb51g6ramAlkpYsmhL3uV7n2TXBEGrAi2/ajDu0ItidXAOPyEoWM40b6T640qWU9LwtDQ/AjrqxSr4DaTOV+4eS+pqZKQLZN5tqQnUpK7VRbHZcM0ej1zL9hZqjXF8Pr6ySNeeJTzJshlnv+rGlO6NC+JI8PIuwp6jQicV/O4CqpdwLpmlis5nsmP6tfsb1xHhqd7Vdg1VDBkqqAYIpOS/4w2zgXCGb/T4AEEQXWrVMOwxsFz2mQd9gWK5H6vD0iq8ZL5XCK3ZHrsc8VWqu+X9mUvp2TAecOEUVvK49E15E1YwUww3gzgwU1H2cLsSY3rorMCMoKm6+uhK9opuys8O/A1bePTNXH316EOCU5Q8zBB4QN2PU2CDovOrIYLmiFuZwSyed58nJP0Cy88OmE25+zY7Ou/8mkhOItS5/lAK92LElgv1lR26C1YUCOulpQhc3qZYj0iOlaoP6aBhmT6O8Npf85GLzgDKwN6/1D+22O6yiH1VTCjvE6CVIbsCsaUTs5sY+3p3NWwe2jAnuamHUX39kAYfJIjhOBv61IloBWPtw++ygfuu2uUeS5MTWEEhnT48n1Z6zJMExpUTqSvU6mxYVDrUNlnPsIHUxlR0+/GPP1msweejeJnfoVV5dG7D/jIxJ0NRuYOLttlNwBFEPC/mOAiyc6Q1MdSx+P9SVH6nQOudB+IrHPDtToFKq4z6PE9gbckfOvttlODQDHQHp92AtK8JvQoxD9aA+e27UALwOHjXzUIpy5UcZGbH8rs0WSRE1wWIjP5rBMCilOhAB5tLi3b42sZ3N2qQqIU6b1kbcMiLw7VWXxbLiCpWUHXVHO0CE6ETzrs70vR45PPsMh/Yw/bn2xNuGBwwSk523cPmtsC9pnLGit2elA5FkXAzxXj5bCIhSMFC5C2whYi/VepK8RRcZDrohCNLbSKtIUTkV+42nWv8q+2HHb15QzY8lIxqrMC6zXfxj8iVZSfudu5EyvMP7cP8VdLD6GAT1Rxav+C3JwOA+iE+4gOe8mqQqDBjuwuvcC5g4mczSxL6r56tLVKSioNd0WreWQNu5JkmjHQqUtYIQRrLVtUN9u2HTpPY29C/dORTtd66HIPdBzFTVSkcTYlnZ626g/vzFChpq471aTZutyTTwpSUqovogIw3gkDrwqB0cXRKJORdQKguCjkw0dl3LYA07PrL31/hFZGVuJYNBmRTpN8lj78M/IFwMv1WMMNjo9e+P55/ruw3837HWg7icnCuuPtNh8xV6V50tabrsHXIYSjmpQS/6IH1p2/WtMkfEUrKF/c4tcZb42PB3c21d3DCnIMF3fHohX8XtTd+QG1ZWqdAM9OlLi685MRUY1wpMb+Woq0gp4YzP0gqFsQK04chxH6nfVCEgj6Q5N/QD4Ypy1hBAbzgqbWwKQ2E1iFb2nL6hCWaGEWxiglD1KGkgiP5ha37mMWGMPyPJq4mG+OsMZVI3y4+p+vFS2RXmc1pKMCIrtzqqpf/YDxHNIqKbGpj0TykSHPJCq4uReQMeaRk1tNFzv2vafgct65omdog7crddG8tzuRPOBTEQ2oYcSDU+11t0i3ild1hAyKtWNHxCSXsQvakHimWQGqneABJmAnuxCI6954J6c7C7+TacBdHJXtwssf+5mlqMPMpmvOQF4GTCEPhAJlNoXgWpdvyc+ZkD91qhTugDu5RBSdLXGmqsG9+sPEe/DNc0wlmSPzOOS4lGOjAuHMWZPyDiUD0FCef9qKTzJRcsMebFWud8V9sAzviPv5Q2KOtOUHEW2bfy5GrEgRhqRU/c4BTmgMGTrc8fG0OuAR803WLuXhpPQ0Zkhr9h2S9hOzKPUhzUahdWK25PURZfqP9TfCA+vHMd7v4inSTHvFYTqM/b/EQ0d2KPNv5TcQpUzgvVJXCZOyQ/3YRbVyVyRprKGUNPfFGp4KEc8kVtNjr02EvQoD+ICYGx+LU/UPxkNOlgBcqVoK8pbvwkCwIvI1WD85uLI3Yhvk0KHOOhk9VoeCPFkQvRjquFXA9RBjoRT5Yno/sWUJEUlun7No+xiQ72y0kNghzjTxXpwLYqK5r+8uTNqQEhCEyvT9kI8tdtdbEeFwf/ESf7QfB7xXGnrmuYhNJSnmx51ivXNzVmAzqR3vmZNrvSBUGlmv188s/gDG2uqczKaBo68jqmPUQNQcgGN7H4UJ6IN7jHU8l1ZHKUgKui53QT95a4wVoPPysTZQrXD2CRyH/Yu8DAfAX63Tgtxl+qKQ58cbWwvf0UB9wBG5x5l5MdJuOuMFuy4zSiut7fYWBQnXOi8S4I+at6xIC3AMmSx2vR54za/CtTG/b/tkD4b7mD+ED/TxJfxmCPcZohW6+MCLQmNtqVXh1ACHaRzf9C5J85uM1IV+qcmR6tzki0gI5WcYZlYEur+PdlcculzYyFHA8/mLrFdrVjaoKewNJvG6VKgeZ3GhagNXfuc6J18PAknNMLGWjhXOnpOUV0l3j6XNs6tiFL6Ye1UwZ3DpajV2kA2gipBfQFkWS9d/fJrqO2rdVR0gcTntyxPPrSCcAqFjbymUD0VctaJc0ZvuVNt5r90v/viq0l3SO7WuNFu+cWYmKckVhA2vON7ZDAe8/lIOQavGwMmQefYwjKT5AZwlZULJ3IWh9OcOAp0pq3Jmo5sq57vLgfxNLUVUXF+UZ8Cu4JwB0WFQZD95zK4aG5XKk/vEmv6YXYFR4IBPppTuQj5O3mS2JVYZ6WrYui6PzOFlqbdWMsiJ23XF84L02x3Qvp+BnSHlruIP+qXyH8n0SST+oOJGa201LAdvOZVpuThygd5UtJxF3xyaVzfJlQuEQqt4FYBYJgLwe3XcEL7UrLT4CMEnJciNjAQ7Ud4Lo+CMqot+4rJKF7YwJEiKSPClUO0G2Iz+FcO8U3HpG32h43YQyDpcD1dx/cxEcqvbzg4tGQssO6IBP21vHGkMxPR3EVTyIaZG+3Xudnl0kuRIeaHfVM9Jn4BYi1/lNpsxlOgyenveF3Q8qMawiwfM/vJ202Y3sMXrweCmli9++uMILZfh5L/jNlbJ67ofKQIWTr6cd2k9EbhFVtXJmeyDy9gIzUguEW7leTUEQf/4mY1UOhKDv+2sOEbnACFu2PsuGf7EFEe+lIznutu0zdThw6IpJsnm9VKjVsIQ7VxzsVDcOV0UwwaGlzGOn51drz5euVdy+EcBusUru+pETFP0YBdMOfOI5phZITdNAvhmOJHT3dR+YGwj9jBSEjByX8FXMKAtBB8z3R1YRYcS4p1l5AKdXM/AJNwDmDyI0hiqpbbZsdCJwg/KfenP7GvKhFy/wOSjkJnQpS1OhS5EAUorzq23dB3gJOA+Bk3yxTnwkiHZ30R0pAyl8+G6ArupfsrpaciyEqefXgxEntIc2eerpPqVEKJ25JkYDK4qxZEJZuY7H1vW7nzrqss4VxIaY+Y9/hly9uY45ugHU54y85jAStCmEbPeRZq+Tq6BPglib7eNFnUO2yyzVh1csaZ3wItksOhpcV/k+4KygnY/Atg6/KQvH0kH3DF+YmOuIEIhCBuAV+3RsSuSGgTc7GCLSvZIejccQehXXrk4pk3AzvgxITGNzRVEN9ASslb+ITYLZRo8lnWIhYvic1tUVWe8eeXbB+i9GtdLWxgkHHTDDV2Y80S0XBI2qyIJf5cMlCdgoCG/RXR+m4r/mBgNm/TxUgG9VJqWmPJwUJydPL2OmhKHCuyVhIiVpFGUm4jz8+8PW3pY0CBrFi3rbhol6yE6+TwGlTdZGhqR+Z/NetN5fxT68d2X7xEnEi1p+UyseSCvZhy8lMBYGvOhdqDAhe3zJbgvq44WMlj+N80ayr6AuZE3YWS5unb1mbcEX1iLaW0szVwsQMVhO2BjHFLQfQPMAgZHGmGNy0GOipyaFI0zvrrirb6JzlOxULf6I0Bi1/mPEOB3jTxaTeAgbal/8R1bRJy/tQ8cZWAHFG04RSz177DTYLeR1J1aYYOcJAQyhGLK6X3ReXB/gjTcqcllLAVIvTiKhkHytL5X1LOxkSJRdXN2PPFhDaULgDy9AscoJkRQiixfxn/zQeDqy/FZw8asb7U3lVRYvJiu6LfkNDsSMFhjNsiZpzGMuLOoFxvPIv6kh+m4vJwbuWOc+7shLOfw6k1Tc0iwSZ4ReKNNdDxf1Ir8mWFI+FkdezdXZX/1EnBeROT90DZdH0QliPVi6Y+sR1t2Jyhg0V27adwQ9+vkZqHFjkVcyYzZ9CO5hy4LUggdfs6JBXIFqHfJ6A6gK0QtE3pSqBAbxxBOuS4DyKQtNzixcFJDPztEdggO0jfUxQp3ZFOJMFw3QZG83g0dwGjt7Wi4aVjX53mUt0SHLEeM5bedSp4UDKQpXuCDtXrRv6bBjNoqIloJik1HHqswRneAAcIXPIahnQRT1jDb8cIJ+W8hGLCkvqv2tvvmnkcBKb4r+NXcUezsnIb7Uk1ovHULXIPgyE6ODCg9VNPqxdg84YPGsOBoKacXFJKgC7Zm1PCOItsksRJshAGXf/puuIaRNVTSK03Ulj59F0+/wfPbivCTasvt/FQQhY1sdN50n5opNd/RHV+bnfKO9cs2WP/3UdiuWBvnnTf5ylYP4Z4F4WDll3w5EJzRwSKcZlGdVwKFlL/fA45Xcobi84ooWMYE/E9Wro9+5Db0omdNznIKRM63zWXAU92cJZm8IBvjeK2oPMM/xGMmTb57X/n5qrjcrOZu5tM28mNnB5IGns8ZuXIt4U68/I8DKihhbK/TGfPKk4qQk1Z1gN+6ncV1SmleVAnIbrB1sD8tadTK7QP1beEE7bugLAbtzikW+FqcwiMx1sm7Bm9/Bli01ejjohzR+Sm+OxtHA06snUeTl4P/A89psD50A6a7kjvl/HDb4diYCU9IIdrFJ8gPalm2q64xTH3MxQNpR+8qpe8GxxwKRvvN2xoR/hCN++sg3P8f6urm2Ujf1Bva0QfAQUmISpWbfgbBnXTDWh/+qfyOTN3PLTacv/O/tL7EMtRDeZh6+hVruCARFegQYZhRNlWFGMwrzwq6X9y3rM3ayOW19gqKQ+j/4w8cxEld05ZbNgqFnfeCvevGNoNNuhjHKfHww2YY/PuYYTpEqa9EcbEafj6gfhEcmb5fPcZU1nQ29KAerc3CHcPeqS7MJvzW7F2bIe6buHgKfAtky2fd4/7UiUc/gTrfTfx9Bxng2COgJ4lI8jgy9txzey+Jbs5//sI4b6MLVuAddbbOkluZlVaw5lY14WA1xDAdPExfCj+PzhHA3LOIYCHYL9iFfy16z798Cwyd4fcn2EG0wyCURvigBHEur1M8sEWkWACfYJAaTZX7QrU80h6yew9FtN+peJSKRNawNivSaSW1rIawZERAyvuBkKY5JvATIz5MuwDMSqJylgY8etKi3ULgth3Gwrbi7YiXSlVXfut/oXDs99qcFIsdMpk7cNgIYYLGp1PyhX6q1BnFh0q/2F3kbhMmG3r/9jVNekXs+UfhmcovdEi6eZ4ZpfHC+2xGZ5UXS2LaFb07kdFrrA6k67XnPGMDIUnD2hW7OAytXpUrt7LiOptBojo1aFfv2Il1ECzXNrxtrNCol2XGtUbT3QY5fwkGxgEtK06fDWNytRSAZeme/SnQMvLU9MrGsJBmWmdypZMpybN2z2W9sWf/+YZj+1DY2w9G37cH7AYesGgf7dxtf/Irg1pZIbAFcECSty4dtHspzYS+ctA2RS+k92xt3l0PJlfjfMPfpQsHcxgOV/69hdoeLr41fdpraGPqQie7P6qwQ/UpRyiLev+EkW8+nwp5eDOdmKntk7W2/5KBsf6f/iZlsWMpROAj8AcWBE0hJ7aWXHhhNAQXAPoqsdC7XMfoAmFV5U1vHd310nWqB2BKCRz641P2yLZErJgjgPyvPbhA1QlQlc/1IHw8GspQFCLB8boXI0eZ/HV/BIJ3/uDupiy3snQqJZFlOy/o5V0g9GyR5Ta8vFDelC2Ae8CJhSdlJ1qDpe7ZShmdL/x6O9hJTpaj9ecwmrBEGL6KbfhiklDPJeChLb2Iz3V902UL9h3vnmicVYhJCCK57PBBbzF2nqfeX+YoK+gUieEfyDBqwh5iSJQKRLYObOpng7rdkNJUQb68gGNw1jAUCMezOUPATPKpOXjt7RgR0kvmfe7roJgxEwsMriL326CL7uMK0XDMJbjDqu3CHFkvRCx6Qxl9kpU8QAHl0eCwN5tcFFDx6Vwm6mpfGcPmLguY2JwzdNytNh/+befNNJbOguzlJGSYiw2F/FiaThx2iXPzPeqmNjVohoNSDyGsBUhpAeSzSUSPgLsZPOt+6U0ofUW9PKWUtJWlzzYjG5Hln2ZILc1rkzKQwHCS21cl8PiielYec4hgNoEboGBT9eo+Zk51hVgg/p2G8VuisZhRIJ64pL8Nq3Q/e+C1qKL2Dj//YW4hh7T/ErZ8xTO3bsTtthfDpNyD9hhcyOCeQdStGkO+eupO1QPzMWj7nJNQbu5F4Ld+Tf/K4KeuUJVmkTTDE9NiEr+Z+ptjKAym+x5XHexhawvI+w48KC7F2Ps0eDwqgvLIi03yooHjTRdafukIu5h4xAStrwROB7ivcpjQTKDl1C3V+UN6EX5ErKRhutQ3xzonOmoZyfv4Qw1E+7pYFTkq62T2NKE2psN3nf1RuSbQT/7rlp8DgnMe3bfU9NTXUKt+Lp6zu9vhNkQCiQBWQVy9IMtkbnXMzlrAi9/Jz05IbRzZFk+uy3LeVWzADXPIiMiTcRbIP4C+EG/g9e9GDG2h3txtbWXDUN7Uoa4njIQywccQ92tVIwtpJKvzkkh3eUPWYx0rwm0kYvZq+RBZib7evfdPQgzKdZvMzxwuuDEuVdvpD0qqgwGPMDA3rFRNuVKTeuO6zQslOqF6k87eLKZgavA1TS9BDIdbDPuhfiUx3Akc4CJfXVOkskaqxbt+o7nfWUQMO/ebeG9Bfp+CFBRoJ0FGWnwFkJiehVB7uJzH4Xa7uELfwIpPU8Y+DVBMtKgZCn+F2RoNJGeu7ZnpH3XiK1rwioerOVU+lpVu6+lx1lnvW9qW94A5CKOVNMEPk7LsnnJqmbxrLBL0YFWTx30dgtmElqZSGbOPC5wppl0A3VbZm8MStlfLVKW8lVw7UcnxphUhMMYZzQbbrrFsvRDpL87L05Oc1GLhkbMKoFpTq5mL2n5bNp3BwzkL6L2BnsOhGG7Oo0bIk+qEXme5KDGIPekOwWZlYe3nM7YYE75wez089OsT/FBT754FJbX0jdHDpQmUBzxslSQE+2k2xhT3CKVF9GVsnxUrht7ouIZeyT7SCW8lj4x3S8u1wUghupZvaURr4WBok0yho4oXbcaSvZYj8pB+0qE9EEQzEjTMDYFmawTT+l1XUFWGfh1I1aps+s2nD582xE+eBlsrVFczdRLqlB9i6nTtGXFj3LAOe44S9ZLFsaKj9qpsyc3aQd8I2Ygz0iuoYa3PhG4tBOVTCw/+ASvD4LH8ligvhXF1nBipvmi16XdmW6dkMRqaMzS1OXTUHMOygrVMv3ozHJsRkR41bj0TzGkbFgWeVOUZx/lO21n4HR9yXfFKM0ty2MdQq2unRwxXp7xkSJkciznmoOF9OA7p9WLmyyRJwuyXrZg5odais+4Ls7NhUWGmJONviMP4Xc4n9zFtXJMHg3HanoeiTdHJCzDBgI5ZdkaYdNNkVJ8flbISdvcUieOPNrAYaflJTFwVtUIETYGWJqMOpuxw9GrCZfu5HDu5hmva/9yoiLvEmyeEj7KD9u+eIxLJI9Q6uOIoD1F08eL0Ffhg9E1AkMykHd4K8H4OmznN/tdxycsCt4k7fp1UREVOljMCspAp194ys7xh0IyejbL4PrUCYV36/H9EPlbFd9NwPtxwPdirWn4ZTsgOJK0YsTkqdU5DUzDMzjKAm4k8zSjlJ1wjnY1u3ndX8fqEZScGlw0W+wm1FW7KvDSI+G1AslzzG6N6oyRwEBGFts+bISpFtDY45UeZgAjNpoH+bUPoHuS3dBZAOAzD0fKvsTm8NHmYVC1aXkaZdEDeS1Z2/Z8lQ6k5L/xWqIQW+gc3sorwmkO/VF/Qlt1D+edyLAtaLtk2JHPVZmkjXWhhngqZAFl5a10Nara8/nOXfJULsR5FHvbptHcX7zfe8KgqLFJYtN7FrwMCVaDwLeFgwCYkv+XpgjO3Stfqey1XmuT+oNMrXUhxG8IdH2/SugDTDWdRtk9CvOJYIre8w+dJbmSRFek+zca51wmz9YRzlEjR83m9PrE+7G1GbYA9GSEuCh6ffFMDs4Ef3X9F6DVFZXOQCnnZlbqEYUys+lOx149XbiEedZWICSCOBxI1Pl2pvWJUdXtts6Hl6K48r3W0Ea7RVN/QfTmp6pmff9ts3uTXfVhBpRdJxf3DBPzOAsJhe9JcpoS2cmRv8ttBR4H1zOXN8em4U6wLDmSYD7M3vKc5Y7cZUPNPDPhRP0bE9+W8c4GqOmiWRIS7x+UcnAGG9gVVXjDOT7SWjPIrVg1sMhvQ6WcZCRWdyZEkRsavT2Rm2f4Uzc2ghMpy0KWsOzQsbUlUrGceb1nBvF0v66Gwu1CD1Bs79Txuxv8ssdI1DVWb/mBJZJY0vF8enLgVUqdqrVEBGZ3oQ12IrDt8vimlQ3VtCebADFDQ7WiI0Rby7i33EcJfr4WtscuA0kANloKzvpHDNWOvFQ7leyXR+BWhz82Aq+HeKvLy+N/ggfOMJd2pIudy2upUh0zUoOCNOznhuuz2aZJYvzWmt7Yan9/rGNPWqKNIl4GOxRXSQ+LLK8hgqmyOY5jNDE88nSzCAj7eM8mcrmju68TRhWcctn7LIdPcNklpEu3KnnuynRYa1aLe5UpAigHLEG5zhlwgKuBsny48jcitQn/ypGb7kY9BeRfM+r69m6YHoXucU1yNXfu/EigamdrhPrOrE5PFf6SAhOZQHl7fUZ9mrmV/84ZR76afaBvS/N9BW0bJn5RGkTTpzJcQfWM8j9iS0MXhf3hr3vShDavuOXzb0wvlw0ynoEry9RPP2kBJadvek6JWAAwVSg1L7KlQJF3v5WWCvnCqxNGlKCWiANYEeOInGlq29kG82S/q8+bnwvhUZmjPcDBskizC4j52bCRwSIi0y3KJU03V1ORoUt59aCHgMQTGLROZIT2T0Roba+GpiM5v4vIM6w6ovCM2Yy2ctavIWEI2kGt4GvRrZYs6Hak+NVaoojkyXtsbKw/mL34YfCYCQini0jFiSnJ1ocDPuP4EyJJOVqEWJXCXfPrn93hByMycxXzD5jXxBeZOu+6YCI1cyeURqM2uxqchAz1A+jqRVa40h9fMVguUT7/pgTYjoBOMD0RAS1GeFP9id5P6O8m8D9Z2blMjS6SEOBg/neY3csui/Gc77uyYshoHb6RL0T0P6TFm01D7WNGS5P0+JHvDEgM78isxxFFkqUXQy8TSdoxSldT8yHT4MCgify7TcDolRwM4oFcBPitoHljuRHGxp7v35qm92vamU11jKUSDHFanN7U/hvij6MXDolfgqEKW/stemDpJ5K0wDlS9fLC0bX1Irc7vMnnkz5S/i/wMm3/bjsec+aAgSMSWzBnP3kq5Ry+4/xkARd1g7ZbD4pt4iCa1/zcEgKytZwym6qS2qUhh5Y3JZdjlehypdKCPB9icWSnuS69s83GZ7pu7b7zsxphtBH/xKIxdyNr9VZXunnV5rgrTMCaPJRce3lfslUEBdOMQhVlcZmTYS1G29WVNvhJPABdVDG1rVzs1VmO34dHFI7IIiXtI4Wu8xrhQHLdqtKEhRD4Nz24aB09EPD5X3SoCHVt5ouZEtHKlihgeTxnw6MD+ht5EmgzVh9Xhs/4U/ep/n2SJAR8SOxwRvlOOgYl+Uo51V58Yojd8glc2z3ZYJI3XJP2VoJClRiiIzHaoFvh1/t0ZFvtY90j534T8uEGYejsogsYkLpbiBYQLufIJq5zOveIGtLoWWFXY5U6MWIHlVH8FWDUVs8R5eXgVBVBaIawGeEVz5cuQxbvPeZHgVrHQ1uCLBQmCU1Fg9KFqPb6iNJkofYVAb8zEHNcyke5TuRY4ptoJIhgEoS503BCE4GyuwxB7t185VZgVjX2xI8pcd6vvQGRVjIhwG7U8+FZNq4uHf2BoYXecPZf4lcxmz6iZDFy7tfJK05gJVJ+5yTFJsliG3fPGgFhp5ErekO6R5KdZDLGOeHWUJEX60mTYCAo0xBRYiWd9H5VNfZUyAT6Oystqkf4IfRJbzaH/SJt3jJH/FeI5rvcE936mWCZ6yOw2V7i0lkdU3PmoMcsC/W3o5D8Sxh6GM1s70ZvzZWIkZdiJ8PGPGRQANQ8RLesapnFmGVa43aU8rwyqhEiFhM/DgbJZhjLEsfMcizPM+z6F07aUasuFzoSikLcVGpHyg0wlshulw3RVUjDy2V62RZWeyTk32dwaSYUbjRQ2sfXINVMT4g00P+jNmZmu0X6gf1Q6sQk+ZjHL/3JijI0aQ1VDOu6/Tc9A5O7CRwiZ37zBXd1a8fmawS64kOy4g1c716Nid4f5YQmKkYkYRuSlCLYBnRf5mimUTW5UZz/N2xgLIXT9879wvnTTfUBwacw7os+iABeGxZ9JemsivE11ayF7lcTI8MC0tiqN4sbep/GGA9oNf3GbBkUxVclRSVi08UAaSPY9QCc+TNasDMLd4p+l1e/7o3uzxHtiFZZ/RhItJa8odFmhicM4TF4sLCsJfk3aeQWLsirqi7H4/N3oaW+u6q9IgCuuyGxT3R0Ezoxi4Yy8zWwer+NgAmc0obM1dNjJYLinScJV+mPhlUbH8oiunF+mGLQLle58Mtith3yc5mo6qn0CFVI+eAXHFa39jxAg2b7nDYXoo+OIy9XdmUsfnuanKBt3gkpSdDOXxQoX6GIYDyY4Mc+tlO7Glp1Iv5VZjN7hlKHzj70ttxFBtTreiWmlJ5P7P1rvsgaFBMRVOhhHgjDt7TaOEVxP9lZVw+Ua2KBD6fT2LfXVmsirCZnatv262szB9+pyxaVLqhBtcRhjOw558rnoqS06A9EqKecPoWnmiLgo6So8Grrf6N/sOhQsZykknmjSfTvtUGaS/YeZF8on1BN3n5nAeJkkF23LcRuYGVNMyLhfraYthps9nv7tQNcjT1RlwInY6MP9aKRAzrxi9UF/jMTQUuEh66R7RoMgD9ZkoSvxy3JgDUGdmetrdwjfeVNBnMF/2kfKT/uzdx/fOPGPzInRlEei7TPL14mqUXhdvlnrwONRfReI8hQ4cdrYUSdSszcLVZ3otONpJsgRskzzCoNI176pq52p7MciaYHoyiTlRMZ1Si43qbuhSBylEB2jjbKiAmyBjPhihHaIJ27omItHvDU5TlKyml+d5sNvjNh2xY0oIrm1DDvk273CoauIcUg9tFlVGTsyNiMGKNk5Bn43SZE40Z4xFAYT9lNXQHCsyysU9V55ogaP68dQuXutV9XpRcJ9Fa6DeHSYLncNCJrrSVoeuC7rDkkvwIr+q/1s2bWdBu+DIH7xa0bI6IzBzNjj/OF9gjGPHkU29bEmhwRdhrluK0s2hrFQt0IBZ7WdvLEcHyo5ssjthIoF5knrkhUsV8x23FYAHTh2ZqHLFn1e4oBOix+cs1HtBgOZwVA8Ei89XDk7OVNGfzYMU6VuADogHaa2Gcz8Yy35m7H0nujSje+uqGOvwMIC0QE2s+AzF8sdiliMIDnlwvYQQMNCs347AX9wOSzuR+AcfBU2czH7KIIT9jcNjjS42Mdpf/zzwweujI0seNAGov6m5k0xgKQGI6kRE2GmrajbOVwEFVDhyoRKXQXnbk0Z+ZUKI6Y5rw4TEuWpbRj9IhTbTltLz/Q9Zxtsiw0k2hQmCrnPPuR6sIE8Xb278pgYkmAl7hy4wvJ6lDVn+eJvuTa8Vo4V3CcNPixaOmG/x1uGb3Uia5FfokK3dS9LYleCcgyWfZ1o2x3JwpMxn7fJ/SY++JnNbEysfjWXBafN7cYmVsJVlXnUa3ZraPa5AO0mjPhMcCOlVPTM5f6T79WUywhN7+UX9gYFly2DxdMfuaQHD9FOpjqSUJJ+TeUILdlcEoCUitpVInvaAVyBP1amxAo9NGzcxyvH0OxdgTucCEMbnkSNO5FdvO1VqjAc+POanNFwJ2R+etflRGnmlMuMrw+uGsr5qMLn9eAM+5Qen5pW94x9pEejEewuhUTOSro1VV3fODRKlsLJzRiRVRh4SRiOjRqAmhzjlrxUJaQWVDvYrg5x1CCEJ7J5rq7GrYeqfPg2BZX0KODoewZmNnSKYo057wZtt+Krf8ei0yj1XUxCvMrPGRvYUsGClDp+0FH1fhyLtekZ4C+PjDDu2mbsdflyvJGH2Shtr8kO9EcU0ArqfWfOa9ptEPgqi6ZRZo1VCnA1TZCHDYeC4AYspu7U49PACQX4NfG6wWZ7LnLB0MJ0DFFhac4Uh4Eaav0k644S0iwINPMmSaYHX5WiLE6I12NdJecV+b5HpqnRxB+fybFO+8akYCn/Q1XSE7i9gvjGIOun+42yrI207V6D+vbwytFi8piETBBOYtj+QsAV5k+fBpATvREJjY/TriV8SbtqeIW4sE2ZJg9Z7jfa0CMcABYXCwJ5PCMgDH6oS8zEZX1mIy0ES4rJc04OT0TgcafQNNkQ2HcAmGE36iAZCUvg9UzsWanSO32YtVVtApFeoP+AAyklmUPvVBBVbGKo+GvkTMA4GLN45PvoVC7oTItjh8NUpXdSpgDpfzZtNdzH5n2HZ3oB4PhjEe4LI9QhOSpMzXpM6ZhMDEJqkxHnz2jr2Wa2yEHRiYZca1qIUv7/1JQEVX2e8Trx7Oe65m4f7at+4W6fQtBnjsqAvexcF1a8f1YJZB+Ha9zXNBVUCuivw9gUlZbj8vcwueCyKdFevXJC9IqnQ/g0XD+rItdQbcYI0WVnh51f3VPhCe1CSc5spX2IKBBVce20G+H5kaFVvpjovkvg6p+cwxshhrnq90PimNEQ5retYhxGgXCw5hug//anhUUZ+DaoM1uA3DhwDvNU+NxMx/HKovW/934AjkwQiwPseUxQajyXsWrZKd7gTGgYR6MQemsBg/F15twTgkkbVPs1CtLK/hAjhqnjeAFqtDM4R2JSJK/byGirc0aTCpPmGRfZWp+76a2CUZKcfwflO0wyXOj8OW5qQvcWlQ17D96dhzF4hDoy4yOQ3c51HKE7aOc6mF7R5YFmbaesBCbiR5UtccKS02pXsuo8CcFKbilx4MCFPHcAuulkPSxEYZz8wWrs2nnFnBqJwFToV7etFd2y+JNpnPVO6+07hmUy9albsye3PcYQza7RVHpAwZPYO7TSeRwyWOxJzYy+m9zyMn4Mj6wJuhGO9PDKFivMJOtviUzQRd1jIENLHMosjgWrBmQb+tB3BAJgC1vcNdVliYniKg4uXScEmNsjQeE5OmkyCvhfLACmo+GQIkto1jhYheTqr8H3webom2VTNCwlgYFwc3o2zSE0T70F3ajywqyWbVPo4mxl2NSmHSOFLuA+bP5OkAEe7LvsvUvthJa8p6CddAW2oqud3P0KGhk0/vfroSEOu7vwVnl6k7xTmEg93Umiez/C+f0tr5Ca2sZnWtqLOsravR+rCwIQrfoZfEifXWa7LE4ozOxWPSN4jU0v69Qz1vYWyn9p6ecSyqCHuyPYadr5aUbmX84xqBENmGVYHulguPyA66qWc7zwr0goSZBX8GAuPQqt+LQdZEBQHClhzgvDvLnLFyAbjyh+9b8JniAVYfUzQaxtJXA4s1uStLjsFaRpYc3SJV3xyJFQSQpAKCkU/veL/hMWnK1x6RxRaaIDEPK9ZDAlfdcdYY4c7lskFlyIIICL3bQvFbbxRGw6jUvzK/MjSyPVHr2cGLXfvI+fxAUYVDW9jzYvQ5035xMPwQMrfCwVfy288qAhVhrMzCLLbsDlD2+QAQLebxT2l74pJ8RFv/+achBM6Tr+rpXtodCB6Hue0jjFAQqpb9bMr7fHZ6JIuHBva2oej/ahJSbjB1rJPxi5j4upNixxGhdq2skGvECmpFfmDQg0nsuyOa2GQy1pNSUEAEgRRll5uCBzD1MZL9xZHg5SH44zqOzGZ6EwtzebuByZUoCmPa1fPm5XWBc76wvmlwbJGcvNJ0qpMsQ4dl6FFW1xduWghJe4BlKUs3G7IXZPejy4e9PUEoQXHgNCDWgms7Nq+3mqIsc5oPG7cwgIkHcsczeH0W2UD+egQcPehLKx62xlMxzcv9xriudtsZS7scSPZOrWmenfLCYuwDtf0hfIp8XK2qToqFJs04aDN0hJenFgue2RKjqIvVhEBr0TXujpwRQZW4WX22EveCcBsIY/eJOJfwKRSRzg9LVW1sbUPyGQYrgea6WqFG/cflIR4bP18asaE9x0ywkTnfpWBKcMZEZM2IXxMNXCFD2bkrNpFjGtWHxcinsl/6DB1blS8C/xD1khIBy3UKlJaBnGNdPGBJ2QGtjT+3zfUzHCLeAUm6zFCxKRcN/Vap7Z2DLGv/5SosoF9Gr+3Vyxt8rvGDfrHkWXGzDxtOvGHCxoBWVcJHWKHbOxQ6ONPtjRiPY6Akcb9296YMv8qdZKHqIFc+Q6FalXkWISv0PwGkBtw81/osjWqsvw+irlGLHdgZSQVUG6x7fK2dvfkX45PRJXHmgyIosO7SAQui1R1jqRddDw76Yl6nAm4Lr5AXOo/7Ju2Wcb9HOzOgMKBOBG6CDXqVyrhkWjX2FsCadPq81RQG+zSSFFY5vz4rbTa8Bl42hayHfk6xi/FKE4+voZWgymstip9IDd4uDa2214bvX7WkrH6/eTqMXhOYtHVihZljtW5bj650JJ0AD9vjRhUWurwN24Qe8YMj7RHeLsVkRz2NA4B8tTHJWTGLPaoX7gvTIFsCq7mvIV1kGThxGq1UfbqQh1t3+aVZ1z71h0f0nfxtvGoAUGia7HY2EyNxJ+WS8Aw69HplVaEPO7RF8oOFBU1sJvTp6oimbkSw0yXLpk7TPpxq79WxKtOMAkhCyE9CkhjRMTnLHtU4XAszJ+XAtSYrAA0mTOlaJ/spx1RafDnZYWf37K/HbYMuZTsiQwPVBksIbZ1j5Oenp0p1xNTZmamxGh20I8gkYdedc46W3d3ThEiD6rvGFbUdhRYAUD+CQd9R4ZnMmJddMqq3ZO4v8LuotVEggK67hlyKWBJOk/69MuOEzSs18wOapKmc3XJGkZtUrvsU24imIbp25cegUO1tqU9F2j1B7i+7O7PsfpyQGXo3fs6H1nsg6ZF2nZxXT1mbxrTbNVWL6lit23OwZFO2HAsJRSPn4dWdbUZcrM6OI7KyAGyfD0NdadxL0zy9gvkzpPDvUlBoL6EWE2CLOAzsWqG/oTyh/OUfUi/PfM28lEaa4rhFCn+8NUBaOLDLH+lUUSd955pYhWL0IUyvnOWZHQls37//8iYvuUUk+lfbWQAMBlpE6B3XoR9dClweKkRmzOF27fJ/cD3fIigu/7MZ/3BVJFwBEKONudtPJe296GMpRyefGlBE8yAck7B1vK+HnvWjHB73YZ4N+Gx7RpaQ+QrJHp3XVD6abBge3r7wosDUqv8WkQy0U/yquNF4OV5mIcUN5AjfpFdCFeehJd8TmPlbfiYn9i4u+AYK5P1F/enjKXs87rVg6EoxcRF+pLwwMbyrLnFjhnAByRntVRK336dmqJrbZ6fNq3F6vJB9tzXfrwXtntdN9uAgwEgJQ6FpUIAyeyX8n4vkwcz6VQrxrjPq4AkVdTTSX9Voh7a+H5xdJhtXc9JyLrrkxTGnytfjMM8FJtlTfhESSUHfCUofmhDzf4LaCduNhoP6fWujsD1grfHzyJFMbAYoQ7T3omCrLoxOz8ZMPpRvmEAdFuoIL9B4Y4x6WjmQ4fOnSugtDxyjsm5VVTwIFqT/1Tg6spJrjOO+I1v2eHVpKl4QLsRcypRS+OMYOsHL2kje8opL2Ov3UjYn1pIB/vuB5Dxn9CBCpLj9p/E3Z60vR6d49AQl6GTtBOlVWYbdvHEp7X0rqz8w3BrTcQRv+axhINiZhU55c3HTlFLZ7ZRWVGfOKZs4MHfqNWKV9PvjZqO9ozT/z/jLBtIPJOfbp3Xybpr64k6UYAimasO5vgupTzWo8E2n5vyWGpt/Ayh8eZI1Tmhcfm75wa9R9/V/pw3p79gWxKk3Aut4Lu56I6bj2slvm1/wg3IrUTcDFQyEmkZQio8pomaXt/qeFioer4u2adsTht+8A/69w8bAo2a9hbRnGoFkertkQ/lGvAUl2c0U1WhZ7Fd007KCY7x7QBb/ObDjQ3+B9X3PqY6PBR85WSEWKjnatQmv6PQVGk8nPRRhjuLef5d+MsM1gsl+8jGFRAqwz9QlB3ICU/MxHvAqusYgalilYey0+45/Ib7xmMUaR1GUuIwDQxkO3mBbZsq+y9RyragGd62xHBbEAriI1ktRyIC95bWB+TnqxJlHETbo2x8RtSAs6lFvW5gACWBHASy0qpMig+Ob54lASwhhqDUvvlRUm6PyAaTc+Vy/FMQ8mDGngAyk/XmoDrT49LKJ9Q0mZeJ/SCj5oBjK+4vADSAOy+tPGJs7HW73R5fScqJmUM/IIqNi5qtfc75bbGa89iO6EDZiagt+wGFexp3Hqana+OJPNk8RoaqSXugql7CR1K4E170OOMirWKrQUJ53RgAEVlUZRjuHN1Fq0LKfl5ANpb8hucKbDZJeF74BX+Dv80B3z9iM+aMm7AAlaGr9eTyDy9DJVJ8grR9L3mXIK6TnfOqOmYYP2q7sbDCCBABV1nA1swAF5ui9+JjeuAiwNnNjxPvihOvL1ZIRn45y/Xg1qb3SJqxa363WXvwFWRypnnLpfLBY1LSCmPKx6cvqHKq3fBOpQE7ghO9QP0UoR55g6/JB+cQHbczPTWEspv/4+t3KYNN0MlV34ocIqoQ8BvYmjN9Pn2NLLHn1kY+tUzNBlWI3JbYVlWRAA8lDpibtyx8xiagptdpiDCRqZNizgKbK6/NIl2j/uAspUa2Ke7XSlWjkbD0IxhnZmA0VB9P0caqMlBULcJHVmWfpe9i/kJKevFZhgJ/Iyk776syW/kKMhRXaYP1cJimFD6ZdwDdUAZHg6UY8HRShNUWu7PInYvowRJCObmyT023qF6SGPf5stTqisXki9VkQuXlgh7Uxn5uPepQmfvDKi2iLa3bMxbwC18EPaGsT0qbaBk+LluZeFXODr6MYIAsL1+NS9pmKsUJUgVGOMt9qAynDSMqfqT44F2ypPmCgVhrJV2r5S50RdyQ+zaPSK+NfpLyYjTIjQQTy9fq2h1IR39ILErAdd3OHXGtJ4X80HAQSfUOXFeOI0VIe58YLwd9NdEhvnSsg6g2ukkhZztjn7SW5XBo4G5OPjOtW7cnADS6o8Dnda/1j/3wF4F4cO5gKODAiGDfzkVGxM1Cp30pO6STeiN/ZJxIT80ghj8ybEb9k0CKaVeecyvbPKLKTHYrik6ydQAMclEEBwj7E0EKG+amxYi5R68A1dimH0fUJpJvcJphbeKJF82USQDVgoFTsZLICmDvzf6fLnzAwzZRB3PE1LG3/K/Jln2pQsV1WhGQhDyPhDdMNM85ck2wR+VyhLDyad0wmolOFiwZbm84T3CZh9Y4yiLSB2IGq5kvVfwXJ4Lr7jDPTDScnrOzEF8+05ISDfErlJ3Nu0XnqwNB4mwDaocvIWA/xJQTn9kXkvnsojwONqVg/z6JdqiQQQ5BdwGZi9fE71YY2PxUrUnL9vlWBDZVeGdqmloNMeII2Eodi4xOOs3nA5gqW20C1CyHOoY9du96W5FWjpqXDgtpFykfGUOUDywOO3z0jIfLB0OuVhsEMPY7CbXNWocZQh/W/zLhAO/inhbYs0nhfI7iwqw0wzWnL/TtnKS1BPLaIK6bGr00MMIMMyWTu8eCLwCjU/v8rEr1G8C88XwiH2SW8Xk0g2ZQxIJ6Ab5/rGM+z/DKhOECa/MtIHtok4AzA1ETlU0WCxEZSkVVUrLwgE7yyRwh4SLBglacF2mEprwuGAA5k7D41AEfgLMja7cVFMpDlT+02IJk6vR0/jfQ6v6FKVQ9yrrJQV3wulHbUJHBZ6bxk4OrUC+5WFWagHz7CXmJCyUQ5AspxP6Mp1EtbkY0Kpe+93KpJPHu4TyLea9f/aeKLgwndNrQLCK75TVsgSgc+ifzFUFFTJ3iWrJVgI4BbCcyMqTa9CKX6iumV5qOeVk8DfdSU5iKcEA4ymXRVUaWhr8X7vomsTJP2BvdaKqRDwKLO7i72H4LpSt65q3/WJJKQNYD/QZGmTky3o8wir20g4elSVXduyIVSaTkrt4Qx/uFvNqNwJVWQrb259L5h1BeLV4V9mGUZRM5ITJlnIoVWRRRQ/fWi9Mnyy+cbD6uIRs3UjIwCIs57c3nh6/8w9xpSGpdeavYHRTZEFlniyxCSpFqCpYsXzzn504vt80Vd89R62EVwS2iJFYqecE//ZaniFne6JUX9riYhIAo92QHswxBL4TEipVZ1KrjRzCwpAf0UDto3FaVc/O+4Nj3laKIpDphz+o7Vv/eoM0U/6FhyQfR2EM023/Wzqs1j3T65SSXed7rKhgrDuTxc3eu6vqzhavYDqPIh03LVm/UbN3UsVOkgnuQ53KO8/a/GlX/d6KJlDsCBQZLEf9NCxvm2FYXTRHFk5TBk0df6bKjLao5ij14XyZinlvTE3+xYIk8/uOKv2K1a3ElKX3yxt4Wudq1xEUxM6rzNqB1MWPW1MVP7lxUQsC6vY5L8d7nDgPxaqPQmtX1x40MHJGK4XqNhuCrSLyY1S6C+DbpOUMWeZv+RZyjIlAt9XFRISmdZbumXAHZwaOlM2RExBZS8ZFtS/bA5rqUX5L8G5hTeeQxg8MDyBf6Jdr0/ZuNFyvVYW05DQJNpmae4rBLsfi3a/mQwhsOdOXuLf1yOBD3xToIDS9b2OWy2AZqHESqbVqplMg3Ig3sWm/a8R5U8Ya7cS4MhI4Jk1X5eNCSP8tY/R1ha+NJ6LBSzhICCY3bDkXE4+031ivqJuN13OsvtjrHpZt6OQCGo0GKehbLWsuLaafrWxgushb5sosepD774n1OuVr9FhIsAFI4fBEGFO2KfMavNkqq7iyBMdslffoEjPovNAaZlcLh+tdsQzlsluzyLg9uOWquA5EaTV5ay8+cZrC0F+owV3a7YZ1cARyw3mqmk9rzIKyT6Rg6v4J85hlBprFCSbQsmAfYtqNmDYfrCsYPpQOK/w0OSCGvxiyvjJFZd+XfGAK6bQ8SZV6suB77T4j2sJUEZhlF7vHu0f6jYEUYFs0aa337ETSitNVvcoh3aNlrqgsP+yYMIIx+u9lpOFKKWdWRhNconzRjd8yD+sTn+ve8dHXFA4zcBUCv69tuPwUuOFHNx20E065XDb/IkQz86F5RYlRIMCyv8WP9oo+MXajDXrxk/P+ooSV1jH3SRhgLQitk7mUihdm7B2it4eyuWZ3nYv9G3rXIkxrrHh6AFFLwxXEAosYgAKxQidcPJlddhcgWg0Q7EdWAeGsZ+V4n33ZPkazxuMIfhI4rL/4emul4yF44voQ6arJJDo3Uq8LWOyHdRCxfZK3OY1TNeli0n8I3hMMCpjwm/m55E2W6Njat5Dv8U0GkDObSyb4c+hcEIQQi75UzZlv5NaTqu8jrwi/U7lsWMbW99LMi/+VljF6l5S16zbTCgaPM3JyCgAHZTKUPmpZg8MFjaI4pNdkjciDjVWvIMgucA32tZ+8MGh9IfSdwFJrvp5bKhlkazoFtG4JFX+FuxRjGFqw+vOpS7Dle3T/gEsHDYnWNJHF5odcN5dVE+JNdbGrkYSIvlJZeljN47Q+RMCqRPR7M9FtM4T+yw0xQb3g9RFqfJokvJQcnRgXcp37GJXW2eAUUlYV+ULT+ZPFUA5T865OGBsdfHCyI2hdcnpue0ZBmrHENLHfGUFq+D6XnY9hxC5pD5ofp65u23cDA6qsNA2TCb3jPYZ3FFBbiyIgyKBJ3yduyxXS8+miuW+OZZgcY3NW86BblhhJrd6RguNRUEwzH34QXlx1k7rbitc+iQ2MlRht3UcjfwVeV1H533QCHlfCvsI0iFSTbiWMpYCfp8vBvv9NiGQ+P+9bvbAHTfK5x4v1+4QF+u5vhDG1fDMDIoCtouLJgF+pqoaNwu23bwR4BV+YzbZLnaYvdin0TKIqn9NEVfdkLBpNqnCV1go4kakWLO7tGGTLaxib7g2jria9zX1RF/Jgf5wLu/sc5TG8EioNnT3MvLiSA5skoleFV/rBWne+ScXrHbEBRr9zcfOoavqhV3dHaoGVBHM3qFf29sPzm/hItLh27gURBUoeRNJ5sBA4eD9tgnEqerSUlBLErvQ1v/FAhoo/qQM9PSQmsmGIrbNoi9Ytxz1WDyS5YZq1CUxMP2UqlJ9kkwQDERevlu5slyoxKK0aMosD5D8XHa1UbSfGqlWkH+uMHjHR2QMWAKJh39sJsl2D3+DTUuupvwD4EDI4Z8oNssaOKbpBiEjBD/ea04rIIS3q9tSJHGKoP7LQXaagKrpUDHxeqoxEeEZ2zAOt/7NgZh1ErTDVjE59aaKg9HICQLqUh7pgEOP36DrWecSiOaIoJ5Jh8lbOVVgoY2YzYDkH7lB/9cNCBoSSik44Gp/596++wvGgQYU+ZgJ13wowv3OoOtD7aYZQKLsmgO6CMPl7SiEUMe1Fdwbc89MwuhUTNgCm7QioN++7kYEZWq7O72eYlwW+ZcV3XSCTZMAIroLKemV3edF+ohkUS6PzXHANtC2zdcrmOxfv/nrEyDgrkg1mdfTsWWdSohY88TVD0DjX4ck+UZt2GFviV2sFckQ6ujH6YSzMFwxd7plJ3K3oB+hC/pDLHp3FisQmuHI41XZHvAy+NhRaG9fOF+QbzgOROG6FmuxPQp5/sv6tfz5xyJlgOiGagldhXs0E+fLgJuHPz4F1ypQtavgTT8HDlDb+VixIRrJpNc+RCOQz7NNUzvLfW/LFrlnWGl3m/oHXkwWLtXb8ehD63rwH6l1lcUt53gah9NjvZiyzgoL9vGE9hPWVfsEu4nwXqtDycNm1uarvIsbG1xE+xSpxtwnN93Vy4IL3lyco2uQCv0rmavByyTz85JFdAvmMHtAirJY1+3fvKwMSjpYgVxyF/4wjjxBeWiJb5Qu1G0CO1ikEjoeirUq/yfhHCvQOgE6+4wR5cMcWJ5HZn8/6igvYMRtzsneH486Xw+0I69jMS0y9+XGWpVxkCqKKm6SUJ+DKi2OEWvMPR2FS7Vk4ZCAreUDBRuK/e0u9ztvk7y5srAwD48qPnJTBnVz7V5uRAofRuu7Jr/o50EIp0DaJ/YiUsDuz3xNj+zDlHiSq/xNJKPbXwFt40Mjm8yClzozZl2v3ClSXvcrvo3kpMYGkNqnP0y4k2vtG0UNFwwSklE5otH8Xmtv+Vr03j5MJcIYi7RDnIqqqqB7Cv17b8wxHUToov2PlOhBDVuuxuAG+gCdNYCgagJV0h92idPzzpXlphhexnQ30foZZzavyXoUGrrWfm2wf63oi+5Ed+qJV37f/jyUoXCEuIMW0oyZ+xzHdYJV6uLOYD02XipdqZfb5hV6vxSKH4/MajhDWt36Whhq2YQ81gENey5ZrmlwK6z1rbBtra9BR+2w8MLAkN9hEcWdCR6YTP33WvDEBObCIC7R2zx/IBnwNGaeChbenWvk0nQv6sdxDjeeKXePJS9j7LkWXWEuy6dlLE9XUNcpJ29774CK0TY0JPQms35sK54pNCLneoyNhzv2ZkQZ0kz4pJKF6relh8TjX7Aty8Qpbb0eha1D7n8JFjxtyHmiQ3X+s+EBZgzvDybAOCk6OEIK+5zc1ziIzcEDIqOzhFqM9JD9Ao95jtWkkaRgcnkBz+QN0qFXztT7pvD4YyLiVahlJHZXbhqbtjQDlVbgdoBKL1i4w0PZ89uUfKIKkHgAQo7bir4TEsjUUvxCc8SgAjbwx4sEZTViGO/jyiXl7xai/82TlMJDyR9woWHscOUO6ewtjUyi/mWaW9Tegyj1bXoD+I/DxeHPApIcBYTMBHapeI/6vKj7FwfWiFV3UgaIW6uFuHxNr25fXmKkgLA6N+viAQSCgPYRS6knQMskJL7FXkHSTQc8FTMZ8pqDxXmU0RGFt/ITEqiHPd0sojnKvn1SF/wMyTvL3kGKmr/tcm/uoU5naUZElbF1YwASiOUKPEBl4v9cRaMeUTxvkACxKycRyqIX46V8xf9a4LGSfsLdb9M6fPw/BVjMmskZv7lZjecUBmBpC+WIctckibibC0YwzE9rs1rjO1uK18hrvULc3+wTjbm2y6cP4aZqTZXTK8iOOqj+zA0c9jW+S9lvxelmG7uHBMxIVMsB3LorBV11KK+/4eZqdJzilZNh0kW/uQSOl7C+B9nlelZwV2Q/vs8yJGuuLGLmHlvKARaREMZLi9SC/tCN4fusa9z0m0Dw2AGhUUySi/TxnXP8d7AvPM5BqXPfcI7arh8lC293/2Ei6uUl4lVtG0ZS994OBXJf1mRB9LATC0vWzossLor+1OOS8kOCu1zfffRayN68R7JngwyphjbIKEzpqUdq1HV0AGcoySHl2ClWdOzH5pbvbyAf4MBQ9z+rV9tuEtEZiEyAfmsRJuUsX5Q2ljph6HLt2S9GeLJpQu6S1cEgdmc6RN5HxzDlpNQ5B4VNdyuB05L1Bo5kepBpoWvepPNOAIFDjFBSJ/cADEDSUNFFpOUk800U19kbTdRQE6Y6+KNF7I5lmXrqRBzIWME7uiV2Uf+6/MUd/fRw4HWALnJncaocPIcw8HZ7Q7Y+IAel9mmTcUZ35cXC3qEJzihKjAcI3z73u/SZLLkbHOKj51LcAVn5aTD1ybkY7UG5FcDu1wjHWcOPgkNYKBw804uZ0Dz1f+sdgN+nYEJURao8tZrfvxZ5RkTKjCMZWNv3vp3zUaKmtKYlYuswV6yhszKIwFuSr0kWbfV/d/vymIUQtWIRNV8+dlWe5xojyN7pa8CFZsEjOYQ2oCwka02n/WQWIpbFfv4VmYnvbkkZOCrx1zePO8Gu41hn7UxbaGd1MDcLOhzWTu28q06hEZVsCVquCFeZuDTqhasSLwx6wju686fVje/SsHD6Nl5klj/3sc/Nyctqjd77qIZA6MwyiMWvtyaMVs56O/fMorwlTK+qDT2vlJr5XEjoH8i470Xp9JlL7hX2JFAe7YGd+uMtojEsrAyGSfagafQQEc7BzsEQ1LBih4qZlQQzEUQHqYTDRxPwcdjJ3XvmkkKnhGEYc5OAly1fH53K515xLXkFtsgJ7R4VgGEiDqjxFrYP8dsUe8fzt+fXqucDDCuIdplXiuw/WpMosMUFQJTBjufWl3vRA8a3qWjQ6qf6qilZPX+R4HGGN0jKEVV1Cl+3qL6p0HxUsxEKdaRTDMeUVHqrRdMUFg9SiB1VZW3IMuLSJMLDpyz9LLLBUf+KuqmJPVKLm1qHuyulaEoqHSdP/tcnahtCUnEfeDLk9NflrD9CKrAjiQDNmO5f2ytHCNI+5jB8IKS9BxWY3dt6QlMdwQmtgZhCGzg6A1ygFCyZxSmtrhr8b/yI7eTd7q7ncfXXpLCLFOqFV1kYxoG2aDRETCWlwl5Djj9ZlJhOLpDYPizGLxm6k48OEPPA0efqTmuOadpHL8twzaTakKhCHwt0RK1OywH264Dir7nP1E4KaCj7Kly9JffLVqXmPQnXWbelMuVd9ktd4vLLUAou5bPiWCr/oVvtcNwjlhMfQH2erfnt9eibF+yHdx9I4WkuxaayAOYqb6X2hu2w152rxKp//VO4xa+izevKXbnCQxJ5QHkyotaa3kK9TbjyXp/013MTWjceV9m9/mBhDOgRBCGsy8TcgVMcAndWQ2vT+78UJH0w50odgb6cmr/Ft5zO34XGq9k+RZkjf/cKJhDmXJJGnrcMrR61fTJGxuXDsr9/lRr8bqxlxZ7/Bm5DiZhWrpPjvb7Cg452cTU+qidjCHskQZ2WEHxxWdASiGnOxDSjVd6lmIg31h0xKncfxbItFeo1JRG7JpJP3ESBvB6Ajy6wZ2ye1kVXWKH0uc1g/kFup6hJVw+XH0e6D2ooiZJIXktyYy0EQQrjVJXAgknTEbe1hzZZqs5/D4MvyrlwS2sdLubjDOwcljUNaE4MyFVrEVbyFJ+eq+kewT3Mi2pVKEopnKaC8a+/pjK9WX+lP6kOxliNC91ds1CRQE87K9YeGlXcPNwk/kMK3l6CR5YtluotG2be2bopW3xgXAP1WpLUQdrUy5WMtvp1navsD7zRyEqj3lmrVXVOFInY+fsfiA5rv3JePAW2M1vjDaF5mC+pEBG+G74ZowtLA+2SvCULu8eHrOunAXFjGtUWZawe7cAo63iWLKzMPKVWERYlnPbSPpCIEC5sBya5+KYEHQu3FyVGo/jfUw8tVrSSmkCB9SiCq++3CnJselZaqnkU6/dyswQ5w1ayCsiKzsVbpB8G8D5C0IbaFC/irQ+Usl6AVapxjl1+CiL4kVsFesPAW0in34x2K8SJSUsnS6rnrxBylNASGGvNBxX4MgBWW6glWP2K1Ko5+hsk+BSlwKRk+6XJuq2I4I/yf4sk4GbC2SW5HcJx4gyKUtnCewvoxFiAkDSVMcEHgxVTdrczTQmrGRZzoYrp+ZJbbH+ITZLmIjy8E5nZqm4C+sxpPvTq9r8diIyB5/6ArlYahJI4V5OvV+EHQLlwfZoDswziFcOmW7P/5amQPsxhvXp+f8iC08R8IRlMiu5ISFnTcOV5wutfXCe98PVItWtTTx6wJYRPTJN5/lVz3D6amdoLtEedsP7c1EdgNL+dYTeO7OH2COCiMtm/ACS62K4++kMdKtLuV/ndH/BBa0Px0LJQ2tERS8s/GO8CAuwA+Vek0O4JVeaNCs2X6sBcPW6aRx6GuWQjlpZ/p75oSu9pTaBShk+NVO8Vxe8NpVqZ1LOI342dqjmfC5fM1UeBsfLtOkmORXfI7LR/h9HB1wYg6kfS+Q8bYTio/IdwOxzLwCsak8nq0CigkgGnQvZaeUBvkyVKnfd05pcgS/sUUos30yXDnSEbpRnty/pmmNAKiAhFlFv2WibC8UzoUQTz3Gtlk89iJoryjSwTZVC3ueCLzR5laiMasZFMvJ0X0blhNBkUkOqXHmEK7J/5QhEk1kZWkgYKVtmyMl+qDghFYyaHiBhEg1epxNbN/aeAGx841iQ2Lvg3t/B54fqiSMi7nKtfRvVovcNHmaKjZ7RvoKNwGDKQm+nGdOPJ7teYpPpXdM7FPUe3JTJiRTyfIxPMH53onRQnzinYqrcCWpF2CqrwZgMMJpWnN7qp1OW/lAsql8WLHET5p7eiU9hcSUqKTHsXv23K7gBQlTYg+asEN7QHFQVALZBhX8CXONmCGzcjyEqcQ6WqGCTHFlw4+/G6TUcTJO5k7HC74WSwEyfjxedhNJGUzUGKlsKIZ85T8EQDgqZRnwCbhk0yatMukNhgq1MY4uNJE63r7mfbbKg2XFatyECDzNk6f217OE6p/yZl49PUtjhInjRw9szDvJjO+3S0p6UkpgaGRnGRTF9zGK0KD0O37q2JTWRGkKG7MRaCjZLzSwcYpNRnw8BVCaWQ6oQDruRdyM1O3dqIVCedE47m/BhVZsgMT/zdeyLV6gX5ik1YAOEbOipjAIDVensxufgH5ETyUx74Gj53ZPlL8W1HzFpzt62tt3VDRSv0+4tEdTPa1F/Bv1mloNyw6O/H95XepBlFJdn/VqIYZx7cm1uUjWc3Ry9G4FYwVt3uvJY7u2dZ70YZWPAXtLCx//BnoEwPWZ6RTokkHJXEO9WVaUO953+5UfxnjHJOpRizgpsXcRFGWMUDKcp4L5zXU3BKrm4X7KNVJmLYl7uIWLP4MVrAvwGh0mxMvb4hoNOaLF0xazgs9in1/eqWcwmd9Px5rcWYpeP1dVaSdIfnKTLO3s4pL0WFXK6PBxJgalZV1zXay0BRBg5Tq319ZX35NDo9VhJJ+0es4313ZXW8RM1LfGMHWrBjwt1G9DdAH3XpcktnHCFG3nEgscUjMddU2Xptfjz72UgAYBJR/81H/eTc71Y1U6NqczrCazXlse/o8CcfBjf1gXskcInFPKltpkfE0rueJxxj0gMD+Q0Hs9sVpRwNSquXLzWv2vDKBLx8j4U8ISFWW6881xR3YDQxMRYKNOre0+z6LqHZHFfy4Vp7lj4o6QoywPNtuxv044E/azK5SiuTrmXJ79EEgRQLhyFvGWjOOFZlRuvb8BGiHKUO6ghQHBSTx0Q/yk2LCPNjRg5R0WEtDbOHF9dQPCiqByWpqeLe+hxiF3+iwXBN1WG58NuZ9LxpRxg0yTADkEXE8TxA+ypHH6I0+VO8C4mReDW5N9Gbu/QOQ3P1BN9d8cKFZWMrZfu4Ool8jiDbTKi1OrwBS1KaK+aytwFDnqF9nKf/7BHoywh3tAX+6dQSudext40GS0ORDUhe5OiaarfeoA7IVOgNclRmMKsId7ToE85TLQuSDQ0T9SXc69wOt9p8xqwKY1F6uI0/yaGvZTVeeEkx23k/aQJOVYOxSNZhFkemsy4ul6WnsJnW2jHv/8vUXCFdN49/J59HYxmgPBImNkYLNYEzzunA9Eg3TT7ZwgkfW5auuOpVDsxth1kqmFPpXfDk+vCt40dL7iChTyIjakot15m4uhLQNvj0LC8lUZl5y03TnIJVVw2Um5sQKs0f14ju3SelfVjDtalnSDRMXVCMOqPh2Y5zL6itOPoxBkS/mzgE7k7DcdJtraMpW4je6s1fkHf9aC+vXWOZO6985w/N2pA04CXQprp6MorIAfU4z12aQHoXVmYaAzv8RDsSsgdGwNfLXaoupvFJgvHK95PzxdmvzFdWCTjgdw3sx93JnCOVqUDCv7IeDsmngP3rvrAqrGOISlDBoSLMToO6j0NgwzWqmoyJVfTYabieUCFOAaSks0w4iJUkT0HtUDo2crHG7R6+JX5O+d1hEOSpwfSO6nAxqe8myBKU/i2V5edMt1x9d53yuZRpq+IoDSd5l/Lwayd/2H58oYC3iGGCgipY2GmIlLRcJWbLUJ/QBmAzJ32m0q5lDyh+caUZiW2FQi6SR+5u6YrvJQLonYlRldMZLJXC36J9MgERb3EoAWuf7Jy1dOcgBqrFiQgAtXihBADUD/Vw5a8b2BfUm/JdyyM/Vog1mm7YUJV2W7oPCDal8TheA/WweSDBDxsGBhEwtt2ca6aJfJ39htGtpNaW0SjPYpgziTYkbh8tvDnTjdhLCoC96sqHM2OwBhlfSxzeCKbvwaqluPl9+J8LOESL7kSmyLmXakzFx+dqXrNse576pTBJ4FJeWKIsJFkw8cAycqPt82YV72PlpH+BZtvptzI1PO5LNxtG8WDRarkbdHqrrHfIQjJUBNUz6XFsc0GGhskT5HDs1U32Q9x50jSTcl6eAEeA28Vsjv6v8dLIBvwwuhUtaFQxPkudSKuZWG9ILrOif574VT3IkBxNJHAP3ZpzIRG2AXhBCvSEdF+QNRt1D9d0bj5k6c/FaNydfzZHggSfNnKL1/1TsGCDqIQKYbS9u2oz3UI6rHiLjEaMpdb/gTW48gi2Km5lm9F9YevSy0NXAEE995IE7gLhtlw3OT56rgaxA3C2EIaZBInN7vrCVyTzCn+nVt/tdXRVVl8WskcAqsjaNn/9dpRAwfktbDVbAMbJ339pnp23rKV4MkpaGoRGmQuzvQbByFVr9tiEQ4T7Dgpo7DviZO6k7r7b7ilLlPtU/CTLLUzT08Cr1vbnG40+GFbVoajb+ZV9CrELW9cioO8xW9ik7WSW+6ImVdmTiYpaUgAJUROZGMxSJ6PiwVbGjqdw4a+nJ/DwTS7KpYtN43wEdrgR5mv2YIqoPIp8Z4XNkKAiJTwj3FFMjIydhTH9WkEQElShZ9Xc/v5lLNdV5OThRoKLVPdOA0adyXPl2G0BhRYGodf9EKsQFbW9nR00g6q+NqIrYN9GPIAdT6B+U5a1KayU0lkg45q0spD9zNoPRjzjIdDg37yr8FD1nPGgGSqXR6hA3eFgdyL0RKEOP9Up3xRbUu5DzrYIQZd1ofZQib/SJbSjNqo+uO1bRmezTox9xRPf/2vFWaS3x6P83amJuCbkkhQOGdKlT5dE6h0wOV/yfkQa2n17MBzzhFMXn36Iq1z+KtNm9aw0pgIvCysGArQe2qKOKMuvjz4tkRYlGl17fvM8kMxYOsVhWTcp+pXRqkC6X1sbn27MGzIvA/eksSn8uD00kFGgrqnZWfAbXwVqnDmZ0FdSauMT0R2zp+5VP1ASwYKycAWSroGjVAl7SNl0Tc/dRREH8VY76TWwEwTw0kTcKJPL3eveq9aY7uW2PffSWYh9ekNqNYnz4SEjRxJMGCQzoDXPuYLGO7XYfjMRaB1RSMJTjEwo0C+snP082wtxEhhe1NCsqZlZpIQjhXRey9UN6I7c36rgjlLpcumHeZBNBVrBnWSzXXGZcj/tNnDuveWDl8C8wyYEKUDlHBBWcSDFHt3LM936qLMGZOziS4i9LbGTE+MMs11zkc9deEAGbb2GuwvqIXFDDADheqDBk0YLOXRm2Ir29FAthqGZxnNz30/ODsh6lHLo+4bb48LkADNCNIw5iSTLEEdiYsD7/+Bk4/gV+Y4UilNLiHpMUmIQYrarJPqzdfFysv0yztacHyZjKEFuVueqnNRBGiSYTvGcz4gEH5mgxp6O8J3fgbkEdmlSshEzGXAAGb31hXOv40tS/25FcIsWJdTRueq3mCjrZGIV48W5gE2EB6icp7jse40zNhLWGPifk0gQQ/++FKHbB6tOzzkrvrnZ0zEGnBwhaV3LczJ9wjao2nAZxLEuIAt8bLr1xz7/CXJLl/mVV48R4x2LSr6ThBqFVy5qlsKW79BCePWve7EQ/wbA+da+KxSqIItb94DTJ9fQgF0IwDQcyuxq8XIQs569ctV0SgcqAiCuClNuAB5yl+NK4d1Xe1nkkt2Cvhk/RlMUAsNNy3/ri5mE+ZETkfrbBMbJMmd599nJGHP7gagEubWMhaxu+bXb1RiH53DSOZoGJekxR3lfdzwwwWULXrkWl3H/RQDTaxOzJSgFA7mo2bCP4gGQoO3ixrBG3hJryYD2mDJY3PwR5/1JMXECpP9l6ao/8EDdOTVgjb8QY6zgXLuY1eCs6JNS8757kpTAoE4O3prEDI8Rj9GlSrlaxRsWqrk71npb3rD8+f/I3Vup2Nj+DNke7fjIPjq0Wi7Y7BBwW8m4V+aBkHRwa1KpOO/iBoQUQ1ZHm2FRbIOM7lK/OyzoKcgQhbdENYDZApSERV4y5pr+hsATIJhPkCh/OZNV9Z0neI4TsY757nmAGnp9lUKGw6HxmKS0Idp7f8NH/pFLUEw1VtVQiW4LoN3Be6rKae8rojwz1n/wgaPAINsbN2YEGCGwhaBdzmZ3ftsYJANiPc+kF+SL+vnS2A8J4p30ODtGi6ojhDraX9uwZynPd3E0pIZe9ZBjBZscvYSKLyXkl24Sc1C3Kq1JHb7dEbCIT1sY/RYGTjaCul9w2Qt+hSYSwfwkdhrRMIFl4vuOhdbrmR7Ghnk/Aq2+N5F6zd5GUMujrI+5KruG2LVyOtI+KI7Tu91NR/KM77EHIRjvTC+Sk2eRP0dI81r5oio7pZgBYQGctlRAaIy+owjwaSVzw8/Y3hGkzDyaU6oj+UNA1Wrx96YG9BMKMsbetxh+oJzQXkYImY77oqEGCAAJUXq/Pxx0W7TJZLwIp+cCb4s0JO+o+WDeDgKjBPfUochhju8IKZa6grYcOwGA3yFEvn8swvuB9b8L3dHDagBhjp+vamcoKaXpFreuPybje65/tmDCpU5q/52y1l+6ouKTlW7xIBSu9oaKHbbHcqEPuwhHvMM1wpKVZDWO+5f4ph4IkCMF3QoOS1VWMCctgA2dNXFaad2OT4ufac/Gtp3vda2Ab78q2nYuD5DpTK5b9+AGQOkiRemJs+8WUJmhcdXkkvLHRrEGmImOZAa198yx4EU7Pcd5LZhx6q314Oq2V3vYc5lWYIK0T07Rz2DULllONPQtMXVY3+4YToLw2ZkRQBmLWtk/IGB2Dm7WHTRwkjgfrG9KUm1txcnXmeriJyCDZDBZnZrbUpnG/SWLm/q3hfezWwErRFhgRAvKnqoPJM1DFtAuZqkno3o0vvfG6e3uCXDbNKPSvYlcGi4mbbRH4EKYYKCwKcCthK3yTcm7pH3+P4Kb0RD2ERjtkE6K463bW95H07G0Q/x6Lb0pOGKBby7C11a+NDFAIEPor+qOLYlLCmw4GGjDxlUx9XqlMEsdaeOaQk5jvnAnwDB/SaQijIZQcKVOGp52zn5jBSAV63KvcMOiuL4/VQMW1nOPgeP/FbdyY7jVF6uLmorwU7XmNLMZPqBkpsVP8uDdSoIfRK4z6EnQviWOAhSrGwiIsqvJw5XMp9PyRjfiga1EHhPvpTVv/83l6h4Cc9xlzVLLX8D5siQhlcmektMtjxlIlKYCFUtwHYV1Z/hu+e0HRPi6ecGok0bu8vsVHD5b1grdPlNkk0aAZ6aaR0qgMCDblCwOIwPa7htjFTKS8+tgYGZD+fY1SqFru8gLHLSbECz6yuDjNjA3NMqppXd6qmyrdIemp5foir5Sz6at7rnq6onMqDyaQ9gSfiiHeJKg19+HZ/1xs+oyA95IA0I1IpH6uk82bklVHpRJvALkCu/YE8rgQFKw7lKVh2cr3FS2+T8pj2q9Tc0cC+Akm0XLFCfOs8xxZR/OobfMviff4BpyNmWP1HIoHeovsz02QapZZ/NH/SkMfoUKKWnCSnBtPzCs6uI49ALb761F6kPSIuxq4D+xJd153Acu/0j67dD3lXtlFtjjgG1GXLZssJYmgXMNVY92FJKykdIciwEO26GBEIO018IcdiMcKsgAowfEKxbG/svD/WVmcarkUkuOAnKF3gp8fLxuNwX+SDp8gIovZNvoDz0reeeVnPvUflaXmUPB9qw29Kb8b1lyUmX58gIJSytcfwRSz/BOyYaNEM1AgVufFD8T8TOeAyZS8+RVNFYxqQKzk93zbJjwGEKYMVJmA0qB+gORNCRzjxTDRilE57J8c3mIbtoiRp4OBXpkToAa9LRWgFgq6EZkzoQ0cgLa6xoTOkufc1binvlopS4Ozt+1FEytLwgxRmAnrz+oXiI1Sj9Js+HiWs6qK0+zUW4DkeSWH1F9WdWl0zGn5BtnTuwlA8pDglioUh4c/QRRiONnlpwkc/ybmVD8aFebm6kdhO7MuCeOiS7xiA6IePg9zobZigAaa57jpihiRfo80qrzf4KRuKGCNChy1X+rXUzIC+PLCaWrkcZ3QRvak0nOHHunaI4GFnBfdyHCUJeb0TI74ZmNz3783UWlfPQuo3kpuzKNVIzzSOuPgX952vKzOKkgDQYFPGXFY+etWGm/11GP6zsfx/4+82e2wSx1Sj364KhUWR5FEeqeEL8CyYpl8w4FsEJzdOUEQtt2IsXnTFDWXNP7SIdi+pRhaoPmkkdSmKQxy5o4jr6hZ5NU+fbU/I1rLbIy0p3pYUrDC0EbvuUOIFWbXKGzYvQwwJcKCwf7S3W9jsedOFUBzkK1uGn5LWyXZ7oNPJRu2D12UNgYkpA9N1hFSD1+fVxHcw+4sdRz2y2Op3szyeT2M40yjSFVY5zzJ+hrpirwnFnglEbNtMQ/gLXNtAG1zZMG6VwR2dy7VOrlmnilZTn5VHOtS8s5ovAe3D709iB/mQYCLaD/21hPunSPnQDQoO7qESQ1+EtR/8mBk7cDQZg364eBwoEWEdxek49lmZiaNYydewRMHwbkqPqswWpmds5rZDynyHGpmqgOgshtaKPN8uPXuSVx6e5iC5ltxcstAgIlr2n6Z/uEAtUuVWKKr8GZFgBoGTxtxINFIQ/Ux28tD0qn5Y2PDCCpbXawrexI1XQlt4tTmVasPz9llv0UyWE0tBNFb7a4wUTY5ibZOn51Qj5cgPOK1aVtg1m9ZHKgmudJ3z9MJOogdyhiLTjq2ajuH7169AwmSlSQu6tWUIMEsa0+l2RFmYPmwofo8+HkwKiO87sMJHvF44W9OhV7OgMCyY4B1gyca4lClmJSHcx+iAna6/X6H+AMUPnOr8tTLce/Bncsb5WkQ6Vj7BVrdCt1UtJOSEBAnqGa3bX74P69FldWcfWxsL/fpIGEIYseYpKfRl4fSuHp/foNSk1FC6xEQuzIRbvwdVbEoBwjFPRjjdaDH+ZyAxB41QQVMlv44Dx8RpQrF4QXiPBbZz+SdyS42XDPk45McbHyqeSa5tlB7DHdtUsjTFmxK7bJvGRdafFgfBrAInx0tpYTC83+xPVIV8e+S1VjSXZbwvJypWKTp8XwWozR6kupWtDEiI+P1RGiDswcdbUxuD6SSDQ/RLmXkcMwuH4ii3KtT54mWv6/2eZ5JWPTBgM4eO26FhDlG6bBOGq+KztNKAv7XSsmcgcfwKhnHhiEf9I0nDUo5zah4vJ2dz8ZTsGizdJh3uC60b9TxUpWq5eXiYwVIpXZWaHvvpkPPpRK9UJF3xPHKKkNENSVPLIeMdNW1eBug6QSbxJifTnJArweYleMMaeBgNDE4hRLlF95XmHw5rqcN08+DcxdeK3m1khJg+zAJnz6oZpZV2Hdd6f9GDiuaycs3tsafyYKp6EWj22GKqtPb0Mnd+9Eh9vTV4hKzgRZrfEGqLIH6dfwQDUcOk0+lvYBBNr5iOaCakO/COEL4kSn96Iir/EJJhIUpq5fWI/GBEdx6exeG7MxJPTATweRxPDwi6dma/IMg=?iv=wDDS+Cbpqw1TX2BT52Qf9Q==", + "created_at": 1690251675, + "id": "fe6e8976ed1f8ae9906457bf5a0f7ce6c2382844872628aed2b1b66eeff79558", + "kind": 30000, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "238d972238ce203e5d6d6e4b6f9d7aa3f815027b0e9adae80ae64ea7d59e6b2df08f4707962303cb9a4eeb19030a468961dec44590892c2af4c7cc5a4b14007a", + "tags": [ + [ + "d", + "mute" + ] + ] + }, + { + "content": "", + "created_at": 1690251675, + "id": "8dc884509db4f009054d39c4fc6873245f6bbdfad7322f4dcce8f9668d1b4be1", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f08f2ad807861b07ae6ca36048250889fa4bd88c5b0d767d6b62ccaabb48e602c5fb86a5cb58e759843abcf23755c99e3fe1709a1c1f86427e540ba684ae835f", + "tags": [ + [ + "p", + "2065474608976bb14e103389871a8a9d2c91ea1a71a49247228d8ed104afb068", + "nudity" + ] + ] + }, + { + "content": "Tell them to wear a contact lens.", + "created_at": 1690248352, + "id": "df011422245d4c13a97f589c45296cdc8e033f778f3ec62a79f1e601a6361796", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "73f25c2c5344fa80aa61af19e32c8675d44c7e9822fdc00b6998c6151aadb23e8ccb9e0c01d2178ebcc98d1c1061e7f520d6246a2c4906e524052d9a6f6f22be", + "tags": [ + [ + "e", + "2a6f72d18ce554ad81a90a552dc2d5e5e1799b3801139a299c567c20bbcaa615", + "", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "", + "created_at": 1690246668, + "id": "2f7d62cd826a517f6153da75112d62bb4591bd7a9980632f11692653ee176fdd", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "deccb7dd37b7d77db7712d8dfae4742274644bc443064c8c9237ad4d2663f8dda75d670faba06785bce2e4eab532aef3f3a2bae6d4e6d4b3fadf39addfecef50", + "tags": [ + [ + "e", + "db6e3abe953de42ee3e4499c298973d0c957f346e6a56ba9681d881a5a1c10b1", + "nudity" + ], + [ + "p", + "7b2dd801b71077284f0f64333a4a9e7c6a32699b68fec8776f86f90ab497109e", + "nudity" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1690246668, + "id": "10ad1d3b9fae2dbb52b04eeef0ec80f3347f4675c5c2084f752f4eaaaabfe152", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "90be5cc5f4906d57ab629fcd285aa74b6e8bb5d10114e6c6b2f7daecb0b9b7335ac86f9390aa721a0cf3f5d997590a88d7f5c1b193a8e9956e526e80305164db", + "tags": [ + [ + "e", + "db6e3abe953de42ee3e4499c298973d0c957f346e6a56ba9681d881a5a1c10b1" + ], + [ + "p", + "7b2dd801b71077284f0f64333a4a9e7c6a32699b68fec8776f86f90ab497109e" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690244222, + "id": "111997ddef6b12be593cb96068e0d016be081d4a718c5cc2c618d9519f1fce57", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "23e2c107252140431c0c845c38f3cabed50113ab04149d100ff53b8f30f5fa8d991d3f0c59573114577c39d38bb0634dfaf89a5c802fa5304a28131a31de56e4", + "tags": [ + [ + "e", + "3621f31be0bd7c5980c3497fde8f6d1d170641084632a62178439871249ccca8" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "I wonder if there is a crazy person out there to reuse our jetpack compose ui elements and build a desktop app with it. ", + "created_at": 1690244215, + "id": "bb42bc6543d54910a38cf2a213da81d4d13278d130208dd05e56b16e6a01fc3b", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "814a28dd2fed0807907b63426fe61517f9a18c10dfdad23481919455282e076436c2364b3d1802608e3da96772dba9636c77822b3acdffd3b12b37d8de21e573", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "Devs and designers welcome. ", + "created_at": 1690242675, + "id": "5885b2edaef4155e17df4e0dc7af9db4121165f0708652955c5d11957c871601", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "888aa55504da091f0d786e330df76b2c29d8349e034dd529aa583fbca266b46d6f163638b1c67384f3568f9406266a37d3b4eb8435e2ba09784914db0ede74b3", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "FbLT7gYTHET9t9YxQOCC5rVKnX4AgpL0uZscQbjdqdmyK/HpPN3ZFtq2AAUqatmOV7dg2oOIafZ2sTYFSqpnXw==?iv=hvlO/iOLcRyNPebLu91jLw==", + "created_at": 1690242653, + "id": "c2b2c99123f9509df30ad497d5c7497e3dabed7a1f478c015843e4bd8de8772c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "add8ac7f7ad0ab40dcdd158c2fd33f66f2f0ef113a296ae372a98e997950af6c4027e666243abaab264c86e9a1291999edd152f9bb603131302721d8ba29acb5", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "ZYM8lrGBHNK4kHXEOVZzN74I1UfT64sIkxDRJWopfnVSr3oxREm2+QAH5JSCzL5kwwLWUMZFueIj9s8178zs2c4onTx7JDc6ByCZGhjbY/gvAo2yK23fChDGdHoQPzqX8p53KTM3a38XHA/sNEO7YvKsoYd94R1Q0UZgQiF7ivVb5QLDFgyTSQVfKiH/ca6I?iv=d2t4j8CP9B8pJ3/cfoT6MQ==", + "created_at": 1690242638, + "id": "3c8f1e613e96ab9dd7b35573dd938cf21877df1dded96de49e74456c39470002", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b6acf30bd79d64fe6056ea864f9e1132daf296626147df9afcd161a9406fd264c3f0106004894dc0f6310da30d76ae7a4804726b1d1d655ba193e008cbdabd06", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "+vCJukCAGqBNb4lXkeHcUbWo7Co8S3U/1hr8gbWdgx8=?iv=pYsP8v0FjaupHym497BEBw==", + "created_at": 1690242635, + "id": "6bd12d078b1579fb62f3a8bed7bbda20af983dbde9ee36844e11cff195c101b3", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dd5c8aa6588d26c536d1dbccaf84e1208b18d411b2080c971a5af836e517a97ed20f44638dfe8d6ddc7760c33e95a37ae64da9dace0dac229ef79808564dc9ed", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "BRZoGneJdOYKd0brs+QhEg==?iv=SbDOAorgoLdzFI1CnvDJfQ==", + "created_at": 1690242630, + "id": "682b4eee106d4dce8e6dac8c736abc9f16762b47934c30b12447299b2cee5387", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1408fa4fc6271e0ed4b90de165db36850ca4d09f9ced27e35179e3360d8180731df2180e07b429f76e1ac977cf4ff981a5220e3fd5fc6bd77c72c20dbb039667", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "IEyJCe4nxKc3oD9jgM1oORxiGagpXRf+veHnumcq9jN/7Pv+xruUY9rxYmyMbRw3Vm6tgusfzyIeaegKjfzzb0GKOjwkJ0ATdJcbJG8hGWV+2pMbxvRAKVRjy+bI8dvlOVGrhqlF5N97o4jcaTkPbDsBrGXePHXsIJau6O0omYa4sMSH6IIEeO3LHU8Qhgsi?iv=uwKdPGDkXcjSbo54u4xX+Q==", + "created_at": 1690242609, + "id": "7ecc386b5026262c448a0266cbc9fdb0c86bf5894681e48bee04fdf410210bc0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "87fe2a64db4728b2b7129590bfbb573303a6d825323438fd60d6a6f433a0722045ea04ef3cc8b0bfe36e4650a65875eb1e3d535bfda7b3905471d1a8ecce80e1", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Zzyg37FBq7HsOb9i4CqR0Q==?iv=I+N33Ubcnk2BOzO2wZiYww==", + "created_at": 1690242607, + "id": "dcf996c003eaae0bb47986c539d997bcfa04d4a7b6cea4dd2dbacdfa30fdce92", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c5846d1006c431459e88cb3e50b49697756155c0c54d4a7565005bc7f89acb93d43658322cd5cbc107781df72c768f472c3f47c9cedda26aaa8046cb644e85c4", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "4fH6cWjO2fRxNwnpk/WEfGQV2d4BrdX7ePzU+u0eJYEQ3oQfSND3qqn3L2gM1o9q?iv=Kf5/gUOJ2HKcG3EQbPSjfA==", + "created_at": 1690242606, + "id": "857e84d663936020a3c3dfbf418e7af5ce93fc64f370368694e5e1966e6de565", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "39d1bc609f8b11bfc0aae3f9e65f43de46bab94370a44e23439b12e56b647c79169b789a802bc552e7b70b2790a1194073ba1993e2a7e6494867cfaa720234bf", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "{\"about\":\"Channel for coders and designers to discuss Amethyst development. \",\"name\":\"Amethyst Devs\",\"picture\":\"https://nostr.build/i/7548146ede4c08de8be3e19ee3b2d1e7af90fbef12d2c247f54e8aa23cafe763.jpg\"}", + "created_at": 1690242586, + "id": "89b9c20216de8fa443e1e10d808befd8cd0569f53158796b45202c82e1659b02", + "kind": 41, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "341bb56ae426689fefc6b769d5bc2bd3597b7f84725327e59c775a79c0a5d774e42ebdb9af6562e829f280f5586cb545be4786606c7c9291edf65395ff3c290b", + "tags": [ + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "", + "root" + ] + ] + }, + { + "content": "{\"about\":\"Channel for coders and designers to discuss Amethyst development. \",\"name\":\"Amethyst Devs\",\"picture\":\"https://nostr.build/i/7548146ede4c08de8be3e19ee3b2d1e7af90fbef12d2c247f54e8aa23cafe763\"}", + "created_at": 1690242548, + "id": "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271", + "kind": 40, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "edcea8f6515a80792fd9e28842a6ea8c4d0de17788f56c3045198242a6938f3a9d3fed5e24e5e58d516e94fe1041478a6b4074d7c10a887d3db84a6db7000011", + "tags": [] + }, + { + "content": "{\"wss://nostr.oxtr.dev/\":{\"read\":true,\"write\":true},\"wss://filter.nostr.wine/\":{\"read\":true,\"write\":true},\"wss://nos.lol/\":{\"read\":true,\"write\":true},\"wss://relay.damus.io/\":{\"read\":true,\"write\":true},\"wss://nostr.bitcoiner.social/\":{\"read\":true,\"write\":true},\"wss://nostr-pub.wellorder.net/\":{\"read\":true,\"write\":true},\"wss://no.str.cr/\":{\"read\":true,\"write\":true},\"wss://nostr.mom/\":{\"read\":true,\"write\":true},\"wss://relay.nostr.band/\":{\"read\":true,\"write\":false},\"wss://relay.nostr.bg/\":{\"read\":true,\"write\":true},\"wss://relay.nostriches.org/\":{\"read\":true,\"write\":true},\"wss://relay.orangepill.dev/\":{\"read\":true,\"write\":true},\"wss://relay.snort.social/\":{\"read\":true,\"write\":true},\"wss://relay.mostr.pub\":{\"read\":true,\"write\":true},\"wss://relay.nostrati.com/\":{\"read\":true,\"write\":true},\"wss://nostr.inosta.cc/\":{\"read\":true,\"write\":true},\"wss://atlas.nostr.land/\":{\"read\":true,\"write\":true}}", + "created_at": 1690242548, + "id": "4efe027b19cc54a8f4c1ab8cc9f89ae3d88b3d93cfde079d6c8d9804fd6d1ec2", + "kind": 3, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94eb3d76bf5be95ddb2f88fb9b5130316c8da617a5be637de39d46a81276df5068fc313935b6dfb138a75e28a3c3787faabd9509d503d94770b14aca46ca67a0", + "tags": [ + [ + "p", + "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e" + ], + [ + "p", + "b7c1a5ef7ccf5cfd5976fba251116ed3f3ae3d3ed175a9ce5e3d33a890b4684b" + ], + [ + "p", + "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6" + ], + [ + "p", + "80482e60178c2ce996da6d67577f56a2b2c47ccb1c84c81f2b7960637cb71b78" + ], + [ + "p", + "bfc6af8244dc2859efdfd0e81a6a79f4ee395bc78acb3202b6f287a1ca3a27b3" + ], + [ + "p", + "b9b0bb9edc3ecf01389c570ea6f44c55a80b9db066d0bca8a22237af185252e6" + ], + [ + "p", + "5b29255d5eaaaeb577552bf0d11030376f477d19a009c5f5a80ddc73d49359f6" + ], + [ + "p", + "4d62dd5e6ac55ae2405940f59f6f030a994ec2b3ecc5556c8dc542cce20e46dd" + ], + [ + "p", + "e75692ec71174e698df1f3d1f5771855bcc4e6e568489d2eaad489d81064ace6" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ], + [ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9" + ], + [ + "p", + "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f" + ], + [ + "p", + "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + ], + [ + "p", + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd" + ], + [ + "p", + "3235036bd0957dfb27ccda02d452d7c763be40c91a1ac082ba6983b25238388c" + ], + [ + "p", + "d987084c48390a290f5d2a34603ae64f55137d9b4affced8c0eae030eb222a25" + ], + [ + "p", + "472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e" + ], + [ + "p", + "e9c0a9c12e3a04edd79afc77d89b6c6413cc942ef9e61c51e51283cbe9db0c8f" + ], + [ + "p", + "b17c59874dc05d7f6ec975bce04770c8b7fa9d37f3ad0096fdb76c9385d68928" + ], + [ + "p", + "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" + ], + [ + "p", + "ad46db12ee250a108756ab4f0f3007b04d7e699f45eac3ab696077296219d207" + ], + [ + "p", + "1b11ed41e815234599a52050a6a40c79bdd3bfa3d65e5d4a2c8d626698835d6d" + ], + [ + "p", + "85080d3bad70ccdcd7f74c29a44f55bb85cbcd3dd0cbb957da1d215bdb931204" + ], + [ + "p", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106" + ], + [ + "p", + "83e818dfbeccea56b0f551576b3fd39a7a50e1d8159343500368fa085ccd964b" + ], + [ + "p", + "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63" + ], + [ + "p", + "c2622c916d9b90e10a81b2ba67b19bdfc5d6be26c25756d1f990d3785ce1361b" + ], + [ + "p", + "eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f" + ], + [ + "p", + "090254801a7e8e5085b02e711622f0dfa1a85503493af246aa42af08f5e4d2df" + ], + [ + "p", + "58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196" + ], + [ + "p", + "e41e883f1ef62485a074c1a1fa1d0a092a5d678ad49bedc2f955ab5e305ba94e" + ], + [ + "p", + "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240" + ], + [ + "p", + "645681b9d067b1a362c4bee8ddff987d2466d49905c26cb8fec5e6fb73af5c84" + ], + [ + "p", + "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2" + ], + [ + "p", + "d12feb34b3ee120423b818cd8dda47000639bbec9a6ee6d3317ea886ec5b084f" + ], + [ + "p", + "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce" + ], + [ + "p", + "1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b" + ], + [ + "p", + "703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898" + ], + [ + "p", + "6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3" + ], + [ + "p", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + ], + [ + "p", + "148d1366a5e4672b1321adf00321778f86a2371a4bdbe99133f28df0b3d32fa1" + ], + [ + "p", + "b8e6bf46e109314616fe24e6c7e265791a5f2f4ec95ae8aa15d7107ad250dc63" + ], + [ + "p", + "c73e75dba8adce307479d65575019ef5bee4dc8042dceeded3350ff89e9909f2" + ], + [ + "p", + "ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ], + [ + "p", + "2b1964b885de3fcbb33777874d06b05c254fecd561511622ce86e3d1851949fa" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ], + [ + "p", + "9b0247ea14361a453ea9d77f9fdabc858fac10063424ce0bb3aa560237ededcb" + ], + [ + "p", + "7d95baf9ac8486d06798348e580a944e2110740e88586af731dffebfc1301e68" + ], + [ + "p", + "c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6" + ], + [ + "p", + "aef0d6b212827f3ba1de6189613e6d4824f181f567b1205273c16895fdaf0b23" + ], + [ + "p", + "1a1a7ff211f3762d6a5a849e3d29d288fd43b618423c9d030ae7d64b951ea183" + ], + [ + "p", + "546879d1ef626692ca8a54f9943e9629197b97c58ea1b4a3abd6ff968b280d09" + ], + [ + "p", + "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1" + ], + [ + "p", + "fdd5e8f6ae0db817be0b71da20498c1806968d8a6459559c249f322fa73464a7" + ], + [ + "p", + "08b80da85ba68ac031885ea555ab42bb42231fde9b690bbd0f48c128dfbf8009" + ], + [ + "p", + "34d2f5274f1958fcd2cb2463dabeaddf8a21f84ace4241da888023bf05cc8095" + ], + [ + "p", + "597b42de56a9e0c19ee2d0cde5797dd58d48ce8dd25c732b4c873af11161f9fd" + ], + [ + "p", + "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d" + ], + [ + "p", + "0000006a13e10fb648049b5e78632a0c2bf09eaf6a9d55d081b82baf86c951be" + ], + [ + "p", + "9c612f8b770f0e3fd35cdac2bc57fcee8561e560504ea25c8b9eff8e03512b3e" + ], + [ + "p", + "4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e" + ], + [ + "p", + "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ], + [ + "p", + "246716c303cb0df99b45eba30ff058e506bedd4193957df34ba61fb1929dc73a" + ], + [ + "p", + "c5d4815c26e18e2c178133004a6ddba9a96a5f7af795a3ab606d11aa1055146a" + ], + [ + "p", + "df4cafee85f79769545851db202a8856f82dc917548093c760f3094896e987b2" + ], + [ + "p", + "7e2cb3b6793ffb7b38cecb1664c47f6216b4abec00c18a8d1eb9f6dbc0da1e02" + ], + [ + "p", + "dbf3d7c79a92995ccfb135997ac1612f41637c8a805be393204b3d1c2769d127" + ], + [ + "p", + "dd81a8bacbab0b5c3007d1672fb8301383b4e9583d431835985057223eb298a5" + ], + [ + "p", + "330fb1431ff9d8c250706bbcdc016d5495a3f744e047a408173e92ae7ee42dac" + ], + [ + "p", + "b945e8537bfd2ca3d36acc393e6ce948ad08471a44e5bc2f7eb1409cf5046619" + ], + [ + "p", + "8c3b267e9db6b0115498cc3efcd187d1474864940ae8ff977826b9d83d205877" + ], + [ + "p", + "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" + ], + [ + "p", + "25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8" + ], + [ + "p", + "c7d32972e398d4d20cd69b1a8451956cc14a2e9065ad1a8fda185c202698937b" + ], + [ + "p", + "045681cf8d47413904ff6429753e6d5a41e05e8d9d50dbd4ec0125380d886f3b" + ], + [ + "p", + "da0cc82154bdf4ce8bf417eaa2d2fa99aa65c96c77867d6656fccdbf8e781b18" + ], + [ + "p", + "9fec72d579baaa772af9e71e638b529215721ace6e0f8320725ecbf9f77f85b1" + ], + [ + "p", + "489ac583fc30cfbee0095dd736ec46468faa8b187e311fda6269c4e18284ed0c" + ], + [ + "p", + "6f32dddf2d54f2c5e64e1570abcb9c7a05e8041bac0ee9f4235f694fccb68b5d" + ], + [ + "p", + "62cef883863022a4f1d60d54857c9d729650702c9fe227b0988c0b6e36c4bcce" + ], + [ + "p", + "eaf27aa104833bcd16f671488b01d65f6da30163b5848aea99677cc947dd00aa" + ], + [ + "p", + "4c8b51820cb56aef2462213fe6927ebb6efda2b94450db5ff8c0b38eec020d89" + ], + [ + "p", + "57c4ec915796158cbe0b7763f6dd0fadcb17495a8fd8db8d27f0122116504232" + ], + [ + "p", + "6e0f5f69570cc2f2565864b0872cfff43219a0b1946e8d46e9d9de1beacb3ab2" + ], + [ + "p", + "58ead82fa15b550094f7f5fe4804e0fe75b779dbef2e9b20511eccd69e6d08f9" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ], + [ + "p", + "933d97b01272a03d281deaec23d446a3f9c72c8dee83e30081f0db191c7e20e7" + ], + [ + "p", + "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc" + ], + [ + "p", + "8cef25eebe711364a06f2e61251cb3361d05cc75283606bc52d74f674ca0295c" + ], + [ + "p", + "c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1" + ], + [ + "p", + "de7ecd1e2976a6adb2ffa5f4db81a7d812c8bb6698aa00dcf1e76adb55efd645" + ], + [ + "p", + "3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d" + ], + [ + "p", + "3356de61b39647931ce8b2140b2bab837e0810c0ef515bbe92de0248040b8bdd" + ], + [ + "p", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" + ], + [ + "p", + "d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b9003833fabff271d0782e030be61b7ec38ce7d45a1b9a869fbdb34b9e2d2000" + ], + [ + "p", + "958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c" + ], + [ + "p", + "520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626" + ], + [ + "p", + "07ecf9838136fe430fac43fa0860dbc62a0aac0729c5a33df1192ce75e330c9f" + ], + [ + "p", + "b154080cb49639bb079a6a53c1d98e7130eeab3c61aa95dd9e38f9e400027cc7" + ], + [ + "p", + "deba271e547767bd6d8eec75eece5615db317a03b07f459134b03e7236005655" + ], + [ + "p", + "e6a92d8b6c20426f78bba8510ccdc73df5122814a3bac1d553adebac67a92b27" + ], + [ + "p", + "bb1cf5250435ff475cd8b32acb23e3ee7bbe8fc38f6951704b4798513947672c" + ], + [ + "p", + "af9d70407464247d19fd243cf1bee81e6df1e639217dc66366bf37aa42d05d35" + ], + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ], + [ + "p", + "7cb13cde0670e590f02cbe9ea0fcf1e05edbc5cc8a409731fa5436440181cf1d" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ], + [ + "p", + "ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49" + ], + [ + "p", + "facdaf1ce758bdf04cdf1a1fa32a3564a608d4abc2481a286ffc178f86953ef0" + ], + [ + "p", + "5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54" + ], + [ + "p", + "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ], + [ + "p", + "e5308b9c04ec5cc2bae04b5a70a7da79b28501a0676c243375813eb0cf8f4c08" + ], + [ + "p", + "9b99745fa17b337f8a6857f4369a4e844f914bd739c41bfeb939eb0930d41d7b" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ], + [ + "p", + "9989500413fb756d8437912cc32be0730dbe1bfc6b5d2eef759e1456c239f905" + ], + [ + "p", + "e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e" + ], + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ], + [ + "p", + "7f5c2b4e48a0e9feca63a46b13cdb82489f4020398d60a2070a968caa818d75d" + ], + [ + "p", + "1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b" + ], + [ + "p", + "76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa" + ], + [ + "p", + "e1ff3bfdd4e40315959b08b4fcc8245eaa514637e1d4ec2ae166b743341be1af" + ], + [ + "p", + "3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594" + ], + [ + "p", + "1539b0762732585a92290b32355aee503a3532959657f554b12caa979097421a" + ], + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" + ], + [ + "e", + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb" + ], + [ + "a", + "34550:f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0:Art" + ], + [ + "a", + "34550:3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24:android" + ], + [ + "t", + "Amethyst" + ], + [ + "e", + "e89d7c2b4e1aa72e41979b5b1acf5dc4aceeaa97d0c40aaeebcde5cd4ff56271" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690242409, + "id": "0e184dda2b69d84cb2e0ae4ee2db7fc936ca33345f2899dea56bed3707161a06", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ad4e6c68c56053aa6c7bf62e379fab9a8bc5379fcb9cd413a32e4ccaae77a4b09b6747588006dc386a091fdc991ba84faae7e7362027f3173511f9c7f3766372", + "tags": [ + [ + "e", + "c37445ad00b5628b11bf4cf91e318f5372ac977d73778e8ee17613d5e357c98d" + ], + [ + "p", + "d2704392769c20d67a153fa77a8557ab071ef27aafc29cf6b46faf582e0595f2" + ] + ] + }, + { + "content": "Check version 10.7. it's using less memory. ", + "created_at": 1690242311, + "id": "7afe29b6be3eab6b18c38ba80db6b2e3e0154c5d62c7d10b96b4a73ae4e543c3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0cd914ffb217298fd679625ea29319e953a09006bf542270817d563dd294f9eddd432f03e0b702d6ff4664c0110f3c5505b3c07fe7f8a4b1eb5dd283d0a900d9", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "3e646fa6294a2a87e6ccd619c39a50974d6c70216eae79d700bca6c46179a389" + ], + [ + "e", + "f469473308fa5e222d3e5358bdd2eaf6b1c4c135f4ab4bf60dd6cd1c8ebfa7e2" + ], + [ + "e", + "649c1cf41e4c73a75604829b09a3a9f7042a685eaeb747a0158e2c70a6f39cd4", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "43d140f61e46792199dea8a8ab4634dd21f1aedaaa4fbd29add506f8029f1265" + ] + ] + }, + { + "content": "Does your strfry relay implement NIP 50, search? We really need more adoption of search. ", + "created_at": 1690241996, + "id": "0369c6437dcb238de1a62e0dc40432c0c3218774515b85bdde00a33921beac5b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5f4c9d0efb7f470ef3900e7b4082bb447276b8e33d89b64aecfc6f5580dbd0bc5246fba629504b0cd8f7cdaa1b6d0ed1ffd1ae2d528f101c34b4403942d95541", + "tags": [ + [ + "e", + "296e0cd484e6d03d7e316cf0fb7dee81c159fe09ff9497165f3eaef60b8515d3", + "", + "reply" + ], + [ + "p", + "d2704392769c20d67a153fa77a8557ab071ef27aafc29cf6b46faf582e0595f2" + ] + ] + }, + { + "content": "yWAWxGt2MBu3qv1x45J3CEVpaHvQq1lV7E1BuNGgbLKZ2RYbrC2mNJcmG4TRup0s?iv=sndyUVWm/4nas9Os4pF9Og==", + "created_at": 1690239415, + "id": "257c1c59ed183315bc7e62b01e6bf4c6f60ebd849b410113f235cd332438009d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "53496c038f4f10700b95c8c665a637522c417bf560624e5ccb814c2d4f36e5f8c7862d70fa3a7c5baf9ec6f38108a327a3966a9f927890c0cb3764df125ed850", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "7PIEPW2iCjCHl16lfM4WwnzZVxdZmAQKwu6iXqRlUQUOs5mTn0CrgPwRUw/kyfRJ?iv=2aJX1EIfi/f0hT+pcBHvrw==", + "created_at": 1690239404, + "id": "222a45a2ff03a0323452d28f264c454c2cc776d0f447c6a3d1cc8a3851d7856d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "99eb19b3d3548c6e91497aae7c8df7c467e1959c958be85336b6b16e50df644cfad4860c872eeb6fecaf3c816dc4c2961da9944f65b398f4aa8cb0e1c30b99fe", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Play has Google's in-device translations and integrates with Google for the Push notification system. \n\nThe FDroid version does not offer translations or notifications. ", + "created_at": 1690239264, + "id": "4a3236c13d92cc3cae2017b186a114d1bea42452ec0da1d7f7b6c37d691cc5be", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1c820adbaa8f6005e262c05843ae9360928a33f90e313589c98f5818420ac458e49ea1ba1695b3919555e849b949cdd35d4e13889143bf9f5afd0ea16315d601", + "tags": [ + [ + "e", + "19c3f76a4283f23d1f9f422eb114864ff4ccc56d47c6f6e4f8c10d6198bcedce", + "", + "reply" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "lutGFIB1gMuvUV+eCwKXKNYL7z6TIm2kDAlbYQVPx2GQ38X02lKyoY1n2hFkfEnUPkVhuXzDpduaxSXtvPkyhOPgWksP7H6jeOicnK9hppM=?iv=HME/2DSrRY30pbrUFJKFlg==", + "created_at": 1690238899, + "id": "39b67b4dbc474ea200d367c0c9a61556fa34b6867479ed4fb577f8461611a2eb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0bd1227154260816c06eee5008a7c9218032b827c2f6d4be9e3428e63b8735f47afce8c5fa6fa4c6c9df572385010dcf5c405a89ebf1776312ff6fbb2d7e4329", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "2/8yfvCiP34LktEny5ATUg==?iv=UmruzcdgxrWaGP0yiKeb8A==", + "created_at": 1690237129, + "id": "b73fd5a3c23b46473847cd3a4b4ce0f1f97d5dc774ad9ace37a99386f84298a4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2af9b7d79b24f5b53a916f1afe8b56ce9f9370e326b28d5dfdf5120bc0ef325576eb48efb4d108b33f8752effff8331329d704539a616afcc4131d8d33206547", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "Q9dvA4/+KObTdjEDP0rnDA==?iv=xiQVTdvQrHxPo/72WBkT8g==", + "created_at": 1690237059, + "id": "8d9ae6ec863f8e8825b8ef87444cf994127f3f48a379b50ca2a3400cb6c7610a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9769552001ddc7666d756297d8de7654e202af09fe0e6f2a633affd452abfc04357fe1a6e4a9e265b27c2873a5ef385fef305866bb0c9dd135f775a407b75c67", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "C+LkAFAhExz9RWkcHhP3Eg==?iv=skqU97xEJJAVJfodGfTJlw==", + "created_at": 1690237054, + "id": "a438e87973913596c44ef1fcc3476217af9151c1eb924bcdd2cbc209b0a5746a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7202d13005954592606d39208c59cc172e870ab5734b17bc3bbf6c46116e1e184cfd524ef76be0d48cead9efb408686080aa758fbc3fe456c02d82c39d1d3dc1", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "bUp6adO8XbYh3Sr1zpH0Tw==?iv=NFUyntsKUNXwb1GCVHrkGA==", + "created_at": 1690237016, + "id": "9f231718910c64f0fd5815ddba10b6f86bacbb554f47e02e66c91527de9a84ff", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8e0a14112190e4dad8552470767e464275b8eb66171a46075aac39601b0b814c0fa64d54ddc24adedca85f6a270b8f69c332f7d4e898b1e136043151d432777e", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "You would write a message and the message would not be able to be decrypted. Message would be lost. ", + "created_at": 1690234459, + "id": "ce6fb302782f0ada5c5b3ffec15a906b81ce681a8ff0bd6eda7ddf5b8a688538", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c62e0d9fbe9cf9d901d50be9a06ba57ad45b12e2de3c11477d71966a402d1e93dcf0c9a06c8b2635ec1445581d3823853a1a9edf60a57a2165532a60965830bf", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "bc4b1cf5a43e096cb581ef42dcf175781b767dad580f4451d61538ef4646c71e" + ], + [ + "e", + "3f1570f86b94170334e666d69d136a2181c3794a15cdb31c64ca2083ac3e653f", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "9110fe860a5e40a41e03d52d306a7b337ecf32d1090812ceaf423ec9b94954c8" + ] + ] + }, + { + "content": "I used android's base64.encode instead of base64.encodeToString function. ", + "created_at": 1690233995, + "id": "bc4b1cf5a43e096cb581ef42dcf175781b767dad580f4451d61538ef4646c71e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f84634af97f84d1367c715079323912aa1c4e95c075d18891ed5fd26f347fa2c406722be048abc1f3c72a86ea8de970b4afad2ef83752d52eb406729b9a01ea2", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "d86189ed8e68f18a3c2578f30a2c210cc38aef8e2acea0d6161e2db4a83994d9", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "9110fe860a5e40a41e03d52d306a7b337ecf32d1090812ceaf423ec9b94954c8" + ], + [ + "r", + "base64.encode" + ], + [ + "r", + "base64.encodeToString" + ] + ] + }, + { + "content": "Qz4ewQ+n1+swMl4fTB6D5A==?iv=bP1lZMkZaTb+/TvOpcaYOQ==", + "created_at": 1690233455, + "id": "2b9a446fa07a6f76ab0efd6320f86f45c3724a75c1a7127f479a5772c10ec0d8", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "66a171a9de4a483d2f5503eeb559e6c788e14a4e0022e94cf6ac37e1edbe06c291814944b1843a4c9baeb8010c696cecc0076190b22790bbd076e209bd4beb18", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "GFHPMUZu/xt52Emyqe2/yOgPgRyeWFlJrL+1Y0qZu7WZj62+TMnyYzbSh45Er8qiu5HMTd3NBQDGdb7fcpqCvbqYIM9i+GZTaUGLxK4kN0IEx3uToJBURlAsvXQkoBZB3r4hx0nxYpMMvZ6vF0G14A==?iv=HoxnNfugsgE7IR3UsrlW5g==", + "created_at": 1690233445, + "id": "78bad12a81ae89278fe090f75f91c59f179366c7cb2e8f137af78500b69500bb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "89ffa0617d9dd878a893848d3d2d4a891178d9785cd5b66d1c1b94d5ee1959638224ef5b3ca8a6fdf7fb3d3dd9f802f9c358e513a325abfeaa537ffe6e5e96d6", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "VxwuKCcgYzAgWi2niqNgp5PDat8/kgR5kgj6os7tBTKDJT3/gOqedH8twDrv64BZMZ3LvtdZGlQzLIcgNE7CAg==?iv=o0QVnuaCOW9yrN67IvWZCQ==", + "created_at": 1690233329, + "id": "fa22a5e06437a59b12babf7ca2f213817137741062424da50bab867f668da877", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6b40692264d9b0aac45d8272c44a4bd07bf3623ce9993bf3074892b3c284e90353b4d022ded1537bf4023ec162cdd259fe023a19dab7195504ede33fe2f255a0", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": ":inky:", + "created_at": 1690233253, + "id": "d41516e423d2b7c3f4333c574f4120bc16ff6a820e42e2b946a389269a87267b", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "891aadb039c5e1e887f3c689e274d9b17710d085bebf71e61695020d74ed6950ef65699e7a0477e463b457b2784edfd85047a22d12207c5a60978c5f0e386eff", + "tags": [ + [ + "e", + "7d48910e219b9145d0dc6728032f393dd6ab4fc7cbaa1f230eb0e70b6a88cc44" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ], + [ + "emoji", + "inky", + "https://yunginter.net/e/invader/inky.png" + ] + ] + }, + { + "content": "gP4BTdaw8HAFyHiTD1l16g==?iv=AsRwobD8FAWgbZI+GS24ow==", + "created_at": 1690233247, + "id": "25fa7b6874d69b4674b5b835bf6e5557d51abe7df38e7f6da991c2179abe7135", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "48ebba404b944c20cb201323d7a729a6482895ae0d05284d752f6d8b22368aca598cd931173cd34b5c31a1182380bf6e33df572608ec32b92720170ff9968609", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "S/toRHn+x4imuEgpE3QBog==?iv=FcftlhIXXfoW8Jt8IAWWfA==", + "created_at": 1690233237, + "id": "a98032bead45c8a3129451dded37a1eac6e01da6504903c93fa0a1b62d59247e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4e556f453e9de83f83ef00f4370189735df123e41226f8a33c9927b02739aa5c9a3b2dce93e5cbd698a28192acf77dc80a47f0be880201cc5e50a6ddc80ea80c", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "not really, the F-Droid version does not have any Google framework, so it does not support Notifications and automatic translations. ", + "created_at": 1690233206, + "id": "9c597be469ced87df16e5085a19810af53ad36c5cd6b2d1f0bdbac24b4775e77", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a368541efa152d50641999646842fb571dac8a24b754e71023cb30d33d89d94b87098c408a3a2b8e68521a13a771942d919f1e9fc5e2894c26181333213ae97a", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "3f3832ea69dde0c130aa13be5e9461a48548322be88f9980d68f5bfc0585c4b1", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "KdASbRnhHUXkBGGr6cZluHyz26/QGGb37ud5Mi0PXXrfNaqv8SJFDbM0SsspYGfkphxbkm+OBtqQ/uCOT+Kfv6yebKDzZc1OFFqtLFQ6FhnoB3RdB2zlzx5YLeBm2+zi?iv=pFCz97lTnwgdru7KL9vVNg==", + "created_at": 1690233173, + "id": "d4f4bf086674bbc8654ef1cf38d548e067a68fb7988c530c2f5435906c063c35", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6743508bccfdfda8fce6e04e22b39f66ad2a7e80dc884a96b6a5e8e74c3fa31ec3332c6544840816ca43caf2e0ae8aa4f7808c26d788f3943931f99eb7fe0416", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "f2K+pgicmByWLG1+7gJo1+gHYNOWUMZ+JtDF32q/JYI=?iv=2Pq0gyLVHIi8Z74r49cG0A==", + "created_at": 1690233137, + "id": "e4b88e84fcb8678183bce2e37d1cac638f752e07dd8c94a74729f65862de4616", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d61dd8721786c9969e0359a926f736a4fec4a7608c158aaef480fc4326a54541299ab3ef901613498829e7e4701851e43ef454dde8502fa42e2c955cc56c5bc4", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "bz+BVeVRvzE8GMyhbckpyg==?iv=i7V1ONc6Sfrao9ADzYJ0TQ==", + "created_at": 1690233110, + "id": "9c5fbe5cdac9797ad05de2ed9db76a5dcb04bb95bedbce1d46247007bf4c3d68", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "293198a3b3a0c3d9ca30e84b4f03d39e3eb2536e02c91572b41e55c6680d775ba611f1bbf0bde379ebe774f1a752716196ffe4a13c1818f0b5f702f209ba0651", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "+", + "created_at": 1690233094, + "id": "eab230246fad596b82ed49e50eb6c4ca97ad39a63f1170e7b3c630079076b294", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "771b470b3aae05e08353b4259c3b4c0e339dd257759f2ea3b75518237c775b3f15be3d5c188cdc72b78dd666e5e2c0d7a96681973ae7d92c531c2c217c418677", + "tags": [ + [ + "e", + "8e39d79d31dd146b5be3e2cd8fbc54dc7c0e8ddf1936e982cf223ac21045aefd" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "0.70.7 if you can use the APK. Otherwise, just wait from the PlayStore/Fdroid/Obtainium. ", + "created_at": 1690232704, + "id": "b6244ed1c0c63352a8df29cb1a689cf8d920681aa29d5b19d6cfb8d43f5b3724", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2abc85bacebcb5da5ec1c8fd88fcbfcbbf789a6d53afa7d9f3f95ba377a4aa940dcfb4e6286c2eb56cee6bce2a975f2b24a6b7dbcf14fe0c884c7f1fdbbed7b", + "tags": [ + [ + "e", + "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "", + "root" + ], + [ + "e", + "3dd05c239b3eead3467fc3966010c9236722c0dde65a2fef32701ceca6845255", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "a85f28306f6739230c0b96483f33fc894058c1f7e2248647c61c7d475d3db7c7" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690232541, + "id": "98450fc53c6bf56129545c1cfd1710eee3c337381171e996a7c2d827ea1f550d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f0a92f7211f3806a685412084ca06a8d62605780d509f3f2f1846e2d08c1a19fd2809672a71081839b2d46d8cb9d976a2e3e1437336bc14ab616189b624410af", + "tags": [ + [ + "e", + "0916d8bb10357ef41e467ca5df22f1a5972848b6e37e53deaf776498eb9930e1" + ], + [ + "p", + "444eaf65c2d35511fd0dbdbb0fe3c74139fcd144de00d22d57819ae5fdc76ae8" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690232403, + "id": "f698c1f427edd31e0e568f9799545b441e4d9ba1fd1ae7283eee93ea5b921245", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f1345b5bea6f2ffe95c042a79e9fbc50cb81cd55e52a822090f465e06bf0e09eba273786a6c6081297e6a82b85d081438621c05ccb76a6c27ba1fd846143fde", + "tags": [ + [ + "e", + "05844e361974d2f429b58f3121098397c14efcf6b5f01cf12792d406c9d94da2" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "### #Amethyst v0.70.7: Coding is hard. \n\n- Bug fix for the DM encrypting bug\n- Bug fix for the back button going back in the stack instead of leaving the app\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.7/amethyst-googleplay-universal-v0.70.7.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.7/amethyst-fdroid-universal-v0.70.7.apk)", + "created_at": 1690232204, + "id": "025bdab0900f6c0bf2036713aca293e43a743e17108134b82816d2adab53fcb2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8bacaafc49225c0eef14edbafa84cc784973437975f44228742e91293f1c21b413ffb83324a91ab64400193c5765d3bd7d72478b40b912a810303e41941916a1", + "tags": [ + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "A/e5KObtOus9SzOgkNcHMlfQgCLHcWdCivaz3lfPoygriVTNoNDuO1XudVbM7ZY5ChnivB4u6Gj2odhYVTT7hg==?iv=SxkaG4il8/q4Zn2uTtmiDg==", + "created_at": 1690231960, + "id": "fb28220996d832bdf65a884c43c3e0fa1887225543313e7c223af1e18a3863dc", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5a54e12b362c66c3a4a4234d15c5f2a2f988a1786af4c5e171f86eed16a44ba327c80ea43f65c68a80aca17354be9df3a45fe00be09df3180d701164ebe725a9", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "1nx3MjyOTjE9i+BNK/hEZc4/b79lFMz08xElOLD7ogY=?iv=hwTj8iR7LfwqoHtLz3NZmg==", + "created_at": 1690231858, + "id": "f24bc09b4f67ac27baac41681704a97a450895223c2a3a1d2df7d95d28e91261", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "021cc84269b26c4e73ca5614030b49f18bfc58e65a92c3cfec8003d0210a327dd87479d91fe2e470e826368a1103eb05af1c018daca2fb96e2a316c2ef04a317", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "+", + "created_at": 1690231061, + "id": "30c908f6de4b1540adc20dd9ee8c54f954806753041c4e09f1a45475317ae2b4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f4121d7b80ff896340a85fcbf0812b78a7b0bd41d184bedc20d4cc3adeb57068fadc4feb615b25a42288d2ad34c7360de53d67440ca3db8b918091e527175be5", + "tags": [ + [ + "e", + "806bf35434fe8274e3e903ddc2bcbd761edc4cbd895e2a184bd879519b22578a" + ], + [ + "p", + "f1ea91eeab7988ed00e3253d5d50c66837433995348d7d97f968a0ceb81e0929" + ] + ] + }, + { + "content": "Lvf0zVwlwHUtikVDSv1RXA==?iv=uVw4/+FGz10yZRjdp45HCw==", + "created_at": 1690230486, + "id": "1834ceeff3d3c1a81054b9b4fb57f8ad08e277503ea8ca0800a6fea26940562a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bf4fbf6bab096234c166c1ffaf220eb91505f27eea0e8ccb4c4b2a87c00fb534f51662669b98664546cbce1324738d2ac227f7c5a035221992ecb4252d1accdd", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "73jzDyeieTrjiwJoTnNKZw==?iv=x4Yn/MQpDRXqOFJ8tN47Mg==", + "created_at": 1690230482, + "id": "060df5b6bb4d97478ed9f54bcee5cc1f5abeebf1fbf162f2bd10d46cfb3b7e35", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "01024d0a7c43b80be39140a28d311a66f86ac105cd465efd3f9698b4ff3e1b432924a2385c3aa4d757897e012decdb0e385abf892be4f398778c1f015191848a", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "[B@8d25849?iv=[B@9ec054e", + "created_at": 1690230354, + "id": "ce065bfabf37a72ce7ce604c69da4b6e7bf1eb175f1ffa3f256580a47d637c06", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f4558138d334b34b653c44eaa5df46432ce1b5a93b4eed8d67bf09b6ac07824db55c605cc5d177274a4766f05f2a3ad6f10f20a4bfe0b323245dc8b0cc70401c", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "ovBLFoTTdd8TOTzjD69NYg==?iv=Q6muB1flX0fXudU7Box7Kw==", + "created_at": 1690230346, + "id": "da53a14a794809086bc065c1cd60e2c0f42a5a94809cd6f60b14b6a2df2022b1", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a0314cef159927d1d2925942595034e2e7a3ee3f1e97161106114f9b40e2e9012bc76c823cd42f3370844b3cd154d6132da0439c9ca2bf94148928ed353a37e5", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "client", + "coracle" + ] + ] + }, + { + "content": "Folks we have a new bug when encrypting messages in this version (we updated our crypto tools). \n\nI recommend not updating yet. \n\nnostr:nevent1qqs0lvtq2hfp39hy9krkk8ztc8y8ck5hafs8sexs6umzfe2azz5vjcspz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyt233p2", + "created_at": 1690230275, + "id": "bbda567a7e9ada22cff285af2b7af45239a0bb91022355dba78e72a629f18345", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a206a2202235726776f0fe90c52f8d780a6c240ed0cc3a509ac4d444bb97478856f59d497774e5d769077d74deb863ec7278907d507b12d06d16aa8323be7d75", + "tags": [ + [ + "e", + "ffb16055d21896e42d876b1c4bc1c87c5a97ea607864d0d73624e55d10a8c962", + "", + "mention" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "", + "mention" + ] + ] + }, + { + "content": "Tu2w55+Du9shxbY5c/XJTAH7P9gpzolIVU40qRwMZr8K44HlgcZgXUnf+Dpmx7JdFTignWLS0dbc7c3EtV9JfA==?iv=kuysIu6gBOGKjEkgOVQTig==", + "created_at": 1690229940, + "id": "2e9380fa35888db71b7a34f718f3498537d8fa0f908eccc32bcdaaab4e3743f7", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3ae5ad7c3d55d7b6fde18e9f534494724ac7032fc6014d494ba3fec8f70236fcfaf8c50b224490d1ba4339a8498b98cb3bd7f9f9b8b3495580b882b655bb5a9c", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "TCnpqllIWnsFTtu3sjc7OQ==?iv=SGT6iPifAUL6ZaC/lOaDKw==", + "created_at": 1690229836, + "id": "4ac2618bda32746e23dd03d4f4b8b284c86e6f1c1b3729f755ef67c22a2147e5", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "07f8b94b38d9f9c13f2e6b05c62097a7f019fbe62c850d2ea19dc3e42a98952e06a42b922fe8ed14308ef0050cbb64b0dc7562185573ccf12dd18f5b1fa596cf", + "tags": [ + [ + "p", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a" + ] + ] + }, + { + "content": "nSTEB5yiTJ4P5rUuIgymnK8WzsU2D474gZuIUMnPpu8Sv/pJjmXqAUSnHvu5xVzC?iv=12q9C6xeozl3+7XGJOA5Vw==", + "created_at": 1690229516, + "id": "049d7746738bcd7e3d08699dfeca3ff343c0e87cd7f120fc88deeba0b7c45900", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e3c2791ab4e94d129a5b639dc95e2cafef1f891435cf994974d153c42e7b0c834f18884f0651d0ba2ec5dd8b309f4374ae402246bd0bb3d078cc5e686a491f75", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "[B@a24d242?iv=[B@ac9d253", + "created_at": 1690229467, + "id": "ca97e741fa2666883892aa47fa6c1afaf5d92e9b7f9b6777227de0ebcf947090", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f91893f237a82c249d3b950ab64fab6e26906a495e83336b93389a8f88130bf7edc961702b6ce0ef6e4cfd5992551d34c76211f6a974533b43ae24cd0dd2870", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "[B@30f641e?iv=[B@b42f4ff", + "created_at": 1690229418, + "id": "cee035666cd2135df68562f2a8841a9a8ee1fcbcacab0e2eb529735155d35809", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "38dcbbb36ff2b9a69292722d136381308f9d1dd71fc20305b6e3544bcaf9a491e78e7c5688eab8efbc306e6feb98fea6b9840bd8387742aeb4c931d099c2ee02", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "### #Amethyst v0.70.6: Slighly more stable\n\n- Fixes a crash when onNewIntent is called before onCreate\n- New Post, Relay Choice: Select/Deselect all options by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- New Post, Relay Choice: Fixes missing switch when url is too long by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Adds missing OptIn when using GlobalScope by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Refactors Crypto/Hex/Bech classes and dependencies \n- Simplifies relay connection status\n- Moves to OkHttpClient on URL Preview Queries\n- Moves to OkHttpClient on Image uploads\n- Refactoring of the Connectivity Settings\n- Avoids crossfading animations when loading NIP94 and NIP95 content\n- Moves playback service startup to the IO Thread\n- Activates Strict mode in debug\n- Updates Firebase version\n- Updates Hungarian translations by nostr:npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.6/amethyst-googleplay-universal-v0.70.6.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.6/amethyst-fdroid-universal-v0.70.6.apk)", + "created_at": 1690229318, + "id": "ffb16055d21896e42d876b1c4bc1c87c5a97ea607864d0d73624e55d10a8c962", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2fe7528a93ac8f89d1d1af8a112e3cab60e616c244b29ac10b87afcd452f7f2e55916381ccfbf24169a0134e7e61e3ca4d39b3fac0acc4d7209edc2d03b4532a", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "738f69184aeda675002b687fe47c8e9e2f7b1a267d6f9145b1193312f97c18ef" + ], + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "f0rxFkXgSlpTYJRIga6xkw==?iv=pkfpyqUbMS9WPb82bwVHBA==", + "created_at": 1690228620, + "id": "8deec47d7370fe50914436b50d46e3e9781bb5a01b9cdf57744e05ea9a44ac81", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "de222d3c13251b4f350050b27599a84577dc45865cafdd1802581eae735291346c4063a57bbb48d136ea307fe2564bdc7a5553d864c8e95bd01622fbed8cb24c", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "5IXoOnU0zP2tbig0jrI/eaMHmHTmqe4G0hMJM4JTvQMfnXV4ARJu7K5X8P0dtIFmuE/LJ/rXjjGe8KK6M1LSIbFoE2u10thYAGCspZTFmqvvaBqtWO1NiRlLoEYHK+h3U0W8VAv+/RsMRjUeEVEoVTeCf6FvLrFZpjL5x4pB2uk=?iv=rvsakuPh8qVCx3bTDHtXEA==", + "created_at": 1690228608, + "id": "b4babfe9e28154ec2c49e4ce6ff357946adc7218d8faef5fdb97a6bcc0aba755", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "69fe9bed3f5bf09a70cebf02f93a8bf7678ff2b6c9f536ee824546fd81d943009a950ff4ecb07f777ed3a09316f7bd8025283d2ab40c7b60b44236a77e5c5237", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "e7YlqY3i9/YsfIv436ynsmIgk69dMdhiNLpU5Im8cv9o5IigoU9O64wUr/8phC38KRCOqL379X03kC7/MkoX1Q==?iv=C4RfGcjbmCwARvXaUJ3Yog==", + "created_at": 1690228538, + "id": "a3078be67b927101c85c94b18d3612186adb9668d40b8883ff3d4c158774cd13", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fb4c05e3a908b05329b306ecc418afd06f745bcf7be1c48e6ebd4940c98787c557d6ffe88b697916879770a3543cf56d2939090d07e00c67f5a1522b0dd1c7ee", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "wFpRKzRmd0CE8Er6sSdFOaW+SLRywldsT9xuc3gyxJ0=?iv=4n0/TMTuAAmzPgZMQLqixg==", + "created_at": 1690228503, + "id": "04fd69bcf970a32314cf0d89075521e349cee6e1138847cd30a91bc24f6359df", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bc0182f52b3135918c9de527bcb295571970940586b1a563b59edd81e64b9d81b72a67b701f10d3a78ccb4239088d93fb5506e84ddfed0b4fc5df4f7ece6059c", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "G2fjhfEAILx+EERl5rBcT8MiB6OJpJO+YSBVFwl0NHg=?iv=jN2FU7QB9W8RbTfPSMuIGQ==", + "created_at": 1690228433, + "id": "f06da5c6ccebd61777bb9ea7403f8cae990348e04eb533a321e603d986d5f818", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "331f43a63744baaeef49eb46e8308dabe78dd699a81bcd9113724924296352225f0025daa5cf0fe4accf425224f3d817861dfa11425260f47002f8a1ad6b8e40", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "Zv6wgC9t3LH/CepLmgk2W18K+/TJSmVd5spsy0DXu/z9ihTGoyOrUQ36qwVGlXvV3wDsZNSh0GbKmIcqtlBUVrGmbx4xH5/SeT5j4M/VpQXlfK5sH/9YaSi8ljvx8ukKsJoao4abVsfrY88FbnZEhw==?iv=VR7r/xc6QJ2HkDfHLSsqXg==", + "created_at": 1690228423, + "id": "b4b7ec12901324d608dd17692f09c22c6b3e8b34d32cb68159c9871225fbd1e4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "710df5f81ac0dba5bc27dfa4272e9fa33e5057e1c573ab5a13cc4d3e59eef31aefe5af8368f1d95c33b9bf5aa1363e0c889a45780ddbadd151364b59ea78db71", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "NtMkCfX5hIj1XxHNo4bfPBzMGEBs9fT+OVdIG4wINrRh6HEs4Qz87Bn5bFkagTc6?iv=b/mnWmOf5k2wSImYITU8hA==", + "created_at": 1690228342, + "id": "4a70cb34f63ed31520032823021c713467df32bd648de0989b37f35326ea6f16", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0638f065e9d88fb8f930b2a5f97dd6a8845cfe3f453060d9e5b926d4866735deb1e63ccb04c8c70de43a5e0b41820d87125b15aef716e2167bd52d97e5ad3bd7", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "KsvZMEAGgsnCrhObxHfleeyGrrrUj0+7b6W/dmI7kKbgvZdRrxc+99AtetHSgEPH?iv=8Dzaf+ZPo20rymUi9gLMBQ==", + "created_at": 1690228325, + "id": "aa4fd0a517095fdf6e1805dd65e2933eb9060cd48d1057408cadd8f0bf0b4f75", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "70fca6c5e521629504e3e70eb57fd5dbd41564946bc53ba875ac8f47794b699c3797e4891326777d03451e03623cac3fba5dac221a55a8535f2b2b47496afd96", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ], + [ + "e", + "3b90e6bc6ed1dc8d54cd7d16b31fd55227ea422dd6d3877c6176fa0f1adab26b" + ] + ] + }, + { + "content": "hOoDDnlWlF0POet6CinuxFBVdITw1XpHmXkuFQm/ijXZ5dJO1kVbgcMStOh1ob4/?iv=RxSDrWU5WZL5AAwJpM6/7Q==", + "created_at": 1690228290, + "id": "ea66a129fd8ea1d89ae1edcf883385210b199834b7659f88925772758005111c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1032781f5e623eaa8a0b936aacf177f86d2620db8bb62a2e80d5d151ee020268b1cdc93f2ac6395175f55ecf62bb75f44c355a05a5a39b4448964c50eab0d2fa", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "OgJTFpAbiLGECgMTDUUqaiE7imMVLEo6xgC+UMXsdkM=?iv=vWJbTiA1NAH88r6W5sAkVg==", + "created_at": 1690228276, + "id": "1b79f0dd1cbfc588c73267a81d7a5fe339116f5b3e7482dc0e888ccb62c49b41", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6e0bf43c28dd4f887c5636d86b78cc6f5ab8b49a78e3c47998303d58e0b4750b7c601a36d77bebf37b0419d85d3b0b492f57f40c9fed6c934c68967acb2ec98d", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "Wa4cXONJIYkjZ7UF1W38RXF8RGOYF1Ga8H2Wu9f2Qm0=?iv=jz9nL8hnlLnyb0nicyHBiQ==", + "created_at": 1690228095, + "id": "071d645bd306db51ca237a9b97978b50553734023bc27866b5e63372e5325edc", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "51208ce6b3fbfd750ea7dec69988b5b1d5d460829dede36e9550f3c38102a275f0fb556879c9137cc0bcbbb2dac53c6f89ea5d72742ed00e715b571ecc502046", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "aP/EaL6KMt0cKss6Ou+Cj8gSeEiLNwz4lxBmo21lLp1Oxrbpyhb7AiPB/yaouzi6L34ERksQXG90Qm0iL6Yxmg==?iv=dJU3gVfMgoeCeTN7FmTL3w==", + "created_at": 1690228088, + "id": "a5464ad10b971eb8f6f5111bda5a64b9d76fad5b4bfdb9f26aa40db369eb56f0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "59c5f55ff8176d65b6bc2249ab42f73c53700a12aefac764eb34b47ab2c8497b59629a3b1a1f58f662c2e22f9df8407fff59d5765125abb9149235044b7565b3", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "UBrSmGFZyQMMAmYON6Z0ppQTcoU/35ZllF3z69xyc9a1TtJr+vNCA/9+FhZH5ahhIbjTE6ZRjk/d/JGyXbS3gmkVkYK01xGi2kkwVCW6JD0=?iv=05pAWlAlwMAVbWzQOoq7VA==", + "created_at": 1690227805, + "id": "58f3fd95d3afee12a33547c3f793d7a8def09adb3ffb6ef3760d69288c66f56a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d973fc6baeeeb7af8d476f13482c3d6a1d14e68ebcdbe22be72a31b11d9258e0b5ea46a84f6341028075d2c525d22ba27a3fe5dcf9edc71f9f317c702e9aced1", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "uppuL+KowTG72APQiuYQzEAKl6opOeCI6jaBifrt4BvrJnP0SYvg5JvAyijt6XRmAJs9HjO83GJ8nExLYZsPqA==?iv=CcFmyDxQnHbOUqNJDgbEZw==", + "created_at": 1690227791, + "id": "44e95b224b6998c70fbd3f20bcdd17345fe2fa881e0cdd81c80a10af00e4aaa7", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "368cb4004b40583c3fd71a37de17b4977b7c9d8bbfef8d976e79d090175d173b85b902f514b2ef2123af8c5fa8e71bcac2d43f72d56f3caf12de3775618d8733", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "EaRIVyoO1jSDRUYVqLN8g/yllaPPxFSAnmbTSdtyab6O8Y3YGMkRO0Wg1RRphDRM2nEaTZcngkvg40ayVQzgv1mTqhxkEiWP+YFFLV5H6Xc=?iv=IDdLzaZDUjIXAq4s7YYPpA==", + "created_at": 1690227754, + "id": "276c7ede298ca1a7b642c28e72cc9efe263e656e2e7c12fbe6607b07fe7871cf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "80997a5c0787a7fa042568bbb43c147117da062fae9801275015a6cc21596b20f6944eb51c34b809890a5d2499bc1916e988d57718e6663c6ce19d7f5127308f", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690227628, + "id": "efef075535252bbeeb40f003ada1a7f295f583a56bb38443778318d17b6a128f", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f5659f79dd35cb3b5ae418cc62a7e28284cace0ae69b66a2b39e81770e368bf6dca9dfbbacf6522914bccc78610c601d887af774c946ffd1e71f2e57908d783", + "tags": [ + [ + "e", + "23a9662d410b7000b2b387b0fc7edbfe709449e8af56cd88ab0205ace1cd2c74" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "cd7zf88DXaEqGtgPPfoyAL4HcfQy/q6DtAUhhu8MnJSTEHb3CKmN3uMi7EJbWiRM?iv=vgZdKLxwPhshs3Mh4O9C0A==", + "created_at": 1690227507, + "id": "cd53313a1754ba05f4b6ce93adfa39ebd09f3234f978a911fa054272296f0191", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b0ab7330919cb4698aa671989ffe34a4f0864b311d389c9935238fda82ed238f471ab9f211cfcd883866e7aa479778ed6522f32b35a288f9a4caecb469df1e9a", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "we5Ld/YAGVvuGerqzNpvT0+m2DKMxDuuEZsdj+ONxeo2NIzwzP2VfsU6gfu0MBp2?iv=i+YzLjzuAAtDTXNdLEluig==", + "created_at": 1690227490, + "id": "0a7a433e967a942c56c61148603568f473f0a05f7abffda23f493fe5c4dc385c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "460c4329424abf9fe6ab5dd4af6cedd96aa0d9b7e367159f61562c082d4d893142a6c07dc339ea320eaedf3eeb2641b344f26b7102fe1aa83c900b7163d07d0a", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "+699S2I9lB0O3ivY+8B1btsWTArJsDJJCrBn9FgU5JX/H9emLLrkzql41Q68FLgh?iv=hih4I9oS/uZGPyD1e4nJ/A==", + "created_at": 1690227474, + "id": "30dc3c835502332ea13fc39e86ba050d541408eaaa5aa8e9746cb552dfdad582", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b5b726b12a911c57c390293e71e1af2f7ec22b062d1ca70e9eb3c7d3d6c6d8027269cbd5b3526b7446b4fb9f437e3edfdc1eae09064772144db84311e537691a", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "1UIB/MzWT/7eZpeBwTDiL7rCj4X6zANvyLUjidDqTb5aQknFxJs6y+wMpjwoSiZf085nzV8EGMy0Q40+bZ5RhQ==?iv=V3+jmN+XsSkq5H/koJE+6Q==", + "created_at": 1690227338, + "id": "fcec1f5080b1a3804ddbfd8e8f4511767e866ae5197a541948fef8a48c97ab12", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "38596c01b3657c7fe45b3dc9b4c57e350e865acb0e4551ce38634c52ec7031c4db8d9f9a653072669556788ad0c10d1594cd143a56ded585319a44600b5c03d2", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "3xR4YgFOWvu+mwlBXCHBcxKkbImhQBAwqrt68/YWq9bhCLUnmAb3ug9hIm1vQACw?iv=FjLifC80ldIYZoUhlZUZhA==", + "created_at": 1690227306, + "id": "b98730e230d2e0d2a5989591828bd102b14c5d9daacb8bef89070c63a3c516ca", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f50a1cc8c049b954b51841132ea92e2b913c550bc81a9bfbb5fdc03a2aa018c233e11048db7b45a13826f3a5ddf3f7242f31f5d10aa1bd4e63ed5577c4183fda", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "oC5quZsBkzBT7QbwF8IaboEGgtQkfp9vsJHBhCZC420=?iv=I7afX/EYBNynEoXLx/Gfog==", + "created_at": 1690226941, + "id": "c1dd8bdc3032122a8fdcc7f646e35e499eaec0872f0455f61732e28b268467ed", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "afd4b73d65d83a14af2f8e33795a25c2c736a3241b670852abb254851e99fa2023a9a82c4d53b46af8a0348cad40c29fcc9b295ab01d6e8ebecee711baa12d27", + "tags": [ + [ + "p", + "b1f94c43bcfa4ef78bf24f9792169fd66aa644ff37e588734f1b42df2f319048" + ] + ] + }, + { + "content": "on it... I need to massively reduce the amount of memory we use. ", + "created_at": 1690225524, + "id": "b127a0cf66668468bb6a1bf83ae6837cd394edab9b0c9db875f68a29e7046262", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d956e9f64a6ce9bdb2332ea3c6ac9f2196a848ac011d6898083bf924d7bca3a377469afefd05e4baa8b28270ea6e4189f6109f42c83b05afaf5e265dcda3bfe0", + "tags": [ + [ + "e", + "b346ca28b9063b0d6d9d81715a716ee5472e99e7a0be48d1e53e94dcbcf2e807", + "", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690216404, + "id": "cd60ef428467bd132a1c9aad9f99eae847563c8ebc8eec0e41a753e1e060c91d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1d5354a460dd7e1a29a7e15f783bd456c47f4439679862a799e01c4ffdc6a7054440272d4600ff8410243556e6d653100063c78822057e24cfe402471acb1061", + "tags": [ + [ + "e", + "c7662cbd99c71e97772a6399985b658316a46cba499a1fd8e220e09c915534a3" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "+", + "created_at": 1690212757, + "id": "f6a137add3e53b6f5f447ccc35fcf84a0a07e69dd06bd8aaa19d6808c6a20b88", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a370fa9b8e5a070f7cd98c966f91b6c2c763cbfad9412a94d3250e55a7b0efdf2fa1812c57f5d34f699a16d4a598eb9241704b50baf113872de8558722d1994b", + "tags": [ + [ + "e", + "3a9d72b17813409f277e464f823a36d81415bad69dab3efe7851abb848751218" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "People's favorite punch bag. ", + "created_at": 1690212178, + "id": "c380b568b96380709a679b97f1ff03bda1707dbf7c486e2f00610475e1db889b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "815038531ead8cb887ef5648aa6389dcaa51475a57295f87cfbaea64c69f8975aace31240a926c26b1f650547fe8f08c14e92a82f147e178204e3a13fc5a58b1", + "tags": [ + [ + "e", + "72820a1aa7f666673f7542abb7ab672b330582efda66339ad291189c2710a3fd", + "", + "root" + ], + [ + "e", + "6c78d72cdb718c818f872b463abb331294663b339823388b8284359761581d2d", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "577de06dce160a0379163a4bb7b680be3e0a0e1c68de6e6ba8c01134b44064dd" + ] + ] + }, + { + "content": "We all have multiple identities", + "created_at": 1690210832, + "id": "dc092b256c585056a6cf94f3b1c5a560d5524a7ffba61a85fc84fbc56727edf7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "05d53d798b60ae9b7970cc027106c04daf72458f42be131d2fbee901b90e74cfd5e6a8962a03f593c5354ef0da5daa436b68e1fd2d7b6f9790dcefed92711009", + "tags": [ + [ + "e", + "ff7c9d42b017c4487ea0608a61b792a5e52e86d5816521ca25cc7da2053aca7f", + "", + "reply" + ], + [ + "p", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106" + ] + ] + }, + { + "content": "Yg/ItlxjLuKDzlhNZxNzxUFW005logbVHbRYOs1iCjc=?iv=VB8/JXO4MSxwX+I8wS43pQ==", + "created_at": 1690208557, + "id": "4efcb194e78ac43494f6f83c4113470880931392bb682bdbaa1dea63b671fc22", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3ad28c322bf12d3fc32d4ce05a42468ff299676b923ab408f9f72d1ec04ebc3565472dc2e998407800584fd92fa4cd22dc1d3db87f3b9b51716daa53a473b236", + "tags": [ + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ], + [ + "e", + "eed8c3d0d10c88c26ddf9afdd5f15d407da027fa811b6aa33ed634e81835ed59" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "Yep, I just have not had the time to work on it yet. ", + "created_at": 1690208431, + "id": "6c4bd9de4e6142f6aca5107f8f88bc021142f2e70ea25a7d569d38f49934de48", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3688bfafa385f849b731462f1f10324b54f5a08489ed55b6fd1fe52d190d2d4e40426c3ab3bfe79a2c72564df5021aff7e7215f69def3148175f415222add0b7", + "tags": [ + [ + "e", + "1b3d29f67935201aab597b4194a92a56f9d55ae23a24558527454b2284b82f20", + "", + "root" + ], + [ + "e", + "6dadc904610d86e20a7c364bb152237fe0185df7e3ac8ebff5b2f89abe751c04", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "456cfdb590144b9cee34dc09130217a938446b2558f06624f1cf160accd0e57d" + ] + ] + }, + { + "content": "Interesting... is the Community available from the relay set you are using on Amethyst?", + "created_at": 1690208409, + "id": "9adbdee8fce306d17e61e72d0931bd5697da102f95b5d17b0508b3238882e557", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6ead4535864f2f8b092158d16bd2b4811d0819464b0a5d3aa63aa46f482fd1895b142f52a9e0320933f3b72f1e2e31b7ae488eec14e1acaf0440ab9aa083d217", + "tags": [ + [ + "e", + "3eb890730dc61b60344b8b2f832943fcb41faa9288049a3e41c2523f7a84d39c", + "", + "root" + ], + [ + "e", + "3576a8688b06d3e39f107ab0b7da6aefc03a32231223af6a52f5b353711f94a0", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "6bf33edbbfe348c1f04d9b708fd51fb6004485812adab49f1d70ad0b66d7c715" + ], + [ + "p", + "6bf33edbbfe348c1f04d9b708fd51fb6004485812adab49f1d70ad0b66d7c715" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ] + ] + }, + { + "content": "+", + "created_at": 1690201062, + "id": "be0a53286c98adb52eda88de58f83793c159454533a225dfb5f141362d5635ab", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2ac7b39051870c9cca6086e889f515d0168a0156a1c636a0006c0d549cb5a06a3eac7d3f4661470e51a0692e51e60c9ddc10dd5c43fe89765f565393bd5e50e", + "tags": [ + [ + "e", + "154e8961290541af45697deaf2537663da54456cda58565fe8350e4db201dfa1" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ] + ] + }, + { + "content": "Hummmm this one? It was the 6th in my feed.\n \nnostr:naddr1qq8yummnw3eyx6rfv9mx2mnwvypzq9eemymaerqvwdc25f6ctyuvzx0zt3qld3zp5hf5cmfc2qlrzdh0qvzqqqyx7cm9ctex\n", + "created_at": 1690198600, + "id": "256b47a26fdd08ce0e2040850d7328a55938c5bdb2eff7598f15779b15ed781c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "cd94f05a2af150bbe32a5297d897eaaf53510b7905a28bff1c4ab581cea8e6701e4646dc6c188ccad884eb5f76ca4a2d9f6d20933b6c3d7dbc30a46638ccdb5f", + "tags": [ + [ + "e", + "3eb890730dc61b60344b8b2f832943fcb41faa9288049a3e41c2523f7a84d39c", + "", + "root" + ], + [ + "e", + "b08691e0f9e4d9254981f1876a430ac9a9f248bf26e54f06109a7002a78a6e91" + ], + [ + "e", + "dabcd7ee3d178ab8efba9949dc273ad96c106e2d2394a986af153ddc17a6390a", + "", + "reply" + ], + [ + "p", + "6bf33edbbfe348c1f04d9b708fd51fb6004485812adab49f1d70ad0b66d7c715" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef", + "", + "mention" + ], + [ + "a", + "34550:1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef:NostrChiavenna", + "", + "mention" + ] + ] + }, + { + "content": "👀", + "created_at": 1690198560, + "id": "b24c556f1f979dc09c6720e851c7707ae907ac272a6bdde25d3d48c1df51b800", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "720c6e740aab5bfe7caa84dd8fdc82bac0d6c4666369f79d8217ded86a91c48691cd8ed0ff59d8b63054eb5e6efe72770d42a1186f1052d70522bb27872c990a", + "tags": [ + [ + "e", + "9aa0a176088c0c2b2e7be88c65c49c0872aeeca70602776d915e9d05ff7005d0" + ], + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ], + [ + "a", + "34550:1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef:NostrChiavenna" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690197065, + "id": "2c60ea99918ede4de03f2d193a5bd90eaa27cd3eb77f4248e7545fbf3cd10ca6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8c520ae3c6846e504aeb7154b11b4f1f74c4408f059f756eeafa0542675c3a8306242ce3b1b10865ef543351f2c85308f9ced483e93bef5036c4bd96fac6b959", + "tags": [ + [ + "e", + "3ba8420929d76e222190d23f28cc8a1871c0a38ee15bcabe749e78e4a3685d54" + ], + [ + "p", + "a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690196883, + "id": "f40a4a59a641bd45869ff6950d16a03c09c856afd25ea81e84f53ae22014c3f4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "17d0aed9b66ba569d6073a56ce2bcbe82c14e5efc9f0173bbaea699d7b5f4d8b675125d067c36e454015e44a234f7b04e356155de64de5444c74ad1632393f39", + "tags": [ + [ + "e", + "e000d1594ab01fd630fd7d635150fce287ed77a1bcb5087360b0000d016176eb" + ], + [ + "p", + "c43bbb58e2e6bc2f9455758257f6ba5329107bd4e8274068c2936c69d9980b7d" + ] + ] + }, + { + "content": "🫂", + "created_at": 1690196838, + "id": "af60844f79ca0fd418e34883cee61a9cb0cbea8e890ce110267c419ae256fd3a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d3bae96968d29b6f1ab47e3aa6e482fd825802443e4baf9fd09bd2bf260a346011e6c336589fcbb0ba20b97689f46ca35f6ef9b912c31c2138a6b6a5a2b2161b", + "tags": [ + [ + "e", + "a284954804a64168b23f5e9f179ac5a2dbf8d01812deafff3e3c452adf872f7e" + ], + [ + "p", + "8decf18b154e3c1f8450df95a501581e64dac7ca09bf9a83e95c783b695ab6db" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690168165, + "id": "f505e57ff50b2fa767db7bdfdf4b294ed7f6f8ecc92edbeadb7086335edf8d7b", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1a6789532ec1ae5b91d992f091d88ffb5889e4d1fba76da7bae3a624db24ff6d9176dde9323b78fe517f8c2c1769deb376591ac670eaf65f2355919f8410178e", + "tags": [ + [ + "e", + "ed5c3ff7a4ce9a16367646b9de3b342e06f5ce9cd1db92547d9764d2d54105ef" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "", + "created_at": 1690166988, + "id": "480e76bf0c518a68f30fec75a613f7e93ac3b40567c55c04d3d99c4939dd0fd7", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "80fb0c2cc3ef946a63ee4fc9439ecf6ac68b30bd3bc2021b333cdf456c04f0e71c9767f97777fab6170cbb2a536700c368954363f9510d585bf4a033bbabae71", + "tags": [ + [ + "url", + "https://nostr.build/av/9a5ab1d24c343007c57d1fcc15c0062b8aa10417b9853373b494dc0a4ffe3865.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "9a5ab1d24c343007c57d1fcc15c0062b8aa10417b9853373b494dc0a4ffe3865" + ], + [ + "size", + "378735" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690164742, + "id": "2743a30ac796bca3d84379e02c2c151a683cacd0c95e436c9944c75fccbd1775", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "950321f75b1abce3ae87ed53692cd681f9f75cdee99e2bd3f55c2dddbd5c3b677762c70ad15b1e465588256e2a93cf0a2f9da9fc0118dc62b82e993f31656618", + "tags": [ + [ + "e", + "1e5fa48d69a1f37cf7d5777c664c2db8a305f3348fdc67cd85de66a23dad1e3f" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "We might also need an extension to this nip https://github.com/nostr-protocol/nips/blob/master/39.md", + "created_at": 1690164717, + "id": "d03b49913572ef0628c03cf431ac5cc7e05f37c23ab25f418b798be8aa2819a9", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d9f9ab2c46276d1684de49938273d172c58a04352ca3e1b8c6616737d79875925104285d6286c0e1aa5a0dfe2f4cd266ed5d7d3187b229787e6ef2fb7a9cd7b1", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ] + ] + }, + { + "content": "Ohh I see. We might need to find a way to verify that this is the public identity and not one of the links to be kept private to avoid mistakes. ", + "created_at": 1690164593, + "id": "402d8aacbec848091fb77eb84fee6cd7483bb051bbd5bdeac068561d37056752", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3861611c96d4e7d62ec3689c8a3afead9441f4c3f927dac52a381c4322c2747ad84e9294596b687722dd6743e7e22e1aa6d875c867085d6319f4126f1847a469", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "150fda08135c4a4d05b7b2efc6f41f519696d4543dced36a0b0cd4093575c780" + ], + [ + "p", + "b70bcc7ad2ef996c7939d91653aa1472e0b7b3a741d194fbb26fe8c1786ed4af" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690161505, + "id": "a88d6e1c7b8b9e83839c15f05bd1b9244dd1e1aaf69408fc53088bcf88d938e5", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7bad72a24620794f1a337602c5416d6980420f20aaad77fabafbd03f43391053ace15018cc9044f48bdde6475072a6d6b51b25d10f0cae061504c260f78b06b6", + "tags": [ + [ + "e", + "8e11fddd2171c00c8cc93b5c7a3e1dbc1789cb41e9bbbc9d3cb57bfaa9b193b2" + ], + [ + "p", + "93eb23ad1d9274e3e284babe1d507f2c80d1eac2f3ef54969361a8a1f926cdaf" + ] + ] + }, + { + "content": "I am not sure if that is a good idea. The point of SimpleX is to not associate your chats with an identity. If you place it in your profile, it might break many if not all privacy guarantees. ", + "created_at": 1690161330, + "id": "3fd93179c24eb3122ccb49fac0be6e6eae50f9e091d7b0eedf350407553ee66b", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0ef0f98973d9a18a12a62aa4ef939eb8062dbdf0e63146d5d6fa3b3aceb7ff9c419a48956244b2d06470a3da5c2d35f9417529e9e87a0f6123ad867640743703", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "8ad94c49e2d56130ff28c11159233d834aa0acffc2c26ebff60d9fb61eca3258" + ], + [ + "p", + "b16a48eed39254385a8754f989045d2b7110e35123ae188e354ab2b3926d925a" + ] + ] + }, + { + "content": "+", + "created_at": 1690154636, + "id": "9fd566b3366e9ed28e0e21bdbb64dfd32d47df582a7e0dc1bdec1658bf698b0a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "57e6ef3a82458da584a807f665839564e929d309c137c33f30dbbea7396b797fda170328663e1d10eeec65b2d9dea67e428a2414dceef3a3d1da0b3b23487d44", + "tags": [ + [ + "e", + "203ac0002170f40a7204961a2d9f767cd398a86da66d483677ce526e7da523c3" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "", + "created_at": 1690147047, + "id": "dcab77b29cb67b63a70a816f489781e662de54c9d7700c326e5eea796ac1e3f8", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "60dcc3d5b4ee38c8f736d5655be8846010b804e59587550f80699eef95a5917ca9cf5f83e8ad60422e0d6b052f9934994e03a6d085a48d9e198fcaf1891fdbb6", + "tags": [ + [ + "url", + "https://nostr.build/av/21267a60f742ee85cda5b6025cc96a56980082d18f299786f60dafd8f1778b2f.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "21267a60f742ee85cda5b6025cc96a56980082d18f299786f60dafd8f1778b2f" + ], + [ + "size", + "6007727" + ] + ] + }, + { + "content": "", + "created_at": 1690146416, + "id": "3873f4f5dbf76a19d6704b05019017f7c42270fc3aa08e3bcf7b263c637d976b", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6b0dcfe92b2984f3f02bf6cc917b5283909dbde0690bc50891805342cdc1e5b987f240f18ebd748152297e06e0d2eba483bdcffcbad6324c6bac2e095584a213", + "tags": [ + [ + "url", + "https://nostr.build/av/0a04db3be1b25964c6b524df3deb0b31d33d8380d5a4a05a1dc66d541f51ca64.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "0a04db3be1b25964c6b524df3deb0b31d33d8380d5a4a05a1dc66d541f51ca64" + ], + [ + "size", + "9910725" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690146413, + "id": "97abf6fc744ca35ac884549ad7bf7ba7607b282047badd8422c4837a14eef612", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6fae6d3f4351602af9b930fae47439d469d5cdce3d73625926a02a94397488ac359012551a2aee439a45a42f993667c69487a20c53fc18fbebbe90cfed00e15a", + "tags": [ + [ + "e", + "2b75ed8899dac01b919a7e6c78803145cb8643a0def9069b0d31d8062810cae2" + ], + [ + "p", + "7538994d9cdbb870be588f44a44a16fe7c2cde7ab203b7dd5fcdef76b8a2ced1" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690146218, + "id": "f75d1da64a3ecdf3fffcffc0e117d86329896647429acde4938d8180e0ee0d4d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "97f0d2c7ec025279f71b5a25434ef8358bc1f40d05e46b7d69868dd4d521f12beddd77cf3509dc0283b91afd9c103c700c49f10d7421b7ad8f8239a6fad92aaf", + "tags": [ + [ + "e", + "aa79b7a1b2833c8ecde7623691870f290f70cfc7b578a02de9fffcf4b0bd4cfc" + ], + [ + "p", + "693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb" + ] + ] + }, + { + "content": "Hi", + "created_at": 1690145711, + "id": "1fad5cbcb12c2dfc878755e9d36253994d744c3234bb7f126e108681c20dcacb", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7e89e6af46236aa7d6d1ecb3886561e6b3ced76a8eae6dbf21ffca4581a9311dd0affc5ea44f3ff2fd6a10fd8efb9bba5e756ccc0afa8b0994706fa9e84cd2f7", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "72b24ba015112bd0d9dfe0b19036500113c5727bf509c5f1f6b66a54a0e24f68" + ], + [ + "p", + "93eb23ad1d9274e3e284babe1d507f2c80d1eac2f3ef54969361a8a1f926cdaf" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690145702, + "id": "64cd298c3d576f3e71c3a49eaceac2fbd576ecfacc5b9310fb55f8059da287e4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5bff5e7e5353ff5626db58fb2d0422298a7fa34a3584473f2a07645d103bb1269f4de2e10ba9465557b49e610ee58b5bf2eb86e208e504e849205983b512b912", + "tags": [ + [ + "e", + "72b24ba015112bd0d9dfe0b19036500113c5727bf509c5f1f6b66a54a0e24f68" + ], + [ + "p", + "93eb23ad1d9274e3e284babe1d507f2c80d1eac2f3ef54969361a8a1f926cdaf" + ] + ] + }, + { + "content": "Sorry, is this on Amethyst? When you search for a hashtag, the first reply should be an option to see the hashtag feed. The button is not as clear as I would like, but it is there. \n\nLet me know if it is not showing up for you when you type #word on search. There might be a bug somewhere. ", + "created_at": 1690144629, + "id": "062f556e82c36c6515f86f4e7c27c22fe464f0096aec5a4d10a131ae9abb4547", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b55589ad969ee964ddfdddfcd59bd6ed59c1283f1660b3b8e80b3f66f7f4460783cbb94521145999f3b86df2a43cdfb6b384efc6c8489600dff8d155dcf4c61e", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "508286bb1e1589f1339a50fe488c126287cb7c68a627433253e949b03c2aa9ed", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "8decf18b154e3c1f8450df95a501581e64dac7ca09bf9a83e95c783b695ab6db" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "t", + "word" + ] + ] + }, + { + "content": "I don't think that is an iOS vs Android thing, it's just the app design style. \n\nOn Amethyst, the notification is supposed to be a glimpse of how your content was received. There is no need to go anywhere else. \n\nI think the idea of meaningful reactions (which doesn't exist on Damus yet) is confusing for those not used to seeing and quickly parsing so many reaction possibilities. The emojis tell you a lot: Those who agree, don't agree, are happy to see the post, or the post left them thinking more about it. We want you to look at it and go: oh cool, I got a few people to laugh at this and two others to think about it. Nice. ", + "created_at": 1690144150, + "id": "e9f43494d02050b4cda38b4618ace86b14ff46d96b816a9434959b9e7699b036", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bc9e359ecc2d6a09470631bb9f8fb32b608d1f40b16c91d60f4a52529b93efafca74f22224b1d1d621bf1b7c26891e25d37eddf7b3b82118c789a117c07bf3db", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "00d94cc83d7c26eed1a915d4a927e6897627997659144f2bcecf6e761b16f71c", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Neither in Nostr nor in centralizing apps out there. It's indeed a challenge. \n\nBut we have to embrace it. Or we risk just being another centralization force in the world. :( ", + "created_at": 1690143743, + "id": "e3623f238ba397fd9fb22e4f4a72d159f4cc47feb3663788da9a282d6f1c7a56", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6795f9ce277ef0eeb41be85317d6b68ab8b6ccfe8e1e5decfd2814a134bf5407c3b5c24988e4b151dbcfcdaa53e98809423a1ba085c49cba8285c1292ea64df2", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "3edd892b48748d552d2426ca32e8da671b966bf6476668460d14b91104f4536e", + "wss://relay.damus.io/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "It's better than Twitter's for sure. Especially for complicated reply structures with multiple conversations at once. Twitter's one only works when you are following a single branch of the conversation. It works because it forces you to go into your follows branches and ignore replies at each level. Which is not ideal. \n\nBut the current view needs work. We need to code a way to \"close\" branches so that when they break off and you are in the second branch, you can click to close and see the post the author was replying to. \n\nLots of work yet. ", + "created_at": 1690143600, + "id": "78de29f56dc8981d1c04a08f49dfc0f4aca4fdf6b7380fe53fc06c758543c066", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c6a343af4611e7c0288a040eb91e91fae70dbbddfbc7780d6b311303ef10ef46bd28ece5381bc54c5d9aa905a4af363a43ed4037269c699b4ace98ec1157c31d", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "8300def46c069d0f10fc9e16b6feef71c1c810821e73bf76e8e3984d4531f457", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "I wish we had more forks. The more the merrier. \n\nFunny story: my initial vision for Amethyst was to require every person to fork the repo on GitHub and when they did that, a GitHub action would change Amethyst's name/package id and release the user's own version of the APK, ready to install. One-click deployment type of thing. That was the only way I found to not centralize it on me. \n\nThat's why the app is in my personal repo. I was going to change the app id to people's usernames at every new fork-install. It would have been beautiful. True decentralization. \n\nBut people love to centralize. \n\nThey gave me feedback, I listened to it and decided to ship the app myself in the stores. It's worse, but it got us going. ", + "created_at": 1690143259, + "id": "76688fced94a5746890fcd7c79d23674e7e84e36f29ef3ff75a1f4b7de62b1f2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f1fe4a9f07e4137f4d5f98f570b28054f56d7b84c894c50b0fda60739557cc5246ff09f0d76c0a3cbcc4d733f20b61b87ade4df22e34d53078cb846bd8a167f4", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "999243d5a5643ba565a8f9f295101d66c70050388e68f771f1ed70688de120a8", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "It is the Reddit model. Basically, posts in the same number of lines are replying to the same post. ", + "created_at": 1690142625, + "id": "cc908556894f1207f2b6fa108a61c6b1d6f47a93f56b4cbe430d9f18048eaf15", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bfb61f208a2a20d8dece7b0e1e1946dbdebad5768c00ad756ab003b08ccdb2425367ede7fbf930c1480f73a569d4d11aaa546f912419f6fb618515d88919e6dd", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "b398f36ea69452fa77c6aa09b2cda032efc3a41a4f826a9bdcbdf935e4507cdc", + "wss://nos.lol/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "nostr:npub149p5act9a5qm9p47elp8w8h3wpwn2d7s2xecw2ygnrxqp4wgsklq9g722q's new icon set for Amethyst is awesome. I am happy to have more help :) ", + "created_at": 1690140751, + "id": "158cd5feb11ec8292d4b9441a0b948dc5d91027b33792990ce33813fa6c39297", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "96731754da8e1be93910fb52675c1aad1b51a7ed2429d534f9f1703856632d61e5e11bc1cba457f153a5ab20a307286e1f96b690f84b469ef3db8fb8f794e662", + "tags": [ + [ + "e", + "5a03b6a7d21f8b76e5ebca4e04cdaaba732a6da4537e7b75d9c57381d84eddc3", + "", + "root" + ], + [ + "e", + "47f1806284a61de31e8cc587a5c66f84fe25b0e6024b40c2851dd1d607aa72d2", + "", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be", + "", + "mention" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "👀", + "created_at": 1690140234, + "id": "0d4467e19a71a4a5c372a96bbeb23fd46f91ea39230791ecb203852f80094c11", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2ba1790c77db5062ee64b7ec4f8fac7ea5d0cac65eb6f95a828e005d0b9ec6b0da429014c020c9a7243ed1f16414cad16da8a09705d1cd122a0879ec2d2676f2", + "tags": [ + [ + "e", + "d63d391bd694b9e1ec5e2de2210771ba5596ad18bd996a4059466a1c0af2a100" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "Then I am just confused why are you comparing this with a notifications screen. ", + "created_at": 1690140198, + "id": "fb72346b3a189ef576609b2b2e5af56f52546722d84094f41733675aa55e4db2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8a7d4b654828e11f8dc94355ecb50b507ee8430599b1187b0ae63b534b5f1b283c74f3b78e641c0301d2bd8c9d58be9e4e276d7be6afbcb0acb4123fec6a0922", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "bbc7290015806dc41ab15d37f8d5b6b66e469371f052abf36738017e4d41db71" + ], + [ + "e", + "d6d3bb1689a95eb9e69e9fe143951c2c2fe402740a4bf35fe5255d86fd9a0227", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Also, it looks like you are two clicks away from notifications. Is that right? ", + "created_at": 1690139942, + "id": "bbc7290015806dc41ab15d37f8d5b6b66e469371f052abf36738017e4d41db71", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "eb3b73acc14478f87a3dc92f946cf8831a63cc1c647bd7fbe1ff585ae7b0d97f588852ed42e8ff68d22dbd0b866dffc045f50468fe0c054e9c0898f46ee900e8", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "7cbb8c290f9c2722e094cf95af3e51069ef97171300d49d84108f66f53de7558" + ], + [ + "e", + "bdf5ddb752eb601777f835846a60f8e7ea34fe0e807afefa515e700ece0040b0" + ], + [ + "e", + "6ea171a5dc97b971fea2a551cfbd418c308c436f2b851aaab8737367f9196838", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Why? Did you ask your users to see if this is what they wanted? ", + "created_at": 1690139749, + "id": "6ea171a5dc97b971fea2a551cfbd418c308c436f2b851aaab8737367f9196838", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "060d4e3a9202d09af5388d11c51089677f725845e96d8f2c7d07747a8be73dfcfb33c8836057d099974d9310ee23161eb78ddf3842560fb7040e8a6acbcde8b3", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "7cbb8c290f9c2722e094cf95af3e51069ef97171300d49d84108f66f53de7558" + ], + [ + "e", + "bdf5ddb752eb601777f835846a60f8e7ea34fe0e807afefa515e700ece0040b0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "It was in the very early days, but we did. \n\nNotifications tab is not the mood for follow and detailed info. This is more for a regular feed, when you are in the \"get more details about this post I just saw mindset\". On notifications, you already recognize most of the faces. \"It's all duplicated\". There is no need to explain or occupy more space than needed.\n\nOrganically, in over 100k downloads, no one has ever asked me to add a button to follow in that page or display the about info, which should speak volumes about the need since it's one of the most used tabs in the app. ", + "created_at": 1690139394, + "id": "e1c04a093203e1a39fa51775e79645d9303330e3d5fc0a6ba4f1ba85568fe260", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9bb5bbe24d10e80f88d117e52c5ec9598a7d45ee6941a0bda09db4aa9efc9ec2623a80fb10453ed7e097284648cf15628d6812a7b41c6613b7772e404cc5e56f", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "27cdfcce9c8f291ea1519567d2d3180a131f3e276801c2b0b208fcec3a4a56de" + ], + [ + "e", + "39a3badfb3a96cbca72aeb6b6319b7ca2b2aafda06c9060158c9c2b56e71dcb9", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "User's last seen is a very good idea. \n\nnostr:nevent1qqsxnj27sn2xud90fyqre6jgakzxwcu2axnj9kcec9jch66cph5u7uqpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygymuzlquexn3g56nnkf5hyw7hv88s4l55mz5j643kjl76du8jacrcpsgqqqqqqsuu0vvw", + "created_at": 1690138954, + "id": "c9ab82b0a6b2ef1adf14106f3c660f9fc06fc063d54fa89c092f64fe24d0193a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "529d56a80125fa7b0e0ba5d9592bb457473929449c775d5860c142d64c6e08a655381bcae282540ab3198636cbfe9aa5074fd237344e93c38dd087ee9e8c0629", + "tags": [ + [ + "e", + "69c95e84d46e34af49003cea48ed8467638ae9a722db19c1658beb580de9cf70", + "", + "mention" + ], + [ + "p", + "9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e", + "", + "mention" + ] + ] + }, + { + "content": "Last seen is very cool idea. ", + "created_at": 1690138907, + "id": "6ae05de6606182d1f3f3afec4b42cd0fcd37c4b12b4807f1f1971f16d4a42273", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2c593ad41af45511a937c5f95e50aacd4ff953e1758faa13c96f71a5c68832f08cf513926fe318dcf52bdc55ae28ee3aeb615a3b12aac553474cb4f4406a8396", + "tags": [ + [ + "e", + "69c95e84d46e34af49003cea48ed8467638ae9a722db19c1658beb580de9cf70", + "", + "reply" + ], + [ + "p", + "9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e" + ] + ] + }, + { + "content": "So, you have to click on each section to see who zapped and each reaction? Sounds like a click too far for me. \n\nAnd why does it display people's about info and follow button? I am not there to follow people. I just want to see how people reacted. \n\nThis is weird. ", + "created_at": 1690138829, + "id": "27cdfcce9c8f291ea1519567d2d3180a131f3e276801c2b0b208fcec3a4a56de", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b9e256fc3ab93ecb522625669dfd5f50bd4468d2c1d0f5328341c559c4d5cb1f44f707bb30949a93fa9d91ac8738786d433f4141e0bd0db625984ab8e6187344", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "546fbf068c293c15c5c48232570d12e81e551891fbc611b1fc186a798ad0dc92" + ], + [ + "e", + "2d59b8711e9aac9f6f6cc1025ce215e744e94979f4af33e7b9a586918e24bd18", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Show us how Damus display the same notifications. Now I am curious to see why is it so much better. ", + "created_at": 1690138505, + "id": "7cbb8c290f9c2722e094cf95af3e51069ef97171300d49d84108f66f53de7558", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5cc460d2f1812da1372b2e1e5bb883ea09e14fd7b33c2ce0fb3324e1b77e5f21cf451226465950061ab59d49e7c766f6f9010116dd0a68a2bca796e2f45bfc33", + "tags": [ + [ + "e", + "10d0e4bb3a880b36610703cf2101b8bf49b91ffe3edcbf1002564fc86e6c4913", + "", + "root" + ], + [ + "e", + "1874dc3c39bf3dab4d9fc3b6022ad850ad5a4aa7ef4d40aa24b019f4f1e4b841" + ], + [ + "e", + "f8c6955a54a8f9c254d85c8c28d9dfa22b63d6b98c7903a8e407a20ff1152f18", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136517, + "id": "9f8f3d9d1e5bb57492d8fb2c6a654ec0953515e5ed009f86fedd08fd788cbb70", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "db59f7ddd49bcb186e12483cc07b819c45f8dd0e1719c3a6dad063595eefb7df34c963ccb206cbcabd37041b4df4d6b5fa4013d4b39217d59cb2199e76181e75", + "tags": [ + [ + "e", + "10fe6f028555c905ce355d042b50605f0eb13b925ba4742855537ffb5a50be60" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136496, + "id": "b30e186018667152d83b1b2a366150907bc4cfc5158c2a74e095fc63eb75cc0c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "14a4b1db558c64a679e02c944b5d63b7d41df6faba4f02d700d92e742a48a21c241c161415416ced8d5bd3663553b071533cfc2b4dbed88979f07ba41176d22d", + "tags": [ + [ + "e", + "eac398b0b928cb10dc9337df29acf7cdf621ba05a4b300792b8aa4d1125e0c55" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136491, + "id": "ee2956b81136a28caf94c12a47a4879a75dc2c204a911ebfbba85243613d4cb7", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7bd2df393c2d65b4240a001c7e67efd7a069f83f50b51f8047bbd3c1c45eea4d1967b066aed45dc3bceeb054df9078d5f451e43427d7063daa1266dbf83b44ea", + "tags": [ + [ + "e", + "df6924922f7fdcf06a802348b28b26cb4eb0eece4d0af995e8c352b6887d04aa" + ], + [ + "p", + "5f2b6a543f5083b79dc22227867507a12a999e365f0af741b1f5852bc1974226" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136489, + "id": "e28b0e4bcf4c16bbc83a7a4d4c4d3ce4159a3798a7ad7a4fb2bb95e5178d0da6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ca878cea73fb03a532452b6fdedc6cb0ec1a4259d9e5ba428cec6b27d8ce2e09605cb079c5d251a45072544de907eeef01219cde2264a8ee6597c2382cdf3b84", + "tags": [ + [ + "e", + "0127ccbde49aec4c9f88ea5f18c2f442383f403d59ba060142f08535f09ee4f5" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136483, + "id": "ceba0d38900e9b7f3ffa6e2935c3e9654aee754f3dd3ad15ff416a061bd6beb1", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "954b2b32fdf7cc393722296b1b5639ef7ba9889a0e850746c8a91f8cb817facb9cf16881f6a1a6f7aa51e924baabf19c796e33eec1dc41c9e30cbc30c9225552", + "tags": [ + [ + "e", + "bff7c2588efc33f94e24ed9e9b9e24e560e5e2c4460cb6bbd6ecbd5cb074528e" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136480, + "id": "23751602d5fefff93fdb7d202f5c0ac4b856d93f674ac24db7675693b1e8c33d", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "122735ff28c4bd0e45b43618767c1a12df1d1c22244d78ed24906ccc9f5194da01540386e75dc74775363fa744b4e2ce338857d4f0013876806e5b9c6c5dc5a3", + "tags": [ + [ + "e", + "107fbdd98adbce0798b6858248213be4262ae1301fbac29e2e5ed7b838534b15" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136460, + "id": "e1404352314e0ea33f8b70f23a9f833e475bd118b0f49886c0d34b01dca6be80", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "286b6470e0c2a51b07d1c7cf72d919d7e06d0f303ad2d6bf05c2a525e37fa17b6b9c1d05d7c80f49dfa954bc08f51c865a6e3e5b0a5feb184d5f96adec613530", + "tags": [ + [ + "e", + "5fb517843bf3accd6ce60e69d5f7b964323f582238242f6776a58c9daa59a3c5" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136457, + "id": "1d8c8f7707f365909ecfffc890bf58ac28489b81f1fb71ceb506b8d9a1417140", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e8a21f42780365e514b9bee4dbf8335722a9520068f312da2b7b8185a8a6041de17e2b631f06edf1aa7959f4436658855688e011b971c2653d83a7f36784d6a6", + "tags": [ + [ + "e", + "28f176f094da6a46ee195eac39ca4154d0d94d86d2e545532f697238f3e52f9f" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690136453, + "id": "f81ec9b259fedf292974205e6d2523cb527f53825e7b16ab794bac30021e5c3c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f207099dd78a41e9fb3f09359acd0fc37199cac970cbeaa0f96e01d3c6e2d1f444f4dc4c7624e5ebed94ca6e936a7d7c60a5f43a753e822e67d4b04ae709585a", + "tags": [ + [ + "e", + "92e9e94ee0da6fd08c62fc2477a8a2c825fecbf0ad91a2c5d060d2978f20d6cd" + ], + [ + "p", + "4ff36a4d67fa327ef1a686a575d0106b4781600900f68ccb5a40f7e455530baa" + ] + ] + }, + { + "content": "Ohh interesting. Thank you! ", + "created_at": 1690134858, + "id": "20e91ddf23a4b87c138fc41c50bd6baeee6bfa352b2f156a51cb914c29927847", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5cbc7535ec7ebc60a21faa27e3bb61d3ad5b5381270209fc50ba7b709c40df4c02fe5c7865b7881aefd4bafbce90abd24f45d058beeda22d94eb6ebc388a9695", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7" + ], + [ + "e", + "56ea1f92baa9008920ab905465776d55cf623539069f406b0cfb057fd5442ef7" + ], + [ + "e", + "1dd32c99b5e875b90617599580270c16e2f7e3be233f029bc73345eb591ba294" + ], + [ + "e", + "3ca18ec56b18d5c17bc31576b80edb9ba355b4cc82dc7fb671effbdf90a79562" + ], + [ + "e", + "682a31ccafec8eb46dc6ae3b8e94471e6d4bf2f98289236e164481f50cd76630", + "", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "That is very weird. It should definitely work. I will investigate. ", + "created_at": 1690132862, + "id": "3ca18ec56b18d5c17bc31576b80edb9ba355b4cc82dc7fb671effbdf90a79562", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d35335a2ee1d8b813b5a63bd86716561356de9c0208162927e3efb1ec6cbb89df04d3d7ceada1c71848d7456037999df947e55a17a7083960b362ba252d2b246", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7" + ], + [ + "e", + "56ea1f92baa9008920ab905465776d55cf623539069f406b0cfb057fd5442ef7" + ], + [ + "e", + "1dd32c99b5e875b90617599580270c16e2f7e3be233f029bc73345eb591ba294", + "", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Does it stay on after you Mark it all as Read? Sometimes very old messages arrive and they are all the way back in the list, but they are still unseen for that person. ", + "created_at": 1690132343, + "id": "82ed575f4d9bc4f19660e277890c34ff4f0aa0d023b86d91fbb809d3457483e2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5c3e81917835a58561b20a6d559a85530aface929422fca1c06e837975932c33124217e5073a13e251fa8ec8d7aed6ccc5aa3fbadf7f996743a9018f21d275bb", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "Back button? That's new. Any particular place? ", + "created_at": 1690132236, + "id": "56ea1f92baa9008920ab905465776d55cf623539069f406b0cfb057fd5442ef7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ee7f13431181d04a1c8b0ce211c2ee788c2890a978f35636c8fde70fd1bd56feb16230f40c90eabf1b6ea3016d324d08e09b24e5513dfccc334ff5489e29b217", + "tags": [ + [ + "e", + "a08a26900f0c1aedf142e61d122abde0f764ff5fe61cf5660c61547a9ad0f79c", + "", + "root" + ], + [ + "e", + "4e2cc89c76dd14bb69fb38d2c6faec03f85f1ef0ef40227ccbbc3c7b05e6e3e7", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ec9bd7465546ba061f5dfde716a4f20f3f27ecc28ca4870775e5e853df11a9d0" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ] + ] + }, + { + "content": "Same risk as using SimpleChat with the same outgoing IP for all contacts, right? The relay can bundle your messages/queues. ", + "created_at": 1690131040, + "id": "5a97c5a5397b6f222d660713269dbd08ef14933f1868d5f20edae897e0be5ac2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "432af5b48d193d0d069dcf2b292edceaba44f0381df378d1e491a9ba5d84a4e0c2d5f755f8a9e8df0c8d55cef22b189014c7a0a138b4119d1a3e9a4b2f814c06", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "1fc5133e2cf877c6dbb2ce5478a5780fb252c77b7683b7871d4944e1f309c82d", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc" + ] + ] + }, + { + "content": "+", + "created_at": 1690130924, + "id": "ac1fa90872d0848def9120db1cb0ee16fc13e90b4e5725365fea651d7a939737", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d8a4cc59e04de535673bb2eeddd7a65348d8daaf3d5c7c694cf4a0df2f1af7ed7d74f7f0d075264e77144932b9701f7fdc35eb2ef089837c59d2b376e80fb4e5", + "tags": [ + [ + "e", + "1fc5133e2cf877c6dbb2ce5478a5780fb252c77b7683b7871d4944e1f309c82d" + ], + [ + "p", + "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc" + ] + ] + }, + { + "content": "Simplex doesn't want to have a link to a known identity like Nostr would have. ", + "created_at": 1690130888, + "id": "273b7d10c62906e48866911ce18fe3735099bc1f2b352e1c7b8a6f26195d0cf1", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9014e9eef179975957e241cbcab1e9b5ffa04b9a527b3447b67dcc17946a8fab0209c384badb255d9e81c7e22a44c1402f8699d794e4fbdfcec22b0db2acad8e", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea" + ], + [ + "e", + "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38" + ], + [ + "e", + "a34f5bf152db8de7cd90c7dba18ddb1ce8ca0309b778af5db99dd9e0afd0055b" + ], + [ + "e", + "1c3241c983d9423dd321ed6cacf17012a38b3a637e1d886a19f21f068a6be1a2" + ], + [ + "e", + "26929187391d11f6428433b304a0d19e72c3766a405cb31edf54f5a6b668db6e" + ], + [ + "e", + "f71fdb383382be27b75a45c8d201f43a9086a5704fc948165d428b52f3fec539" + ], + [ + "e", + "5bfcab4ddb3d59528974588f1c16a1e7633d7720e2fe8ed370e856c20b57882a", + "", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c0605aff47b9330575f4865671528832323c6d0a8320a8568c9e044272e266c5" + ] + ] + }, + { + "content": "We can rotate keys, but if you discard them, you won't see the messages address to that key anymore. In all cases, you will need a collection of all your past receiving keys to move to a new phone, for instance. ", + "created_at": 1690130151, + "id": "f71fdb383382be27b75a45c8d201f43a9086a5704fc948165d428b52f3fec539", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffc4b2fd51e22628d66fdf10f01b93551a6ed69c42f115f9e880858b13445e87a913a569ac1832c6394d9658fd600dafa297c13b9228ae3b9bc06c251f95c7dd", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea" + ], + [ + "e", + "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38" + ], + [ + "e", + "a34f5bf152db8de7cd90c7dba18ddb1ce8ca0309b778af5db99dd9e0afd0055b" + ], + [ + "e", + "1c3241c983d9423dd321ed6cacf17012a38b3a637e1d886a19f21f068a6be1a2" + ], + [ + "e", + "26929187391d11f6428433b304a0d19e72c3766a405cb31edf54f5a6b668db6e", + "", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Not if you need to save all these keys somewhere (locally or on the cloud) to see the same feed in another client or device. That's what SimpleX does with the local database. Keys don't leak randomly. They leak when you are inserting them in a new device/client or when somebody gets access to it. If you keep your keys and the local db together, the attackers gets both. ", + "created_at": 1690129188, + "id": "1c3241c983d9423dd321ed6cacf17012a38b3a637e1d886a19f21f068a6be1a2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9378974e955039364618a0ad76df16e4c2ba1e18fedbca954896008fcda96ff469886df88c648d5590e99b6849f8d188a959731ecdab7f191554a66696bc2669", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea" + ], + [ + "e", + "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38" + ], + [ + "e", + "a34f5bf152db8de7cd90c7dba18ddb1ce8ca0309b778af5db99dd9e0afd0055b", + "", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "The new idea does use ephemeral keys to send. We could do ephemeral keys to receive as well, but I am not sure if it actually adds much security. ", + "created_at": 1690128558, + "id": "cdefd0a3c55e3fe120d6a4fd93666a04f9a94e3349f47e9ccb39c57696e0af38", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ea935c0dce11832e87a7dee58bead19a6fe74eeaf4dbfda227f82501ba4b8da987eb9644e4fa6feeae8e5da99670878fccea63b351267fa5b732859f121bdc25", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "0df509f7198ea50bd1e62d3a63d876f64180ab31752b6271facf16e34a6bbaea", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "I don't see it. As long as there are two clients doing DMs, we are going to have this issue. We only need two good ones that compete well and people will be lost. It gets attenuated when people use a client on the desktop or a tablet and another in the phone. They want to see the same feed in both, at all times. ", + "created_at": 1690128385, + "id": "43baea1d165172f1fb7ece392e3d227921d7a2558e0ae25bc29a39c9889528bb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6899b4e30252916d97848ea0f26bf89834528e7d1fc47c566c61a16246f32f6041db75a44c62e5fce44f3cae02a70d2ffbab6b58982f649411698f9425a23956", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "cefaf39fa34dbc1e01b28930f269af13d9d77c90b2e121f6166b4a7933c998a2", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ] + ] + }, + { + "content": "SimpleX chat relays do keep your messages for a while just like Nostr does. And the receiver also has a copy of your messages. So, it's not that different than in Nostr (with the new idea, dropping step 4). ", + "created_at": 1690128254, + "id": "ceafe9e1ef47db0d1a62dadcfd38dc621121beb555a5937579832d3dfb5cbcd3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8d5efb0bbce9a5c0cc7fe2855e2d33747a2c904fa2c37fc2bdefbad2fcb8a196f3a649fb658dc87e8268bdc6c4eebc24638c78149ab0e62f4a8811fabdb910cd", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "f1208fe67ea4647e66f4c1245c42ac8eb15fa94cb635969b025ac5fec07ab0da", + "wss://nostr.mom/", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "I am not so sure. In practice, the security is as faulty as with Nostr keys. The things that would cause a key leak in Nostr would also cause a chat database leak in SimpleXChat. And since on Nostr, people want their DMs in every client, there will be a lot of export and import happening, which yields bigger chances for leaking the entire thing. ", + "created_at": 1690127530, + "id": "d1d6ae797dd7a9900dc6696e3bebc884f5d06a523d010e6cad62a70aea755589", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9748ea22ea7336a436133085fa8052a1b60f146ef9ef49bb27676d72e55f047165385fc409a3217fbb01f3178a76deb889674d9cf51a9f657a5fe9693a068f65", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "90c441fe992e9277e2b5ab4cb40c249d1d3ca64f41ea4dd33e1e5734cd6bb869", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c060b31fe2bbb0be4d393bc7c40a80848a25b8f0e0f382cb5b49c37bf7476cb4" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "+", + "created_at": 1690126785, + "id": "0601df915b7a6b6e0aec792879ed95a7c05edea54e339bfa330be013cd9087f3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffa70983b791fb19bb5a15da05b4e71b6a9d5d65a7df796e1c7d9633187fb1b52000a21d18c8f8d29ec278bab88bf159c24a1620ac0b64cfdd95836951c43cef", + "tags": [ + [ + "e", + "0001d512384cff57a0a051ca705e2913269c8d7ad2563f7717f988acde2e0d71" + ], + [ + "p", + "2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5" + ] + ] + }, + { + "content": "People can opt out of the 4th step if they don't want to recover their own sent messages. \n\nI am not sure if there is a practical solution for the DM history leaking issue. If people want to keep their history between clients, they will keep whatever is needed to recover the history together with the key. Once the key leaks, the rest is also leaked. :( ", + "created_at": 1690125971, + "id": "8e763cfbd52dcae5c9d14e772a36d1114ab129b9d1fee0725387d4979f43af44", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9da781d49e1d952b4f11633bbb67e22d4ecd54460f9d3ff99ce5908b83d49f4d123dd9996a62cc65090279a9c3798bc913d5aeb136d53e9faaa59eb0b2d0c06e", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "ddbaa33cfe39957dfb381ad963d74951902bb907f9c07e167c2e336a27d34e95", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690125054, + "id": "9f7e02cb022e9efab2fde0dd13493ce8c9753c4189be3e057cd3ce88d7a03c93", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "224e654f8599b4f25e53dfb49462a359bcddd37ff5c9e0374e11b538cdef9641e7197babc7984de626577b10940c54ece892f6824ee49548d6e541a756ae5cff", + "tags": [ + [ + "e", + "098f01242f5d18f7dfe622ed292090b7c008c55f8185e5b972cfdbb8df7a4c00" + ], + [ + "p", + "ae39bf0aab59bfb1bacc784bd7b230ce762a671eb33d5c0c586ef7e96c8ae25f" + ] + ] + }, + { + "content": "Yeah, I need to figure out how to use less memory. :( ", + "created_at": 1690125040, + "id": "b735f0cc3f6fc10d2a15bf6da690ffd8f94e1f03cc7ea24869b14b10d7fe8533", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2d709feacb2657707eeb80219d626cc397ddb8815bf5b28aa5592f486d66e45dd2163682cefbf063a263ae4079e0d482c685fbcd2832c0849fed9c310b702aad", + "tags": [ + [ + "e", + "e362d1324f7672556d6e8fdfbac3fc3a26906abb2b32824b43ba1d2fb80305e8", + "", + "root" + ], + [ + "e", + "a122d53b0340257911cc8e2f7d940279dd5e4100a708b70c9b3dd20eced7f18b" + ], + [ + "e", + "a38e734e65750bbdf679e6564f52e49a19ee8424e590319935f05625820c83be" + ], + [ + "e", + "669c66fe822ba472e5b4b603409f9116badf3a1af439de357c9ec159b894dde4", + "", + "reply" + ], + [ + "p", + "269b0b280b99e4724daf5718a6c8e412c59269cc472a70b472b1acf0dec91bca" + ], + [ + "p", + "356875ffd729b06eeb4c1d7a70a1f750045d067774d21c0faffe4af2bf96a2e8" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Yep, just an extension of that. ", + "created_at": 1690124736, + "id": "8d4bd3ea01e934d6553372da70175e07fb63708e34cd2cd6e99b903d7eecad32", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fabc98d48f0f3a3910418899c1eed968ca9b15f7b2b4cb19c32934fd4ae83cfcfc480e26e074fd091d331ada5dfac6689350921c6df53bfac7a40998ac6dd6cf", + "tags": [ + [ + "e", + "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "", + "root" + ], + [ + "e", + "38a91f7ab267336f20745f74312d4dba3fad6c8a965cd61aa8b294e86e7b4503", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Isn't this better than SimpleX Chat's protocol? \n\n1. There wouldn't even be a queue to correlate messages on. Each message comes from a different random key. \n\n2. Send and receive payloads can be separated in time (the receiver sees the DM immediately, but the sender logs the message, or better, a group of messages, in the future or the past inside Nostr relays).\n\n3. As we add more private event kinds, the anonymity set increases. The public won't even know what is a DM and what's not. \n\n4. Yes, anyone will see that you are receiving \"things\". But GiftWraps can wrap Noise (events that don't mean anything and should be discarded upon receipt). No one will know what's noise and what's an actual DM. \n\nhttps://github.com/nostr-protocol/nips/pull/468#issuecomment-1646858226", + "created_at": 1690124517, + "id": "4591adeb9ecc789599b3f20f51714fbd005c5b38943f91b5ee1174443a63246d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "86eb89acac53fd87f975c1b3799113c9841ee92c0802a15547e9ae6f4914233a95224a1364c087434e758f57190229db17fcc1d8a45951d785824bed063beac3", + "tags": [ + [ + "t", + "issuecomment-1646858226" + ] + ] + }, + { + "content": "Really? Hum.. I don't see any crash reports when writing messages yet. Is there a particular procedure that I can replicate? ", + "created_at": 1690123683, + "id": "a38e734e65750bbdf679e6564f52e49a19ee8424e590319935f05625820c83be", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2bd6a3dc8b8e16a2a3474e59d2b7fb8052dcc374cbaf2cbf387306fb8b482e54f972a29ff2ce8e9fa4acf872334e322dcb0ac02498862c4f96940e28f7e326be", + "tags": [ + [ + "e", + "e362d1324f7672556d6e8fdfbac3fc3a26906abb2b32824b43ba1d2fb80305e8", + "", + "root" + ], + [ + "e", + "a122d53b0340257911cc8e2f7d940279dd5e4100a708b70c9b3dd20eced7f18b", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "269b0b280b99e4724daf5718a6c8e412c59269cc472a70b472b1acf0dec91bca" + ], + [ + "p", + "356875ffd729b06eeb4c1d7a70a1f750045d067774d21c0faffe4af2bf96a2e8" + ] + ] + }, + { + "content": "Each page has it's own setting. You need to change it 3 times to get the result you want. ", + "created_at": 1690123610, + "id": "b6fc9e80837196ee50661ec7dccae9e4780efacb2f1c041eebced362819e9066", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d206951c7acd7ccbadad68edeeabdc91952600c8f438055fd57a0d096c2192e73c28fce4b78cca04f04a8b249b762997cb2242c589af79090ac2f4e5e994f99f", + "tags": [ + [ + "e", + "c26effa4ceb95606db038a88d7be714328a4819be98e52fc7536c8deaed67b14", + "", + "reply" + ], + [ + "p", + "ae39bf0aab59bfb1bacc784bd7b230ce762a671eb33d5c0c586ef7e96c8ae25f" + ] + ] + }, + { + "content": "Yep, block is local. Report is when you let everyone know about a person or a note ", + "created_at": 1690115660, + "id": "d35a705e248e0d748938bd403a3041895396babffd227e41eaae2c8006a1929d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "44fb41d3992aeee7343a65c3ef11abbd34b6399c8fc7fe244e2a0f23e928ea78b7c9f68470358f8e165fbda7e68c088b754b2942e19c362c1eb0212df6489977", + "tags": [ + [ + "e", + "c038888ec69c6c8987f5b2dfd224ddb55ca039689b0a20a22287303ef8894f36", + "", + "root" + ], + [ + "e", + "e8091b484dff5afdf25f0f4a4d1199944cb1ee13f3938f78030fccbd6da92a33" + ], + [ + "e", + "806796cf44e651a4f91b9e3f645024a1df19e0fa62aa95db444e15e8939377b0" + ], + [ + "e", + "121b4044bc989a3b0139372d7ccf27890fea3a5b77af370311f778ea57695bf0" + ], + [ + "e", + "ab1d298ae27795f0fea2c9446fabbbc41b2e5b6a127bf4f200598a71295eb569", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "0c9b1e9fef76c88b63f86645dc33bb7777f0259ec41e674b61f4fc553f6db0e0" + ] + ] + }, + { + "content": "Fight free speech with more speech :) ", + "created_at": 1690114179, + "id": "121b4044bc989a3b0139372d7ccf27890fea3a5b77af370311f778ea57695bf0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "56f0634768de22c8bf3f396e30f3249ed6a3f6d68be81ba9c61f82d77b92699f58320a0f59be3c69aad77b81287af066211ac43f298942940fa2846fcf412671", + "tags": [ + [ + "e", + "c038888ec69c6c8987f5b2dfd224ddb55ca039689b0a20a22287303ef8894f36", + "", + "root" + ], + [ + "e", + "e8091b484dff5afdf25f0f4a4d1199944cb1ee13f3938f78030fccbd6da92a33" + ], + [ + "e", + "806796cf44e651a4f91b9e3f645024a1df19e0fa62aa95db444e15e8939377b0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "0c9b1e9fef76c88b63f86645dc33bb7777f0259ec41e674b61f4fc553f6db0e0" + ] + ] + }, + { + "content": "", + "created_at": 1690113754, + "id": "dc34c01e8f71b64c206ed9ef324beed9b233a298f8df3227702b0ed2907603fc", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "789c4078488b2eecde89c63d63ce93f8a6803cc704728465d3ab4657128474de9b17cb12eb8b8dcf03cd0659c1d34e248829ab3f6358f079baf3d71817a1aae4", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d829c52bb54e2125989bc4c7876c2523f0c7945e20ed1f59.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "a61a285079ad066ed4a31e6dc59bbd6e187c32225c66895342f7ee6b82121a5f" + ], + [ + "size", + "2054861" + ] + ] + }, + { + "content": "Him: I don't fall for placebos. \nAlso him: \nhttps://nostr.build/av/6a92ccde091c71316a28f8652a40147a82d7e573076414095cac12b9d2a3b1aa.mp4", + "created_at": 1690113542, + "id": "ad1d69d0565ca03d3329fa91e93e7d5e7ec21df3d8729ab8b8696379f04605ed", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "95758a89bbc4251e022aef31d0e5f98b342e26f20889e836e39ac59ea8f02131897053ecf4b9e2cdde66ff78dbfd209afd35f0dec754e0f6707a1a9974e7d1a3", + "tags": [ + [ + "r", + "https://nostr.build/av/6a92ccde091c71316a28f8652a40147a82d7e573076414095cac12b9d2a3b1aa.mp4" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112771, + "id": "6b5de9eaa52f1c889dd1c49b44696a53709d119784f3b8859b27b350d75df2e4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3e79410fae3b7223997edc61a6da17df8ec3978723a2931680d8636b64e7cb9ed420d0ec275aecd2f7733df56978166ae568076f106b1cc1ac81ed01c0236950", + "tags": [ + [ + "e", + "ad079dc69fee2dd362bf444404e71b71a4709b790bdf7cc45c4a38b95c9b87c2" + ], + [ + "p", + "ee6b33fc72adf103af33ce25a018179e4458e79d1e898bbe8d460eaa7818bb90" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112700, + "id": "ed1aab29b697e44b5a6237e7859df7e8217b8d2f42627be6745a7031774e6939", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6cdb924b825c5982738b2bf16090f49356fd76eedd098c8153f6071164cc8d8c53956d385f8fd7f7f1e26cb5f35ac3f07f27f3128dd122efbcba58bcc4c061a9", + "tags": [ + [ + "e", + "b69250498d25feb15d65e2e8270154a65cc241593be32cce4efd299c78619e99" + ], + [ + "p", + "a12fb7fe051724a34ef3409ebcdb377b9d1157a79b7a2fcd8d008995f190d9fe" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112681, + "id": "0bebf55a12f843aa3fbdac0481db0939a5926561f1d76a0a3c7ce3d8cb3cafde", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "952547cddc189995dc5aa3b2b34e4c21d540d7cb3203eee559419feaf7f97b4415f00a51dcbb8a719ae7b8d3de87990ccb6448622a64489142cdd5dae0bf6577", + "tags": [ + [ + "e", + "b5a56ca5ca5535609b08a9d1f7985cdc0c96d7ecf5fad484145ad0b1060575ae" + ], + [ + "p", + "be0435d2baff7c44dcf0c38ff53a26720f2729660141a5dbc0dbcc25d0cc619d" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690112674, + "id": "3d1f0707a14218b295716537e7ee77264da6f380f5f868f66e625c83e9ae4e95", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9b4e2c701c7f36b0de297227d627180594010e157a199e9801db8f869d99e4d6e8baa5693ab30e5aec75bc35b0496868ac79373b170ce211992964383c49bd45", + "tags": [ + [ + "e", + "40970fd0dcec2d6e95fcc70385b37cb4387d234acfe7ad483ece089072e5adb9" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690112212, + "id": "e24e855089aa4769587b61792cab3314a521703da921197aa80543b6d6a4a778", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b1fae809b4a20dd954f78877c33c872c647fb73043c945c7ccf24355f95b73bc382a2b3490f87791b320633dcf307c4178ab9bd4a8a416c7673f6dc13a10d779", + "tags": [ + [ + "e", + "9ab63d4a2a5d3c5154f1da7359cc4dbe78be286e120e863b3d5ea7aa0ae8d23b" + ], + [ + "p", + "3a5ccf9f1eced28f3a34db176054f17e0fe25492a33d734b4c0482a09a275eca" + ] + ] + }, + { + "content": "Report is part of free speech. You can say whatever you want. People are free to react to it. And then everyone can react to reports. \n\nReporting is part of Nostr, not only amethyst. Many clients implement it and relays use it to delete users and posts. Amethyst just displays them for everyone to see. Hiding it won't solve anything. \n\nOn the warning side, you can disable warnings on the security filter screens if you don't like it. ", + "created_at": 1690111912, + "id": "e8091b484dff5afdf25f0f4a4d1199944cb1ee13f3938f78030fccbd6da92a33", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26b2ad6f938e396084629c10d48b85835bc4f0571fdcbde974e5b0eab052cd0f829265bdc80efd86790adb8ed2e51207dc6ca467d62bc8d949d8cfeccdbeacae", + "tags": [ + [ + "e", + "c038888ec69c6c8987f5b2dfd224ddb55ca039689b0a20a22287303ef8894f36", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "0c9b1e9fef76c88b63f86645dc33bb7777f0259ec41e674b61f4fc553f6db0e0" + ] + ] + }, + { + "content": "Change the keyboard. Graphene uses a very old version of the keyboard that doesn't work with newer UI frameworks. :( ", + "created_at": 1690111377, + "id": "600b878282a93e2fbd1533727127ab4ab3473fbe0fa80c263e9877bdba46e912", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5ad458d6a0de654da1370b0e88b6ad2478182e78a05c7c4d0df5d089863f4aa70f0683ad871f96b89f7b87af7b68c6fc9893f9d7a4ee393f99b579fd73eef712", + "tags": [ + [ + "e", + "6d195bd88b4a7d4b3522a862fad439d4351e0bc87659a5d50a34aea8c8499e1e", + "", + "root" + ], + [ + "e", + "1ee94a94cc5028a217a1d8159454145ccdaceb73206cc85791ac47d84d79e1ca" + ], + [ + "e", + "c9045bcc1bbba5a5934ca475cfb9b6eb94dd7863b4d56c841d4e0f112d541882", + "", + "reply" + ], + [ + "p", + "27154fb873badf69c3ea83a0da6e65d6a150d2bf8f7320fc3314248d74645c64" + ], + [ + "p", + "27154fb873badf69c3ea83a0da6e65d6a150d2bf8f7320fc3314248d74645c64" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "3a5ccf9f1eced28f3a34db176054f17e0fe25492a33d734b4c0482a09a275eca" + ] + ] + }, + { + "content": "Nostr is open and all reports are public. Anyone can build this. And I can use as another input to the filter. ", + "created_at": 1690084953, + "id": "de7c95cf799612ee6fb6f4e4d3d353e765fced03e8176ad112433e12c9950c08", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9cde5c0b0ad14f2d7466ae995af622562284576202b40ebbea462a67fd763ae25a54fcb83ef43709146f9e088cdeda3fcd9490ce167052d61bd1f5e3f3a914dd", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "595c5a0f0cb5b6d06a0a7219c62d11a4283e122b3469fdc5bb57ed30e4ab25cb" + ], + [ + "e", + "c65fe276c2338588773707dfa2294a313721594ec93a04d6078624ebd2d13632", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "Just build a system to expose false reports. Fight free speech with more free speech. ", + "created_at": 1690084772, + "id": "595c5a0f0cb5b6d06a0a7219c62d11a4283e122b3469fdc5bb57ed30e4ab25cb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6f63e1c98bdb9b2fa9d34c47afe17a06d0a5d9da12c1c4d37355952e34ff4db9e0707514a73b4f86b7c4190584f4a31c7a2124493ae088eacc61f02c81779bc3", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "b2231c9bc27017f9f57892ef75eeeba2a31a255e43401b689ebf3660caa6b22c" + ], + [ + "e", + "20a22ad4b7db92df61d4f8338846a3b322612d52efbd60a9cfc5c8d6aa791acd", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "Nah... We have too many kids with the app. By default, the experience should be conservative. They can open it up when they are ready. ", + "created_at": 1690084580, + "id": "b2231c9bc27017f9f57892ef75eeeba2a31a255e43401b689ebf3660caa6b22c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e0cf116ac3872a4af3ea99bb84255fa46a6d0e99054a00936c5bdca4912d31c37f4687e27af77b17d4a3919c66de87ec709ce6d1432806e68881b54167ece253", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "094aced9e82bcf37d554a6967aeef0f7cadad963ff6f84495b16b26269ddbc26" + ], + [ + "e", + "07cf4cfdccd7746e1dec48784a78569d106efce752d6dd0c0b196c1c395613d4", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "Allows people to block you, yes. That's not censorship. They can always opt out if they don't like it. These days, we even have a list that only shows the blocked feed so users can confirm they don't want to see your posts. ", + "created_at": 1690084378, + "id": "094aced9e82bcf37d554a6967aeef0f7cadad963ff6f84495b16b26269ddbc26", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f0bf30ddeaf6e19c1a13146c98142d36b3559a909d330c866e1e2a9abd196801a88440a31cca574e7da2b1ce8f37c2f57374b720a570fda697b25e1aa54a2041", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "root" + ], + [ + "e", + "52b562f2f95654d2b2a84b742285337eba5cc400ffc33bdbe21e7ee4a5f6198c" + ], + [ + "e", + "7994583bab3bde80a01b4a9b8703913eacac1e4fe7ee20cb21220ea5b5605bfb", + "", + "reply" + ], + [ + "p", + "ba2883fb4a7f62cb851b9f5411659791cffb2e3fc8b90f683ee5091f413880a1" + ] + ] + }, + { + "content": "We also need a data heavy client to make people accountable to their reports. If a report is false, everyone should know about it. Falsely reporting people should damage your reputation. ", + "created_at": 1690083028, + "id": "52b562f2f95654d2b2a84b742285337eba5cc400ffc33bdbe21e7ee4a5f6198c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "39e55f79a467361e56eb2d1cd49c321c99965855af3e0a976605b0fe494b7b1a963a1ff122baee02c01573c0665b5025e990258105816e75f3e0ccef764d630e", + "tags": [ + [ + "e", + "fd2f4cd8122c3af6871458e7939c14a589d3af0fbdb0cd27208dcc67cf98df7c", + "", + "reply" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ] + ] + }, + { + "content": "Hum.. I am not sure what you mean. The domain is clickable right now. There is no expectation from clicking the user name (there is no email or web address we can point to) ", + "created_at": 1690079870, + "id": "bde7b3599fde0d7a80a96b20393563382c5d1c622afb062940b0770ce1bd7e7e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "201da0302b41f0b1ad45c723870c6b82a2352eea67ee890cedbeef394e8c32536a7fa3117e4057972002891e7f89bb3bec60214b14f3bb02a0e94295a87ffac6", + "tags": [ + [ + "e", + "f2845de7cb2d774af657ce57b2886debd9b5b9bec9660664bd40910ade0bc769", + "", + "root" + ], + [ + "e", + "f90bf3162e5430fa9398709f39c9fee0b81b175ab4fdc26f648b1c90fc5caa17" + ], + [ + "e", + "d5a7523bfba9a9d6144fe3f6de462555b6b4ee3aeb6e581ab308003bfb9d4660", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c" + ] + ] + }, + { + "content": "", + "created_at": 1690063233, + "id": "a0b29a3283c10b5bc63632e9d4e6554e46580b6320513a62da68c8298f464cc1", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "44aa72f7f5644f6f3f07e7ab5c76f00c2bf7621e6ff172743591672bf70a27e0deb5961b9e043c4bd3e95007320d75c470cd283b799b9cf8f9022e987465d86f", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_581d924e2f04b41cf2bb258623ec6800a7420f83bd81ff44.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "9180a00d8de2209c1d2623af0b5a492471f967d080e6620ec28e6f003ffb49bd" + ], + [ + "size", + "2371930" + ] + ] + }, + { + "content": "Spam :) ", + "created_at": 1690062330, + "id": "9edeca1c1c29db5cbee94c2f38637eb46774b84dd52c9d2a6d5910c8352d3898", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "071c1ff194a234f1009628994191b3e77a22874a68dc4d82e799d2f35f6c6f9edbe83eb79ad07fda3b8fd096fa83c938d340c49fc271bd197ecd640978c4ee74", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "93fec67f101706b4ab832466153df265c75593fbea8e3ef302410710fe833a56" + ], + [ + "e", + "bb953fe6a64516caa9a0c2285a29e5ded168572be6cae1f3daa63bd8321965f9" + ], + [ + "e", + "aa6f251e957f4ae00f0596359f3185b9579c3602e351bfdb0dd10da1ca3c2c44" + ], + [ + "e", + "be4b56d812d4ae5f1c31b147b18be3b60bbf3cbdf636a4f02e61fd850cfecc9c", + "", + "reply" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690062314, + "id": "f2e1535fb76026dc640c6a9ee171637553fa45a7beac7be1d61c59727f421a94", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94953d6006f0d76f29f5b4b097ffdd3ef3becccad9020b1ef4620a3d567c085c5eda72fa139ebe845219e4be1b33ee54b78735df4ae9eafb563746d717de46ce", + "tags": [ + [ + "e", + "d84e9c154142dfb6327e6003358822b808d1e13f64d6be4182b05b97e4cd56bb" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ] + ] + }, + { + "content": "That shows that you are following this person", + "created_at": 1690061566, + "id": "f98bf74282c9719d53a89764c270b1338e9d3ee9bc81e48022e7d825bd48a52f", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0af6b4e3005dec7a59105e3c7d038f29c0006930ef934841864e241cb2cac4445e88c2786b7314e6ea4cefa2e172fdc1e8cf2ab2a75831bfd5dffe5e618457cd", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "370eb5809959295eae9693ee07e1183501363a587f482d8e2fee7044994d97a4" + ], + [ + "p", + "4081035ac927281b3a086d12b6a6aed9f11872a2d82b8f5f1d0706244e84d535" + ] + ] + }, + { + "content": "Those are all from strangers. The spam filter can be disabled in the Security Filters option from the side menu. ", + "created_at": 1690061413, + "id": "8dba54845e5bcee2c3391a34843ac9faefa452961f9cb89c9e70f0233e5c03a0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "01881e8ad06f625f446c48fdd7ca3ceff54f07c4c52d47c0b0e8c25b896b75879222e9481fe84024d73524d02bcb83eaf67850918202f7ab1e95580034f7d32b", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "f19ab878cd9aed173d5ca6f12fb0036a2bd992091e2f9e13f20887f7442b3413" + ], + [ + "e", + "4daa7e033c8cc3fc6e1e98fce4569c876ffa56897016a92e5e78586bc15a5d59", + "", + "reply" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "Yeah, that screen is up for a massive restructuring :) ", + "created_at": 1690060494, + "id": "f19ab878cd9aed173d5ca6f12fb0036a2bd992091e2f9e13f20887f7442b3413", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "db85ee76fb66a3eeee6f8036e283c5fd8974323225a47535014658e08eacdf220d3197997909c80f180b15adb71d4cc0842f5e4fe5280618d0e3d4199f3a8493", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "06ba84837bcda8cc94d9a11f45e2cf0c0998972c4686a17da8b63500be31191e", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "Yep, but on the private side of those lists. Only the author can see it", + "created_at": 1690056576, + "id": "25e3140b1d838154f06ee1f7dafd52d16b52d38bbf679245f65e3ab889758e7c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "adfca8fb093f442e66d51bec2a063f22e7a293157063a8637fb191aa7e41a4f0f1a06e126116dae11027406e958e7dc0d98eacc830281e8a5d3298880e51915c", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "aa6f251e957f4ae00f0596359f3185b9579c3602e351bfdb0dd10da1ca3c2c44" + ], + [ + "e", + "acba23d2aae16f161b6489515746febbf3b3d5a581ab581d464e7b93cbcf5c42", + "", + "reply" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "👀", + "created_at": 1690055271, + "id": "3be55d0c79a7803d31aa9aabbc72ee943d253fc4bb9b69655d54163b05a2b9df", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a5f26620c693bbe87a824c60f829a8d9a20afe982224c0e811bcb10c2ad7cea824761da226796d8f53041b25c60e8a7883fda40a8d68770ae826d19d7f8e7c61", + "tags": [ + [ + "e", + "011d95918a2c4fe8c6666784d8229984999126bb24520ef2f3ff2bb799c725fa" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ] + ] + }, + { + "content": "Block just hides the person from your view. It doesn't affect the person at all. Reporting does. Block is just for you. Reporting is when you are warning everyone else ", + "created_at": 1690055267, + "id": "aa6f251e957f4ae00f0596359f3185b9579c3602e351bfdb0dd10da1ca3c2c44", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ac3e68c9002292121c89b7ada44df8f69b54c9f3562c300d6d40b5a0979d293c907ceae39b266db368eb473064772e21f205152445e43c9141132125a649223f", + "tags": [ + [ + "e", + "ff460e6c65591f7c43bca28d26c9d67e01d32743e7b676abfdb54e7378c3b9eb", + "", + "root" + ], + [ + "e", + "93fec67f101706b4ab832466153df265c75593fbea8e3ef302410710fe833a56" + ], + [ + "e", + "bb953fe6a64516caa9a0c2285a29e5ded168572be6cae1f3daa63bd8321965f9", + "", + "reply" + ], + [ + "p", + "ae11573ff5b6d1fe250da805e8278b8d7488052a84a3463f110c9cf2e920243a" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "c75160732ebd0d6f98238ffc4f87a269841b05f7ef5b008f0a6eb7d8b3235b35" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690048291, + "id": "6ebe2878b5bca21ab869d71278c4ee25f1249a960b5f2c873d97b0a2d5f154c5", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8446c68226f4c486ecb40ca82817ae12eaad2ab31dba5985e16adb1e7a6b39830df81a371367e5ba7a10be2627b5b47f4e8fb02d68726b814a74667213bda6a4", + "tags": [ + [ + "e", + "927c65129de519fafecef2bd2cd75785f6843b2da0ba0697d574131f9953782c" + ], + [ + "p", + "1f297398e7abbfade212d7bf1e98bfbf682064a9a5cea4a5f3de49df63330302" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690048258, + "id": "84ec8afc369b68af39a44177b8a7e7a6146642bb3ebae85485eacf2a1195f4c2", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8be5d9851ee5fc0d0589ef960d1b7f211c6cbed005d06b8693d18f5fcfc1b8949e5353842fa3887de2875b25a528032fc55fcffa43af3df89509a4e28d987bbd", + "tags": [ + [ + "e", + "1de83a4e1cccd7a424ce97f67cc45d7cd2b41766dcca3fd59a59138aa982b9a7" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690048141, + "id": "8f3ba22a76f92c56e0670f8c47d47d190aef401bfb4699d896d0b575f52ef455", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bbc4655749a64f7cd973364e9fdd77a94b8900fbe874e140bdc1b7221e7e6a38441aa893ce9c1cdfcbfef91f307233ee6e39aa7e7bff40f5bb782aebef268f38", + "tags": [ + [ + "e", + "661435f046df5f20399924de66c34672eaeaa04f6b5b3c3918a2f74d2c768694" + ], + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "Each client does it's thing ", + "created_at": 1690044425, + "id": "ec0f4b6f3d7f08c2183cc397527b48f47973deba9eec2c77a6cedbf2652cc1bc", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "04e00c840222bbf02da8c15aac1906d2861a123bb4324b84f14e663b3975930d932eb7f85a4d16335a1afcf745577d177b4e81adcd2aa20c14f890eb5e321d89", + "tags": [ + [ + "e", + "e1574ee2757c6443502ad1de2e9bffabe146f6ed0edc15be6cee7fa9725af430", + "", + "root" + ], + [ + "e", + "8ff6ab31d730b8934e6a788be0a686c71ca41985ad87f6d15f3cf2d6ba99ea55" + ], + [ + "e", + "ccc770aa2e496461dd8c328e889e8f4ac3a02e4e2e0496bb083bd280e6bc2266" + ], + [ + "e", + "d4b98b5cdd30cf226fed081467dcd16caf6948b1cd389b2f6d7a1469d36ae864" + ], + [ + "e", + "e1bd0e07369d4fbc9141f767f8d643ef20db243bfbb5f171e1799dbeb33d27de", + "", + "reply" + ], + [ + "p", + "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177" + ] + ] + }, + { + "content": "Only if you create a list of nsfw authors right now. ", + "created_at": 1690039711, + "id": "04ffa93f7c9c765191f6ff7c33b89870a5521226193b46739d283166e0457493", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8cfb4c7d3ea59ff087f801e6aac44bdb0f82dbb17241caf80f1896df5237d8398b446729456d71c296c47538661c0ee99403e5ce71c2c1d323e6279097720fbb", + "tags": [ + [ + "e", + "21db8800e3654871004b7653e549d5dc9c03028d05abb1c9417bed4405a47d77", + "", + "root" + ], + [ + "e", + "163d779823264345dc473001dba0987423c4c61438ec2b887695ef3f4d18e636" + ], + [ + "e", + "d9a46441a3df57e1549ad2d2d458fa4e95b73a9dc5034a6212e3a61112d58e92", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "People can just flip the always/never see sensitive content on Amethyst settings. The relay option won't matter. ", + "created_at": 1690038798, + "id": "163d779823264345dc473001dba0987423c4c61438ec2b887695ef3f4d18e636", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0e835e8b5e6e938dfd5ae6f42a7ae6d593725a42bc57985c729e12f709f5128ea83665b051edaa49aedbc50a4c2a91998481a796b3d75bc3509c068d9b534301", + "tags": [ + [ + "e", + "21db8800e3654871004b7653e549d5dc9c03028d05abb1c9417bed4405a47d77", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "Nip 50 is supposed to be that. But only very few relays implement it. ", + "created_at": 1690038557, + "id": "d4b98b5cdd30cf226fed081467dcd16caf6948b1cd389b2f6d7a1469d36ae864", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2a6152d146f5bdb7bc10e6e86fceee6ec2010c5ecff107b89f51d2dd577738b727f8fcfe6ede555b6a64799454e9f73c68baca41b9d0c18b55c44ac1949e9470", + "tags": [ + [ + "e", + "e1574ee2757c6443502ad1de2e9bffabe146f6ed0edc15be6cee7fa9725af430", + "", + "root" + ], + [ + "e", + "8ff6ab31d730b8934e6a788be0a686c71ca41985ad87f6d15f3cf2d6ba99ea55" + ], + [ + "e", + "ccc770aa2e496461dd8c328e889e8f4ac3a02e4e2e0496bb083bd280e6bc2266", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ], + [ + "p", + "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690034791, + "id": "5814e68e1a5f5cae768c0661065449b7bf981162b9e31553740a11749df1a668", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1bf9fae5ef84654c3b79598a72396072eb59f588151a132deac8948712774ea4c1f790e47075d7bcdeb09a993011896fa0532e69e5efeca9bee32c7f105d559b", + "tags": [ + [ + "e", + "84179e0bcd619efdd068c9a604f4fb3f1ed158c7798a2f7c8af6b4e1c9f1ab5b" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1690034686, + "id": "297d8d5ef9a77a74348fe0e490e515e6912e8bda2173a20ce31f6ceca1dc3c90", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "19e249457e3e695dab747a373dced6fbbf625c39d5212e9f020d73f30e39187b66a232d89cf070e1a5e147f012165fe1d5bde4c45ba4314dea4be43259469852", + "tags": [ + [ + "e", + "47e702b7a6c69e43d5113a0eeb49c2bf934611f3cf7eff98e215d798f53f4492" + ], + [ + "p", + "1c6b3be353041dd9e09bb568a4a92344e240b39ef5eb390f5e9e821273f0ae6f" + ] + ] + }, + { + "content": "Did you try specialized search engines like Apache Lucene and so on? Some of them seem to run very well on Android. It might be true for iOS as well. ", + "created_at": 1690034653, + "id": "8ff6ab31d730b8934e6a788be0a686c71ca41985ad87f6d15f3cf2d6ba99ea55", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d989836b6e209dfec45244ee467ae7a92ad9b6d35b5b31b81884191e81e75a726bc1064901874a463a3f5f63a2c454d00837f5ee668a3eecc4784b923bb3556b", + "tags": [ + [ + "e", + "e1574ee2757c6443502ad1de2e9bffabe146f6ed0edc15be6cee7fa9725af430", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ] + ] + }, + { + "content": "Unless you are going to do a massive bounty, bounty hunters will be either professionals with 3-4 hours to devote to the solution or high schoolers with around 10-15 hours of focus. If things feel that they will take longer, they are not going to do it. Neither group will have the time to learn any WoT concepts. \n\nThis means that the more especific you get, the easier the chances of somebody completing it. \n\nHere's a template: get code of server X, add a listener for event kind Y, which runs through the data Z, organizes in this way, calculates a score using this equation, sorts it by score and writes the result on a event kind W and sends it back to the network. You have to give them every detail. ", + "created_at": 1690034428, + "id": "79531a6456980a95d5177bb0904a116b771eef37a374c30621e3fcf6b57edb56", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1fc94a332ae92f993ef384e0613c2a62101776cb910e69c1a818f0fec165c8cd2af1471f514af41164793ca40345c7fbc2c6fbc260de6838040da9f9efd4f7ae", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "622cf2369280653f92b3b47b802feb93038a9db3d1253cbb1af23303fde84c72" + ], + [ + "e", + "084cb0d0d8e7cb6ee7cc3b4e0c14e1746c6192a329e5e644cf2aa42b0702dc87", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ] + ] + }, + { + "content": "Too many options. Keep it simple. Focus in one VERY simple use case. Fix ins and outs and let them fill the middle. Remove all the jargon. Otherwise it will just be too much for anyone to be interested. ", + "created_at": 1690030365, + "id": "622cf2369280653f92b3b47b802feb93038a9db3d1253cbb1af23303fde84c72", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a67e5db83686746310d721b2896cd14bd36343010cdd4b1e295e38e9b3f7bdd461904912e07f352cfe0bcf34b44f242edfd28cd14e7dc4b1e4bcf6fd1001e7a1", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "311df23c765f2c44322acd39b0838474cd6c911bdbbe5133561a63d7b81c8245" + ], + [ + "e", + "0fc743840d438b7ba2472e9a4c922a72f38ad9822f05e885e48dd5a70c9de141", + "", + "reply" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ] + ] + }, + { + "content": "List of posts or other people? If it is people, you can create/update nip 51 lists and Amethyst would offer then as top bar feed options automatically.", + "created_at": 1690029264, + "id": "311df23c765f2c44322acd39b0838474cd6c911bdbbe5133561a63d7b81c8245", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a4fbeb9f8b2f90e3bf578cb6ee7d3e144dee810ed20210a9ac862db0ad501829088fbccf5893a4d9052a6ecc6fb7577e6614d3472e29a742958e3b7fc1b66106", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "c936aea32b162c762c3b26ad7ff303869cd018a55804c28f5486c4545c37b55d" + ], + [ + "e", + "fc9a175bbd853076754d9ea1a3272e089875ded5c03b1d3b7d11e4c567ad1364", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ] + ] + }, + { + "content": "", + "created_at": 1690028553, + "id": "f4c3fa3b4e18028b986728b70fbd4f24747b28840ac2dc880fe128552332c37f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3b7d8c66c8c7c8db6c12e274e455e072b9964c92915bf51421e421a61fdd0eb7822705a251f6a1faee7c02462bff739d18825f4c06cf23feb231336e0c3417e8", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_77519b031bb3bfe7956b501a01197a9508f376697f378577.webp" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "667c8409013988802a168043034a06d4501719aa27ab6ebbc4b825db417f281c" + ], + [ + "size", + "26866" + ], + [ + "dim", + "510x640" + ], + [ + "blurhash", + "_BRfkB%M~qay-;xuofxuM{ayRjj[j[t7_3RjM{xuM{ayRj-;j[RjWBWBj[Rj4nt7%MWBxuRjxuWBt7ofRjRjj[fQ?bM{RjofRjofWBt7%MM{WBt7RjM{-;ayWBRjayWBt7" + ] + ] + }, + { + "content": "", + "created_at": 1690028054, + "id": "ab3df16710d9ccc53c08e9b54232ef0b378e3eaadf0acd0f7649800fd4787857", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3cb3fc0059720310a950543e3f14d84830b3b8ab748e7f29b6d4d8e4ee4abb60858056bcc1d438a2a549da236f83e78cf52ef8488389f4cdb3371c4e1e63c78c", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_4b1b9cad209ed9ce79cec1bea6c76836bef273d9f26ea42c.webp" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "d9af8e835f0fe1024bebea6efd45348c6abecc2cd6cb12a5c217061f03cccb44" + ], + [ + "size", + "71462" + ], + [ + "dim", + "700x804" + ], + [ + "blurhash", + "{nK_5qWBogay-;t7%MWB_Naxjsa}RjWBRjof?cWBt7oeM{fQayoL%MofRjWBofj[ayayWAjtofj[ayjZWVayIUoeayWBofWVj[ofM{WBofoLayj[jtayWBayj[j]ayf6j[j[t7oeayayazj[j?WC" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1690027967, + "id": "b69a97ffa74a84b2cd1d7ba53f6b02d4b84024cf32de1c3df6eb7be819ee90f5", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f50ba3ebf9dc452d8ea8862b7602c2837f96b23515caa05222353f82f2c30228f74d942121ab4a1acca3ee2304c501f5d92e1fedf77d0122614f59f40f8f68cb", + "tags": [ + [ + "e", + "2583ec8975b63ddd5c373d2c8bc7bba419362b8c9483945a78214edea52227c7" + ], + [ + "p", + "4796eb254645a3b0db24e77dd7bae72eadfe18fc4c24f2bc6c4e658c4fec4ed4" + ] + ] + }, + { + "content": "", + "created_at": 1690027967, + "id": "08c3212fa15443ee5b0b7c71e0192ec47e79deababfdd5637f7eac30d9d1b387", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e48a9824005948ca96d4b16696a0ea75e255ba1f44a7ba9abf823b14992faab2b57d58b8198b36a3598ea075f2c5d1b38188dc3c4f90ccb8f84a636c1c87ce7d", + "tags": [ + [ + "e", + "2583ec8975b63ddd5c373d2c8bc7bba419362b8c9483945a78214edea52227c7", + "spam" + ], + [ + "p", + "4796eb254645a3b0db24e77dd7bae72eadfe18fc4c24f2bc6c4e658c4fec4ed4", + "spam" + ] + ] + }, + { + "content": "Have you built a data vending machine for that yet? https://github.com/nostr-protocol/nips/blob/67e950a2009e81df1b8c91b0a2ade0596e83f168/vending-machine.md", + "created_at": 1690027938, + "id": "c936aea32b162c762c3b26ad7ff303869cd018a55804c28f5486c4545c37b55d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0a25b1c1855391a31be333daeaea31b0654cf804f0c620b3c44651ab7d59d48816fbd26bd519e0a68ac558d4b9233931f0f20624e91e1291220cda94d67f8c28", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "467b16aa0975ae962b9322438448a5b94d28c9677aa0bf783f0d5a97c85238bb" + ], + [ + "e", + "8ac9aea5873049675b1340276158bd332f955230c7eb3c606a160bb15dad91df", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/blob/67e950a2009e81df1b8c91b0a2ade0596e83f168/vending-machine.md" + ] + ] + }, + { + "content": "Yep, that makes a lot more sense :) ", + "created_at": 1690027857, + "id": "d8c51aa8ff8e880bfc229b556b762a8322d25a0273bf138f55a3bb5a4e6f3683", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "52fdfead0a8c473b7a467a820cfc19f0214f7dcce763223e0c74b35e627c97ae0f86875348592b551f006a8b0ef386d9c5f51c26330a6bd1f708ad7c14244fb4", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "9a88b1aa5e514e33a721a8e036e1e49654b31133f8922d27e9bc71d0f463063e" + ], + [ + "e", + "1326e784c89aae0d495b933710689757ffd307e4193e7de396b04e1b49c194a4", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "", + "created_at": 1690027511, + "id": "e97c4db1ec0cf8701dae216a0883239fb870ef8e477f0f83c9935c8225d0186b", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "70803168e9f3a53c4f05ddb26ed8d68d716680ae5e77b1c41efc732abce1541adfc5d01b263d868169a0ee5791d69db982944db8ecc48c4f1ffbc8f75b2ff31b", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_fe2f3b78ab59248cd0e65015d39ab827b60f4bfbc1e91162.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "7e9ed8613fb4d077afded5b131cc9238c3e5e9737e85ea697028ee0acf9cd3c5" + ], + [ + "size", + "393081" + ] + ] + }, + { + "content": "No, the kinds to classify nip89s apps by. Right now it's only by event kind. We need to do “I use DVM npub1XX for kind:68001:job-req-feed-generation\"\n\nSo, you can filter nip89 apps not only by kind, but by each individual type job being offered. ", + "created_at": 1690027384, + "id": "9a88b1aa5e514e33a721a8e036e1e49654b31133f8922d27e9bc71d0f463063e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "95dd34d7ff940ad5e92d0fe11ceac901494d5f62eb6c14f3cd53ed3872a46978a4e92859ee09645bee1c2bf5381ad039b437345c93bbdf18bc12412a3a929ef4", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "619c69ba1e8343d87ccf36260954a45bb52b8210c5facfd87861a06fcf5561b8" + ], + [ + "e", + "71bd71e53f0e857bf5915a181ffae08b26557158690170f32c3976d20727b2b3", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "Sorry. We haven't optimized for old phones yet :( ", + "created_at": 1690027143, + "id": "f469473308fa5e222d3e5358bdd2eaf6b1c4c135f4ab4bf60dd6cd1c8ebfa7e2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7dbc9e95c3ccd98ad00dc84fb50bb78fd3a88e1d280c6fda72f4cc7155755aea71b377445144af8017f1c03d98908d67e3d46c2800368354c67c95ddc2519f5c", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "3e646fa6294a2a87e6ccd619c39a50974d6c70216eae79d700bca6c46179a389", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "43d140f61e46792199dea8a8ab4634dd21f1aedaaa4fbd29add506f8029f1265" + ] + ] + }, + { + "content": "Eventually, yes. But specialized apps like those are always going to be better, simpler, faster in their specialization than Amethyst can be. ", + "created_at": 1690027096, + "id": "ae01161cfb1508092b9b4d9ac9a16809b354b2753490c870585da6b001e979f2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d25cd417e0cb9917d472a3d5707ff031c70654486ee79457496103af389f3434a530a2f2f3979ae36501ef81d160d3babb221f9aa98056b0bbe8d612be98d1c6", + "tags": [ + [ + "e", + "3d995935ead0e9a10af53b465a2c2c0d1e309d5b9cc563e8c0d6f0c569bb8c97", + "", + "root" + ], + [ + "e", + "540605b2e4ab1cadca0abe9806e96c06b643de78e34e1275133cf1dfb3817cbc" + ], + [ + "e", + "e79e452b991dc561d99eb33f278dcc26e0cb105eaf40eb9a3aae87ae62db315e" + ], + [ + "e", + "50719f470bc924b46617433b06dd67aeba0f9b19bcc810e89e5428a7118f81ee" + ], + [ + "e", + "3b992f41a24e66a78cb3c5779edd18c300a6a8a04afc722f8a9e6efbddac8f23" + ], + [ + "e", + "5d4e39e25d2453b8d42b2093d82ca99da9c65f6e8d56f3b97fee4e3b271299b3", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5131f3572c960cfa0b5d262687565fe53a98c8d35bbfaa2daf029bc3f6eaec88" + ] + ] + }, + { + "content": "As long as there is a way to break further down from event kind, it should work. ", + "created_at": 1690026998, + "id": "619c69ba1e8343d87ccf36260954a45bb52b8210c5facfd87861a06fcf5561b8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f9a77aa366699537f4e52d284bf4608c3959115b0ae2804adc47f69b39298385857f94c077c01d3a0e646d0f003c9104516f451d71af3a90ee3bb72ad6f5994e", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "1fc961d0a4f87cd225eef96695507f5a42aa446620090c4841faa4a84c22751b" + ], + [ + "e", + "d2c109bbcdb0ac2b0f6166b367d164a9d96e72605bd4f5c86dc31fca0be7ff1d", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690026846, + "id": "e799c69de8fa38f99997e48dbc2b5d203a841bcf1a20de848456c38d1199d29e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f705dc055fa87d21e15579f6691d12cbe9270b1682edb81f0861780fbb09c4fddb92cc20659d2214db6b7436830149eefad724defbdb56a55abc24d143714326", + "tags": [ + [ + "e", + "3365db2747c4f5ee69b30cccf7c20eac703d2c61f2f4e0b1bdb6973ba211a037" + ], + [ + "p", + "4ea28e364020611166ad3f826fd476c05b2c7852fe3d691a2c43381ebe704d0a" + ] + ] + }, + { + "content": "Yep, DVMs the user can subscribe to are way better. But we need to figure out how to log down which DVMs the use wants to use for each task. ", + "created_at": 1690026543, + "id": "1fc961d0a4f87cd225eef96695507f5a42aa446620090c4841faa4a84c22751b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7fbc9ab6433ebe48f29bf9c4cc3e6e48ad1fc057cfa8a292600a12ca4e95c14022770b7d679ebd248663b949b7cb1408862715e395082601232c11f462175b60", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "16ccafb23b3ea34d29a5ed546e30e21c29457458b389e59010d3f577e83937e5" + ], + [ + "e", + "9ed61ee9d569308861842675798444bd50f133a63bdb07deb6345be9efefe73d", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "Create lists of people on Listr.lol or highlighter.com \n\nThose appear in your amethyst's top bar as options. ", + "created_at": 1690026476, + "id": "3b992f41a24e66a78cb3c5779edd18c300a6a8a04afc722f8a9e6efbddac8f23", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fbbf7043f61a63c1bee4f3d986fe6e6b9a58a9225cce97fc45acba1e6ddebf3310daeab1cd121e020f35d015830c843e02d8d0006abcf63d785d347cf4f1e923", + "tags": [ + [ + "e", + "3d995935ead0e9a10af53b465a2c2c0d1e309d5b9cc563e8c0d6f0c569bb8c97", + "", + "root" + ], + [ + "e", + "540605b2e4ab1cadca0abe9806e96c06b643de78e34e1275133cf1dfb3817cbc" + ], + [ + "e", + "e79e452b991dc561d99eb33f278dcc26e0cb105eaf40eb9a3aae87ae62db315e" + ], + [ + "e", + "50719f470bc924b46617433b06dd67aeba0f9b19bcc810e89e5428a7118f81ee", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5131f3572c960cfa0b5d262687565fe53a98c8d35bbfaa2daf029bc3f6eaec88" + ], + [ + "r", + "Listr.lol" + ], + [ + "r", + "highlighter.com" + ] + ] + }, + { + "content": "Yep, I don't think it's good. It just reinforces large accounts. We are not here to centralize everything again. :) ", + "created_at": 1690026412, + "id": "467b16aa0975ae962b9322438448a5b94d28c9677aa0bf783f0d5a97c85238bb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "356c955f44848aae2f188efa7a26c2f6f0fcb3784c22ded65b992d0a4b26dc967a818b890cd8e7c8228136c25589e9ecf4f5fcac766c53e339b96eda42b805bd", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "088c6a88368f2b7c0bcac5be688b8a493161add325c32742ac2e37439dcae5cf" + ], + [ + "e", + "d4bb741326defb5f62196196c4fdc6c899cf9f2be20de1de1a6c6dc9cf131e03" + ], + [ + "e", + "16ccafb23b3ea34d29a5ed546e30e21c29457458b389e59010d3f577e83937e5" + ], + [ + "e", + "670401ddf5959ddb5bbc72451d31e36d526d1879280dd3bb89321dd5fb46164d", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ] + ] + }, + { + "content": "We haven't yet figure out a way to make trending actually work. Right now all implementations look like Netflix movie algorithms that just bring a bunch of stuff you don't care and the things you care are harder to find :( ", + "created_at": 1690025979, + "id": "16ccafb23b3ea34d29a5ed546e30e21c29457458b389e59010d3f577e83937e5", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "644876edca3832f919ba921368d97a48ff95c63881825ee7c8bba53ea8d7016395b57c83a41f097b095e378ccb0bf2880c3af4c2b7ce3abfeb10b75f03743f7c", + "tags": [ + [ + "e", + "2738282425cf2d147fc0d01ff9d95ecd475202399a3805c40e193d2b20244bac", + "", + "root" + ], + [ + "e", + "088c6a88368f2b7c0bcac5be688b8a493161add325c32742ac2e37439dcae5cf" + ], + [ + "e", + "d4bb741326defb5f62196196c4fdc6c899cf9f2be20de1de1a6c6dc9cf131e03", + "", + "reply" + ], + [ + "p", + "ba94849433a724544ab6794f79700bc980613aa93bc8c872a59c4f7fb85d36a5" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c" + ] + ] + }, + { + "content": "🤔", + "created_at": 1690025791, + "id": "4e750cceca0a0377fd97bc3fbe02329afac6753be8273d4051091d8b2cea2b87", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1e9091ee1f8df139d36d948c50f3a28df024f52fb4736eaa013304c17a92a915a214a7f8e5e0ae1ee05491510be63ea037f7bf31776a3e18d94c4bf2bb89e28e", + "tags": [ + [ + "e", + "ac3798231044db275288061a933064011544192f3b867644a1952ea76bbfbf23" + ], + [ + "p", + "c4f5e7a75a8ce3683d529cff06368439c529e5243c6b125ba68789198856cac7" + ] + ] + }, + { + "content": "Put it on a list and access it only when you want :) ", + "created_at": 1690025656, + "id": "e79e452b991dc561d99eb33f278dcc26e0cb105eaf40eb9a3aae87ae62db315e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "768d7cf56d649dffba187a7d8e04f8f2f7aec4850277189d4025da4c37d741d4e05007393b775ced3e335747ada23ecce57d43a0d124c0462aeda070c274a2b5", + "tags": [ + [ + "e", + "3d995935ead0e9a10af53b465a2c2c0d1e309d5b9cc563e8c0d6f0c569bb8c97", + "", + "root" + ], + [ + "e", + "540605b2e4ab1cadca0abe9806e96c06b643de78e34e1275133cf1dfb3817cbc", + "", + "reply" + ], + [ + "p", + "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "5131f3572c960cfa0b5d262687565fe53a98c8d35bbfaa2daf029bc3f6eaec88" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690024223, + "id": "817f132a4daf21a41f30096ffdc97fddfd4681f9e66e9decc1548a7e5ad41d85", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d5b6fe39efad7254f37894035bc52edc508bd97911e53dbae69ad058644fc0f35100ac36598c2118fd2288178f3b1ac55e4881cd7ab016a5907d40b6bd296fa9", + "tags": [ + [ + "e", + "3e0242a1e3afac94f6ec5f72da83429287d8b726c04223342fc738e151a616a6" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "🤙", + "created_at": 1690024192, + "id": "1c467891446567ff979de8c0aa3900c1f6d125131f119df522feff8929afcd86", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8e75d7e513399ddaf8800926b1c63554b96539ca7df4fcbb3e53240e1a4f8d359f1597d5af157626d3c777d4b95bd2a5fe9fe107d0adec8fcec0dcfbeeee166b", + "tags": [ + [ + "e", + "c581efd5a4d700022e040db861eecc0f5167539cb19f57b7bd1417b2360aa165" + ], + [ + "p", + "7c1521d55a53580a05990c6cc4bae09c9822169b6e544b1ee47005a6c3e9e9be" + ] + ] + }, + { + "content": "Amethyst doesn't have a local db yet :) it's all in memory. But search is only used to find new profiles when you click the search button. ", + "created_at": 1689999260, + "id": "c1309e7769630260fdfa5ff25858f3416eab72e78c8a80b9ecf33244b62b3848", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "904e705f75a25b9840589fbe86455b5929d6e1755a43b35e24ff339bdc7edd578e8c4e9580c6134ed86bb8af78fab6503ce4085342dc0e6244993d5e157bbc8a", + "tags": [ + [ + "e", + "dca4527bd76908cd8071fa80d9241b552c3c2348719bfa42c6bd8137a568e9d3", + "", + "root" + ], + [ + "e", + "a1c52b61bd6c9096a98ff51ff63e1cf54e868f481eea2a503f6fe3e806946d43" + ], + [ + "e", + "a1461580c93fc038bfad8c9a457b6e4156d4217e0d9f3b7f95e51ba82ce95c2b", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e" + ] + ] + }, + { + "content": "nostr:nevent1qqst4hmuwdt88ru05r99mqlf9e0a5c76hus738ns6p90d8s865mgqccpr4mhxue69uhkummnw3ezucnfw33k76twv4ezuum0vd5kzmp0qgszp55czrt2t7ftq3dduqhthtwfqdkhg8xxs6cqg9wy9dprdlj26tcrqsqqqpp8z68rfc", + "created_at": 1689998418, + "id": "2529de5d4177178cd1bc495ab3c9fd47728f1a8d3f2e33615918d57a33d88e77", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6545f19adb258d6fa5cd2ad10b648c15f0eb5c2ef12f19c0aff96e4afcf04f6a2c1cdb9f822a5ced89a62acc54c755b1356c6ef55b5b131c98f15628a327b3fd", + "tags": [ + [ + "e", + "745b854705717741bafb74d36e968e6dc120aa8a6abae7d6934abbbc4b0c0c11", + "", + "reply" + ], + [ + "e", + "badf7c7356738f8fa0ca5d83e92e5fda63dabf21e89e70d04af69e07d5368063", + "", + "mention" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f", + "", + "mention" + ] + ] + }, + { + "content": "Yep. Let's hope more relays enable search. Right now just nostr.band and nostr.wine do. Without them nothing works :( ", + "created_at": 1689998365, + "id": "a1c52b61bd6c9096a98ff51ff63e1cf54e868f481eea2a503f6fe3e806946d43", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6dfead703845bf105b7908a6007de6cf0ffb0ccd62d371930e2022dfe636e7c8d12c82c8370073b592c96ee4c08b89dffc1e310778cebe4d867bb5422aba4b24", + "tags": [ + [ + "e", + "dca4527bd76908cd8071fa80d9241b552c3c2348719bfa42c6bd8137a568e9d3", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e" + ], + [ + "r", + "nostr.band" + ], + [ + "r", + "nostr.wine" + ] + ] + }, + { + "content": "", + "created_at": 1689997153, + "id": "cb3c8ab48e449fd9f312bcd3dfc1706dd4dfb997853b8b6f8006c06603654c56", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1905563b8b682aea269c4cfb2a247cfc8ca4b2162f24a68ffa20b94d83d8ed54fea47d8cd94fde821d84b10ae6dd5ac1eeff31f062b4e12631b316571c82f3c1", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_e64adb648e5ff82dbcb1f4df76d3378c58b7a60a24738a3d.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "592e1b0c982a4dae025e35e6d08bb4df41aa330c542fefd1fc942a6cc605ded4" + ], + [ + "size", + "974483" + ] + ] + }, + { + "content": "", + "created_at": 1689997049, + "id": "07846fa8245c0ddb620debcb3fd735cc3a8df6b4a010e97e211eee20d58c6bbc", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "61baf4dc3e84de957caf11deb63076de6db3bc28ea9dc1472b4e6ebfcf69be9d53a5c0533040585a255418706fd113e31e1ec847de56ebbae231fc04e172f42c", + "tags": [ + [ + "e", + "782621cb24a3a8ab85f31c15774bf1a78b4c8446430515a702976bd7a416d5af" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "ad80abee20daa3a220763272a7e3090242188f71833edf970c42121a4e6d5504" + ], + [ + "size", + "36172" + ], + [ + "dim", + "554x554" + ], + [ + "blurhash", + "UUOOIzkC}@WU9]W:ELoM$+bIj]jE=}jZs,bI" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689996772, + "id": "c8df2b4bed40245191d8703bcacfdff60d18b79f9571a0fb4a83f3d7a206d019", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e83d039b5624f2a17914037b46727697ea63c64b54de84b66a14e996784f2d818349ef213368ef2b6c8d7e1202dd977235fb19bba376e0ccc173d89ed1cf80d8", + "tags": [ + [ + "e", + "badf7c7356738f8fa0ca5d83e92e5fda63dabf21e89e70d04af69e07d5368063" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f" + ] + ] + }, + { + "content": "", + "created_at": 1689996403, + "id": "e9f0ea803d47d2a4088a85160ec5e3b460d4d7ce80d8005706b43a670c9ec8ff", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "874d491cca43ac519c2fdc2b28203609b0d66f07a950bba18a72b51577237131ea131c22049753960f20a28cad8dd8bceca7da5f16d398f0469850c06b031c7b", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_c69a040d82b8085ee667b5c439f0ede384f3f9fcf758860c.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "b9af7ba10a32bd5ba80b375fe461724991f0c36216f175a2c68a8a9645d89e1e" + ], + [ + "size", + "1912315" + ] + ] + }, + { + "content": "", + "created_at": 1689996260, + "id": "83218f8e706f092398a99a458a64b847e1cd39d0eaf1813f20fcae34663f72e3", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "188e44752870fcb55594f0f5465fb17a6ff248aed5717b9b66c89ce69cb82f44ee55853c6798e828ffa3163ca11579395ad84374e71217b0ee0a481efcdf3b9a", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d130b03e7f6a25183e930a37f1b35f5763896067d60b79ca.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "f3e8510dba57c20c8f3e00910cfa27bf660994d04d82f255f7822abb0e023cba" + ], + [ + "size", + "854488" + ] + ] + }, + { + "content": "", + "created_at": 1689994614, + "id": "5d9e383311d5e2da43c252e5029a311b43ad63f5a97a79b47f96b3654e419981", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fbebdff8e5077364ecaa8c6e9e71a743990b0ba12db0d346f0abdecded0abf65c3ab8d2be65da0ac6ab67788f93fd35e2ed3dbc950d07b6518212cca06e1ad6c", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_b6b4f0f16fc4cde884220091e8f43cbfa1ca1c9b7038fcf3.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "baa579aa8bd19a0c2bdc6d091012b67db89526eadf454cb5e5ee3863fc25bb51" + ], + [ + "size", + "14207658" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689992751, + "id": "6227574060cd0e32ec7f0e6c4c1bd59a049db49b88eb4a7fb72675c76fd3314e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ab33e1e1b8399b9e0030c578c2a79f2468bc7bc8d0b5279abbf80c7fa23b05b89dae373f68a98fdbd2de081e8b27cdedf07bba8195b2e366f0c2f032446055ea", + "tags": [ + [ + "e", + "5fffbe60a6107d0492b0ad0a18586957472ec472762b931e3fdd2b5f5dd38ec9" + ], + [ + "p", + "7f14df43e1782029254e2b6aed560440b0d1d202ab4855fc70f25584d8d4d732" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689992748, + "id": "bcd6ffd307fb1497422afe873c12d021df5f825039411aaaafe9c43700db972c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c43c4182dc8c06306f094c83e18f08ff54b0fe3964bddbca35ac66a1b73d2d4ff16891c37fc08f5e0528e4a157ea246de17028b6a638383b9e87a7cd38c33474", + "tags": [ + [ + "e", + "411f4bcd1a717a1b8266a47e646f27838f349c773b55298fb264a749dfd945d5" + ], + [ + "p", + "7f14df43e1782029254e2b6aed560440b0d1d202ab4855fc70f25584d8d4d732" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689985609, + "id": "3e8962e2fe9a240531d3971d70f40f87e3a3fd6ded501631ab95aee313ca6eeb", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1e1ce8acb6e9ec11bea55e6ca8a2cbcff2674d28a6336eda6d1c3b2e761670b10114c6b5f6d86cb54f2cb294b9ec6b92c2dfc024f183e72cf23d644437d2149a", + "tags": [ + [ + "e", + "fc2b936584da862e4926d85437accb5412cfe9c983edd517b88cddfdbf618954" + ], + [ + "p", + "20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689985596, + "id": "ae40b833935fa4844c9fc7e62622e653a67beff93d02091f0a4dd529e75e03a0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e2a520fe8e0424d0c2310d1e9d1aa827f338348105d85d295b6e3165324da8ab2db219fdaf4c6851302839658ae077b654e9be02da34a8e6c0fd499adc482660", + "tags": [ + [ + "e", + "e91e975b8da19b4c092ac7581734e2168d45fe73e4b8f2049aab3788fa35ae54" + ], + [ + "p", + "4ea28e364020611166ad3f826fd476c05b2c7852fe3d691a2c43381ebe704d0a" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689984810, + "id": "f4bcad31c092a0042bb445f413c593e320b6a6c26941fbe106ca111ab52d7166", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "04b17cedec6de4e85b759eef2049da4492e1961106573f1e61bb4b79accf7b183ca20744e039c40939a3f7c8a3fd4e003783b68cad554f0f1795dafb283841e6", + "tags": [ + [ + "e", + "d269571c1d2ee39df436b3890d75373fededa88f48155df92f0469f80e69fd93" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "I am very happy with that idea. I think we should do this. ", + "created_at": 1689983150, + "id": "8bbfbb3a847fc22fd3446b68a7428fd1071d4e3b17a85502f2c9d7dd21471925", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c0e8227f5f211a1bd932742635c919f2f2d7cf749c15f069d7977c312f5bc952765cd5b5076a454b5f0acaa1f48f31c5c22a1cd5693c1258fa24d9b8b9780efb", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "7523b31c7ebf3c7230943b33076e7afcfafd69c10aa7deca5c455cbd41bcfa67", + "", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "I have missed it myself. I am not sure how to make it more clear 🤔 \n\nI am just happy it's not a bug. ", + "created_at": 1689983110, + "id": "766c8ad5a213902deb33c7177b3a83559e4f140425ed9e5c96a4bb10305fb634", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ca0841bd17b42b5303e0923bc81f2065b8e44e1880480a6b8340f3e6b1bcbc25685eb6f1dff7101a11ea3417be632eafd761d8c3fd3104157f9c8cdadbd9c6c1", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca" + ], + [ + "e", + "7ed23adc9508bf4bc57fc76af8874c09c9af9d6a4afa0e4b04db199a04461d62" + ], + [ + "e", + "40900f8a39b67e8688c2cad1f2ccc73f13cd0bb4af8ef8d88fa86f0bfc958b4f" + ], + [ + "e", + "56f22ef8b1c40cb9721b208f4231c88ee9fdd0372d7affbe426edeb8bd2803f0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "And this is marked as Global? \n\nhttps://cdn.nostr.build/i/cdf3decc75007b18174c3ece49f94c51f930e28a01c126bac1045b4c3b67dec2.jpg", + "created_at": 1689982919, + "id": "fd4e2a1222c0c7b24d48c7dc9359a7d8311246c4f076914b8e72b94e1ceefce3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e2bbf02d867d2b5d971e11131449805d7025abe446846c0400c8d0cdf3b61066d42c30dc23990d00f0f7a8454ae132d4f6b16553f9fbaff0bb708a4f976e3794", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca" + ], + [ + "e", + "08297c8b2ce0bd53a336a3328d9b9a9970e2a81a28ef70a7b5734bf4bc26bdd1", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ], + [ + "r", + "https://cdn.nostr.build/i/cdf3decc75007b18174c3ece49f94c51f930e28a01c126bac1045b4c3b67dec2.jpg" + ] + ] + }, + { + "content": "We got a name! \nnostr:nevent1qqsdqxyjenhfw85v5gx7kxqen8055s7cw2puclm94js4gtddflvagmqppemhxue69uhkummn9ekx7mp0qgs0e0trlf8y0g8mel484etgx9qyma39x83nzlmhrzuazss0lh08d2crqsqqqqqp44zurc", + "created_at": 1689982779, + "id": "1abfbd6067af774e88a049b44885148337fb870595595c31c2510abf9159f86f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8ddd006e5ce070f94588bf136490682e585d506c377e7c86822ba7bdc704d88538a69da09e9ad9e037065db0985c9a147ae60b69148f791d848cfeed67e36e63", + "tags": [ + [ + "e", + "d01892ccee971e8ca20deb181999df4a43d87283cc7f65aca1542dad4fd9d46c", + "", + "mention" + ], + [ + "p", + "fcbd63fa4e47a0fbcfea7ae56831404df62531e3317f7718b9d1420ffdde76ab", + "", + "mention" + ] + ] + }, + { + "content": "Also, check if you haven't chosen the All Follow list in the notification feed. That will filter your notifications only to your follow list. ", + "created_at": 1689982726, + "id": "40900f8a39b67e8688c2cad1f2ccc73f13cd0bb4af8ef8d88fa86f0bfc958b4f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a816b3002595152cb878bbe3c74d6602f38c4ce12679229bddbb85efbad8aea025825c0c070ac79ea2ac29ebc225969aa99b163a9a3d5b783d61239710a55b0c", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca" + ], + [ + "e", + "7ed23adc9508bf4bc57fc76af8874c09c9af9d6a4afa0e4b04db199a04461d62", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "Of course, we also easily allow the author of a reply to remove your user from the post. In those cases, you should not get notified anywhere. ", + "created_at": 1689982674, + "id": "7ed23adc9508bf4bc57fc76af8874c09c9af9d6a4afa0e4b04db199a04461d62", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "10a36e75f8fcd7a27d73eb9ea967487552eb3351acaee2e950e4301d713a7695cd053e8d5a94843469092fbd15104a4056a497c680bc7f2cd5a94c4bcf717e38", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "root" + ], + [ + "e", + "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "Amethyst only shows notifications when you are an author of one of the e tags (if you are the root, or the last reply, for instance, you always get it) or if you are directly cited in the text. This was a response to the hell threads that keep happening. In those cases, cited amethyst users receive the first citation but none of the replies to the post you were cited. ", + "created_at": 1689982605, + "id": "a7bf27944116662a24a1fb68b420081aacce90f36ecad03965c45d81574b80ca", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c540d90d6126d14ffe15d082050bf07c50a551c34d515857d6017086697af1713f003e1280d7074f85914d7b8175df722e912f0cf3a3c6da60aaa510c8211667", + "tags": [ + [ + "e", + "3d4837be2f1101014ff150cfaa71da66d4507d136f0befb535403ed4ff08f768", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ] + ] + }, + { + "content": "BHz4QOLgtnkLFMkWLeT7vYEog9hMj00OCdIOFn5tC0I=?iv=B7sbT7bbjVS23Vsa3XdClA==", + "created_at": 1689981957, + "id": "7fac13ad33c8cc8805b50d1cd944e50383af754597dcb24be3bcb0e7d73fa34a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f3f0d2d07998b8b164a4f8a463422ae1d0711d08e66d2956871926aafdafe9eb1530027766f1b8edbbb9b7078e154b9cd51cd73d1fab8c4fc6d60888a040b4d", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "zvK4PV/zfIScLBttRMRYRvJBOKjoxxy5++CuSB2un7eWdV5lfuXbdBMBn/e+jSu4?iv=ic1MXHOOCfk7LNg6+qfUFw==", + "created_at": 1689979685, + "id": "cab93844a5464ea9c3a9101f2818bc72e20531c49c061dba55177659f7ec6e3f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2c685d86c9cc220c0cb0436fe26ae92625d5df71575f6bfb9630258120a7309c9c3e440b02ef45129c2cda4299efd4c230b34f00dc52789bfd32708a05b47c2", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Y1YSzKaLNfyzk/rAeHjbnpqCwdNZVhz2Ogk4LaVOew0QLUQux/MkYL1POQb+icsD?iv=GuVyD9MvvB8prX7SuwzEvQ==", + "created_at": 1689979669, + "id": "a8899213890ab028d13961a1f4b7962140ca083ca0d834f537ecfe52a50f1b1d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4cb381c6cb5287bd8df253d540947c84f441c7a73dea56f32584ee1f860179ea04fa7500f3ad58f76630a02a09f127f93bd39052fb0c993238149d4024d939d0", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "9873Ylo0oUHthBI5gHDkuQ==?iv=NAf6J1gwgBYCX9aX19WXUA==", + "created_at": 1689979646, + "id": "b26f9295f51ea0498ef9008c208b27adbed68833c570c2d6ca9451b6ac8eec49", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3a96d2ebf9a2695a031daa48afcd4f5f21ce0f7406bb495d8c7513ac162e37986165d989dd1b6d302a753babb5e4d87358783dbe66fd18067ec3e5c89b5f2027", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Makes sense ", + "created_at": 1689979221, + "id": "840c1578c4e6c49f0a623dd0d50d9aacfafb3728e986f0bb529863987caaeb75", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "15e6d9161334d3abfebaf2ca2b9f292498d35d7199f6cb0b9974682f0339e95be8c972982e4aee0cfe95855db3a3a8cfb244f813c3236697ce9f4212a96715f2", + "tags": [ + [ + "e", + "bf7ae8649a76c977606a13ddd453942a3ba501783b23c121d1bd685a218cd002", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "84eaf37660225a312d27dbf72acf588106664d444bd432b1c8004c18fa109d63" + ] + ] + }, + { + "content": "OUQDJlyIyVPZEaJsh0Zh8wjpdPNqri3ua3KzNdVERNk=?iv=LMJ9yIbIEj0va6BSF4lOGg==", + "created_at": 1689978881, + "id": "cff285496df7108a40a3d78ad158848b4e6a0c85e34bfa45a2e5cda587ff6575", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6a701ec9868cec9a12926443d3f5d4ae09b1d114fefc3529bb59f09ce4ab712eb722ed94be7e0952251e474f4c5cdcd4bfd3cce7c80a2fdda73df7d7c9a21396", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "AJcI/HvqWp4WtZwC/jSwUavXG46/NPMV1gzkxyaMgYYNRoSZIvq6bR0+b7H+ImfbJvzq1HI81eCvVsyt/eM4JA==?iv=7jXjtbkrppHdmcQT3Huerg==", + "created_at": 1689978874, + "id": "b74800c167733cb13dd0241e212cfbd5e3b729d245912b810f51caf844059693", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c66d201aa976d4c0d37c9d435633e5b8d693d0c6a9e258f3618523899c46984772c3d58fc097ac2489ddbb2a3bcd4c6d4a48ea2ff6cc78b513bee81c0032578d", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "e0wITUuIQONaGOLT8tzD1liObF9LHsVScktSpgkgrPlpUo/YjzbyugjGZ3h5D+t073G2ZK0X7B4idNPZNhDrAA8HFcRqP2Vc+ADn3RUHD0Gx6BoQ/jwIy3gOnEV9oRttkawDv4HputQaeWYroIX19A==?iv=ea1Zp+6AM6Hs7Grc3EdzmQ==", + "created_at": 1689978795, + "id": "966dba764185afd72a596a74c91e706f5a28618f16962ddd3ba41ac19904b9d0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dc43d1cd5b000d94a453c9dcd3caf5d400ea2d23b200996be1367852b92f9575fc2894ed63758f24bf1d130dd3c6a044016ea70717aaf1772921544e9332fe35", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "AfKccxXWtZjL0ZV0U0gZzXjxmtomauxe9f0sQTIVN9+2cYL8njKAxEsWSQZbrgIZ?iv=lxtITUPcoSK15HeDT0S3TQ==", + "created_at": 1689978633, + "id": "fa2f35b2f2841f92d64a3a006792a3f361c2ad60507b55081bac5c4f0f3f464d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "49b0d07eb6eaaa38e73c11f24fe4037f11e2dcd224168ea681a04283e1f3149368dd819eebe51957a5b5705d8a48c9b2b6ef575140f3cd842bcbe2851abed92b", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "8YAcpa1MZfvtAwVJ5/w16YQ7sipjBjbBRj1Fe2nnHJkT7O3GdIlk0V5TpREHDL5a?iv=AtGcsKmCEh9MTuofv/Xkzw==", + "created_at": 1689978554, + "id": "0219e6f056c0cebc919dc9e1a6043b5a5302f4a0fdb327c87d3216a2a4b8aa2b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "97c587f226bf35d2aea3c4ab1b36c02008e792630cbf329af28a59dd06a4ecb7339260fd4e186d8e15eed6ac8a47549956c7873d783e5507cad3b57ce9dce041", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "TColF2a6KOL0tPR2Cq0oUUJ53oRsJVJ83q/rSEYCDcbpihSgLh2sPk43OSJ4hujoNTvkyy/TmIqq9x7U8R15gFb0WIw1lVvZ2owykU+2iDtnVJkcicXROLQVtj+x5nu0rP4NYJxsp275G0YzfwsY2ngigSX4a5aT3FXwloP649rYCuG+C1Uv2nqLbYl0lbkt1PkoubEc7MsqboDAZ5a02QFpS1QQ3kyscrPWlF8/MG97dVVnKZKmXgpt5e/uyZKkvCHQCR910qJTFt2el5t1Bb8uII9k6VMtXJWMO/KLHywH/kgbM6GC5l0enohwvnqK?iv=chSXqVgngEDxdbcjTXDNzw==", + "created_at": 1689978540, + "id": "e30b18b795404ca81ebaf2a5bac9b82301de22f598786e267b4dfb93335656ae", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5ac4871503ab5e78e5949e1301ea9a6286d44e18124c6f193447c96783a809eb942b1d571ab64c9afcb8016ae5ca41c66f67d8c1e71a56af6f9f3d198f08d5d6", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "yM5XtJP7JyKfg+F1bx/3Gu2FpOPx3dzjbvrM8O7HSok=?iv=yWgjpXyDICJWVFvWoSEdnQ==", + "created_at": 1689978135, + "id": "fb7b2ff803042667144416fada94d9e919ae1a6442ec644d609365380ca34c3d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f0dfbf44bc54410fe14567236711175f5b842579bc942fa18d5979d2b58880fd6c879b8d4ae2e17fada6555ba08284ab8d75a49413588759c200fa8c389c8225", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "UkCJkOqT153btoDiD+4+VlToAtl2UlfFsLH/8sKr2lce5J7oQWBcLdQ5OiDjYjzJ?iv=UiYcO7+BONYURJf6Ezap7A==", + "created_at": 1689978078, + "id": "3b00f93d6ee7b42832aa34575170a74f50618b1095e65e0d325ef1c498b2171e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0dd1469c4f6d8be190f828f9345477115e66176de9a4401f578b053b6b14f6c7c62896d7c74d88552e0dc2ec3494492b0cad807f1cbd0926cd1116176eea7380", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "G+wV6IdMdJ93uV0aa0lwQe1noHo7ctUiXuqmX/AbctZQrBkSGgpX3XUbr+0m1Zy08pALk0vRlBmve7NmdhLk1AT/Am23vbxxq5i9ox+UOMgw46VyjB5g01XrLhKDUHg8?iv=WxHcPBEYuLrFZOohJLlrlA==", + "created_at": 1689978068, + "id": "8a2da86643dbecb3272e72319a7a85c687648ed68e64b4530a163727a66b2fe1", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "30578d318a41da4e61aadcd8cc9ede9150f2adfecec92727a7d126c58dcab29ef86e923e458ff67b465cc31d42947ed90d2d5b9c21f8c6f56887cde860121b2f", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Qn7zesOytPT+WF1W0uRDFnEbltsxbbc2ie7+NBqSHyGlLJBAesuJ2Nay9ALOntBk?iv=fnhLxP/1sYXgjjqJKiTOBw==", + "created_at": 1689978037, + "id": "add78bec9b1669dfe4776b4029e9910f88af60e7cb4a3832feafd408b5093d04", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5cfcb4532dc1c18f9bdd3d1b695e58313a11fc780a6f6613f10524b55f47d7da6182b1c1043d9b9692549615264658e570894f8c853fabeec53e92892344845e", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "gU/iheSDEaeFKtJLVs+7IA==?iv=LlKtkWuSJfH4ErPRKWh2TQ==", + "created_at": 1689977622, + "id": "b1241ca4526477334d56cab686836dc3fdeaf6ab910c1fd7e474db135c87f48f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b2a67ce55251f5b0ef42ec5cfa8581e99fdd069ba592d1f288d70f5801935f52fa8bb45ee89d8dcbf73b4879d189a16d64b674b7b02dfccf6f1b91f0295b43d6", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Lc5utJWxQ1j5dT74uZmgldkNzwufFEKXIEi43a2V5JoSqaAjjZj4SYgYFo81UbyCsX8+WvoOpyUBS9pmZ26+kw==?iv=fNF9u5WfZg1lWTUQY4w3Ug==", + "created_at": 1689977617, + "id": "8e0e0de3cfbcbbb8c40c6643524a599c9b445f3f00579d88f6595fa19031448e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0a576c229f0959b34c47cd76d18c45f047f338c2280b9e435d04e6e94ea27cdc82223775942060741b2514a0a1f3fdc9a7a11ce646af79642cc038f0cff4cdf1", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "ptJxRhe3oiOuSJ84UqDxqg==?iv=DG1s8TtDwB89j3zBOnt2dA==", + "created_at": 1689977604, + "id": "98c010a3bf59659061d710791df4874d979f1131d8292a8cf423916b07196d49", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "41ad18e5cfef67760dc24859fd52b44b982ebd1ad10ffc50d8e5cc9cbc0d41e391dca0160aaf92d1ee3593720dfeca05f6bd13595e722ffc8e9c1e1d6779ae62", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "eIl+2IGO/rzItb9UkRntaMrt80VQvAMXu2p8o0G476Ed33CINCc7r2MrBS2OZCLMFIysREZmipm0FOwC4zMfwDdQ2VDuSvKx1Wr9yp0QeHaQuMO+fvXbAM8SAtmmmu8sJFIm1nHByF+IxvGHMZNyC0WdhJsLFcikwLREXX5vfn72PXNIt6CjOtoT3GfWYR9lXYZoikvcYoL7yMYEqXHqS9exQl3oJSa8aMQ11K197xUikSG8lnbMBrbPCTBljMdx74+0x5aEGXGtQwQKV25eGY+MImxAiMi2XBneI4JzEqUvXCgMZwYVz1QbtYY0ajOZGWKTV/P/IT/81ItgdMkpgEPC/+QaEegaZknxDCbNU8MeytI5uGEGKZSdKeJSk3vIsDKaz4DStlIFcLKtVi2tam8Op9JLpGYF8TThe1WlfWU0sCdlCut1LN+eDkt0LqaW8k8z9Fo2fD5lXvLNrdU2M8kco77dGAKcyvz1DB7uGTCXQJkrbfdZTW5BObF9EDALJPY1wGFt1IFkFYaXj5k27dO4q+NLMuDYOoREyVPedcyv9zzy/CIllOu0LlQN7JStufo2y4pNSdDKk53izyqQX2aoPH1X7ABN3MPaOZXZ3Frt9UYMrsZiZ1m3kPcGH+wrRocYl4uWwQUbfS2dvTfarOq1vi38m1ZdpLvaEVKWgrZ39gjBup5UHfv0LMiyYWEioBauxLBmyRnfsoU7wgszHhn5Wu0835Mv1wvgRvnlvSqj4NnaneV5d3U+DadNifgBReQyjNDLUGT/UlMdgso3gWbjPF4Q4M8dmCdlomwOa4KR/eFE2Sw1KPqO836/6K5xhkRcxuqEzIG7RKwEIMLGWg==?iv=/CzyaHppYO841qCLbDCo9w==", + "created_at": 1689977531, + "id": "7f86c6b8a04bea04f4b1097622aab3a7c1da0dbe402cac74b8119d3c7282b1c6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f842721f12e20f24fd6a332d32114caec583d4e229ec3240c13def3292e260ca093eb22bb82fc8d8deaad9cddc59458275e89e5f68e86908c63875f45d5acb0a", + "tags": [ + [ + "p", + "c63c5b4e21b9b1ec6b73ad0449a6a8589f6bd8542cabd9e5de6ae474b28fe806" + ] + ] + }, + { + "content": "F5A58GD77ZjLyVrHN+JifSYzbBG+npbhJw6vnVbGtbI=?iv=ll19k4Ar2luPdqXHcsj/Qw==", + "created_at": 1689977465, + "id": "7b54d2c1cbedbace05ff968de66e95b7969c1e83dd5069f65a6de6be2cd9ebd5", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fcf6705371dfeefc15281d9ca5b7df534682a93531d1cfb6e13e1df57408cb079a390de2a861a2f2b60300a9dadee57933f006e091f646ead558868a9cf43b1e", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "VHiN84CzKCUh8uPgqS0n3SvulaWO2Ztg0FAMxoB52GQFsKLwTMFafvp+UVu/9o++fSHaQlsnUuobtwfS5Jv7PFhQpFrApLrFjRdOO7ijkhWwvHBqAQ3umh63aNjhS1MQ?iv=bnjEyVyuyYl0N+IFn9uvyA==", + "created_at": 1689977293, + "id": "59640cdddf28828cb3f75c6aeb4212811137ec990e3bf418c2ba19d764e6b198", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1f40ff28659001a244af4d73e41962b7bfd52d263bac36296616da4d0cc93cfbd391e9589b3711938412981cbeec17809b31d90a78eef4d7f6e7506e9815d6e1", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "These release scripts look familiar :) ", + "created_at": 1689977034, + "id": "aece58f5f5c7b98f5494ce7db0165adc8df7ae0c6b9bab581441bd91b0df3ce3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c169b8e506011a263ff013bf56f3f52718e44a12e50f5a06b6679d20f12d28e8818aead329419667c41eef111e0b1927ee2ba2f689f7ce4883a46a240dd300cb", + "tags": [ + [ + "e", + "31c2e724b2c44fc395e24382ba8d80e8174160767bd02ec16f0a9ca3a6d220ff", + "", + "reply" + ], + [ + "p", + "df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708" + ] + ] + }, + { + "content": "", + "created_at": 1689975807, + "id": "f0afbf83bb2e8c64b989776a7e674d917a6196c24cc313bd0d9a90a8032e3874", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "547c76ef76f139814ab6ae33fa5efe03e5dd899003c182af97e11e0aea9739d68e5a95ed1c316533856f8b63674b9337b1ca6d73f4c9816fdabb563904ba2eeb", + "tags": [ + [ + "p", + "352b917a5d3d447b7078a9e6178b6514210564a927bb4003844df48d98419d11", + "spam" + ] + ] + }, + { + "content": "LITEQ50g9GvYEMlz/4HxI2O616p4Nizdsva6JPXL0TolilM+bAWGxSJ6R/hnInV7G3y+3HvCTxAwAQnV9UpS09q8Q2MEKg+eOptLCZdzd50=?iv=1fVnYnkBqLvHshEaqi00hg==", + "created_at": 1689975101, + "id": "6ceb478d0e6e01c4cbd06ef9957b85e37b57a3a04a3c63dd3e66a92b9a512c59", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f883a65cd1ad833cd24805eb3d1347aeff98e244f2c43d8956782ea1a6aaff71fa75a1e5a952a8da6309b02cc9add291c8e6332e279da44b2b0a494be745554b", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "4BwEVTjQtwPalZOqQLSS4wlZnOc6Ncf/7wnv6YGXxYKhplB9c81H0CtK9y1GYVWa9ld8hxNyM8QggB6xB3zmVLowrDqT1z3KEPohY0nRoRw=?iv=YrIYMh52kA3yj5g/akYRtA==", + "created_at": 1689975078, + "id": "bc79b93654de0d04a4abc0c881a9240ff4337da92f8062e69d69724d6df9ccd2", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "93d9a154ad18e29eabc67913cab8cb6d2ee20b6c55469afaed55a6c3942f2b100cdc7096b427f82313dbba5bebff2f2d724af4b587245ed36f611e9ec59a7e30", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Make a script to update every hour. :) ", + "created_at": 1689973771, + "id": "7faf1a1ab9f093bf3f5696c8e7c4ea3473fe4142079198eca7efbb722aecd480", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dc86d190b84b44db717f48eed16b29e5707bb336f59ebe1dd38b04525e6bd5d11977e6c2733d212ec1ee0c0fc6e3f76f2c6dfee398098408f26320daf0dcbef8", + "tags": [ + [ + "e", + "84b084e1d31a27a92f76cefd5e745a089e09f64e92e679c238b268af5406723d", + "", + "root" + ], + [ + "e", + "72efbd2fce58b9cc92ec861bc875b02a7252503192e7e3795e78ddc4a80af8fc", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ] + ] + }, + { + "content": "A DVM to find which DVMs to use. ", + "created_at": 1689973691, + "id": "f4e1b1f74ebb814338a1c452dbdbf3aa97a849ffcc3044833bb338eb4528374b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "17a447a47002507cc4f58b1985e9112551d51453d48d076be16824bb0361b3378657faf79e4868b64b1cb82f9ed58de16b0030afce0a09b1194aad1451d9a948", + "tags": [ + [ + "e", + "49f06defee1977eb5a9a2ab86fae6311c616b5455c3f63227753442f5175a0b3", + "", + "reply" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ] + ] + }, + { + "content": "Not on chat yet. But it should work on other screens, assuming that the profile is loaded in memory. ", + "created_at": 1689972970, + "id": "f8a2486f4b5e24e31a653d99358062c67e13d20d68efc940504048385b7b4639", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a9e75594a467ede55727aa5160e4241b8bcfcd741f42524b16c06819f749e3737d307d24ff278bafcfb8f3b6a98318f8c1cc13a4337a0e22c9cb1e46ec06c6e6", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "7d2ae83cfd768993ecdc105e6ecfb864f53a3a84945ac2cc7bef20a93eea9a48" + ], + [ + "p", + "aaaaaada01484648a650df7a59f4e6b0d48e226edd959aa237095f3b6a6faa79" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689972368, + "id": "6fc7fe8d7eb7b9d6e17b7ae1c8a42d5bd5c15de0f3d543b0899961d827bf2e12", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e8a9f9ccc5592892080e09ce9414da1ff75f9a4d7d4885fb9e011a0029d67aed8c305fb841b1b3fdcb0f839c851a59692aaef37a36a679c7be9328c67e904d8d", + "tags": [ + [ + "e", + "297b09d4c973412fefb9a47ad724e4f7ac62c7350eb32c45abdea99af98c2588" + ], + [ + "p", + "4ff36a4d67fa327ef1a686a575d0106b4781600900f68ccb5a40f7e455530baa" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689972158, + "id": "9c250df48d9899d18e634573297aade8e1e81175b272bc60a155e50274c130e4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c78c7b2d9cf42561aba618a11f94814aa6a6f86dd0791b89bf3b1d5db8d86782ffcd3bff3f5fd3f4e9e148d656f2b615128c7dd4217a002a8d91db96bf310e54", + "tags": [ + [ + "e", + "6a7a47dfef9bef875b58763dc6185903ffa4bbeb6f3d01db9999c2cc4bfb1800" + ], + [ + "p", + "21d98206ea80a4aed5a7efa97965b5ef08f8f1cf22ef295c1d14bcfcf4efc3aa" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689972155, + "id": "75f95f238fc8912757dc4d8720a1b76ad471bde1235e11f304c8b4bd8548826a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ce8d68a1d67d24366870915dabc044be2f39e714a9c40dff0b988007cec7db7592c66f4e79b1c01a11ec9a7c4bc1f9dc95bf3bd936522165f9af5b360a0b92c1", + "tags": [ + [ + "e", + "38b911cf18013676cdab7699818415c52c8f3f76c673bcb7bd93eb8946caeb3b" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689971326, + "id": "b90073d643d6632831d65d76098be4a5bb2ef1f1edb040dce63965a09166d3a0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "11352b96b205a1588c64b2c7941199370a80d13fcda0f507fc608fc86a53ffcb20641d365a1213729dc87676d359a5bf2594fb136ddd5c2e36c3b034c7225051", + "tags": [ + [ + "e", + "306f1ce3434139c9761742f13b3f30a91cc0287dfd187d19a0a314a93a1f5696" + ], + [ + "p", + "4149bd2ca7c08ab6321dc1f54176c78acd295daa2030dca04e8010ad992e714d" + ] + ] + }, + { + "content": "Yep, no full profile hiding yet. Though it could be possible to mark the entire profile as content sensitive. ", + "created_at": 1689971219, + "id": "5c2964fdd3a2c531ddb6dae4008f4c8daa26a12b1fdd1c5f23c93e8c2502977d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fb6230e03f1c7e2ad6ebba17e299593566f3f3e25e00e9f770e0a55d70e47a119b0692412dc3686891815c78cfc094ffbb323adc59684de8ecd398b949206145", + "tags": [ + [ + "e", + "f1b86fbbcdf3def82c539ea1cb47116eaeb81806cddf2b55986f79f73b9a1b97", + "", + "root" + ], + [ + "e", + "a2b6c4f5ce2f59e2b665623624935ab9a12ad899809de109eb62497a20674c39" + ], + [ + "e", + "825bbee3f59a086aea0f4ddbcea235e156ff10db4169af01f2b7b240eae42cb6" + ], + [ + "e", + "957ad7b333d2b068d42b3eccdcf1cf4aa2a7e3fd9b4fc3932fd4f43dc9c9d5b6" + ], + [ + "e", + "99ca15337411022cd6dd6ebd12970c678e039ce64b16dc801698a220d010707d", + "", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "1b1e7b22eb297be33b16dbc01d89cf2ee88704390710069feee7ab9ed91deb1d" + ], + [ + "p", + "dd840e433b978795bb35a408bc61ef7e99688ba0b166f8cb4c7cebcb5318ecb0" + ], + [ + "p", + "c6402125e90e82792f580003bbf81a130bab2540d553331c5035ff0864e5a54b" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "D9ZDPT+ym8SiZVDXLY5r+ccfMfJOLZr/Fb34eYAkSNhE4bfz6VeucqqRflgqiZtnpJXzxzVu+ccVMlmOQKKBA0BQD7juHaaaHAXMUXbhLjw=?iv=Fax6ymzr/o0Vj+8pJqHyiA==", + "created_at": 1689970514, + "id": "10bb476b7e35472b292f69e14aab8825ba2921eed14bcfb175e71f5bd84682ca", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fc82e33f3737423385466c4f825cbb8de6232a5d81eb6abf0d0e09405ee0ca5217e307994becdd6f32b23dac4ff625218ffbe930810f372247cdb0f786985df0", + "tags": [ + [ + "p", + "8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa" + ], + [ + "e", + "2721a5c224fdd7b786fb97d5c173444a735b42903ce8daa9d7ac636658360ec6" + ], + [ + "p", + "8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa" + ] + ] + }, + { + "content": "Top-right button. ", + "created_at": 1689969132, + "id": "87f3e8669d345ff58a7314b59966dac2091c6bcd9f78f8670d0f559acec70216", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b112cd109be777d321ff77d9b2a51bf81da709547c21456e29e2e6340399021f313cddfd9e9f170f85a82b6cdd54aa66cdf59e38b49478f3ff71f3ae5097f933", + "tags": [ + [ + "e", + "2ae6e0e1978e9514561bb989f5da7e8adbcf8f1630d7034a3a9ec7118888fe26", + "", + "root" + ], + [ + "e", + "9f3e00e694697dbdd682a83ba9328956fbdee15ee98c8247f609c619d5b1ba48", + "", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "7bf7db83f73228f5df6ba34849f2af9fd54bf565b5ad698ac708249b310079a0" + ] + ] + }, + { + "content": "l0giUaY9CWhvb9TJDppm/TxERuBTIKu7EDV9iJn9pfAtoQAzhq6DOOZxspsFc54b?iv=0xlAHbPb/lU6u6M+mMrVgw==", + "created_at": 1689968865, + "id": "e79169091e1262551ce7d9720910ab4276e9ba3a90375f0eb6b82133d5033d37", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8b7559ccd3d527efad3e1f0de07fc73957d39d1261f665a7fe21c4aaccb9fce895eb7ce1371aadc06ef9ce2dd7eba6a2cc140ec05de6f1b7cbc53b6cf5612d1a", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "B0LQfQWsazGI7Z1U0YbsslTRgrikDPkURzlU/ZaA1dNb8xG/62ZJI6glJTmSTh7Nx0ZlnqviisPqU17cxWwjJe3vPM1xop/FDsha3ZdmFYY=?iv=jIcTUYXKc9pnAWlEvwNX8w==", + "created_at": 1689968856, + "id": "60b7f72e911d92f54f3db9d207a71fede0cf78692e110859c9e8915b7f745175", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0e227cf32752a175bb5be027064d6eed57b7f6913f092871533693124da6f6eac0ccb0491ec5fb88fc23995d0bed303c5f5e01a7a61adb6c76791767c84f62eb", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "GTncRNSHERzHjokQ4zWRCk8q+0Tj98EIWVfDlETRdnb34tUvZoK1NQ97BXzyj/aRJz3t6Qc91z46vLursOK2Sg==?iv=5v/i58l4GDhGx3BpXQciFA==", + "created_at": 1689968831, + "id": "c96f42508779710b8d09b8f3308e5e3ed8069983e574ae64fdcb23eea01877a6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9a32548210a39226d8eda5ae21bd4f0d7159e4bfc4b7bd11014deebe735e8ab7cb80a525dc73b67127c4bb0646f738b8f4ac0ceee687daad02c6a55461fc7678", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Hummmm.. looks like I need to fix tagging live activities. :( ", + "created_at": 1689968448, + "id": "860c9594fcdde6307bef4f6acb99f7f155e1061458b8c9894e3989b023c59d2f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6fbeae674d410eb00ee2a17a0735ad0c3e746bf6695d8239c1cd0094e45750752d8b68a4998724894448e887865cdca56c60d2a1e0cc07633b9e4d6f7bbde939", + "tags": [ + [ + "e", + "2ae6e0e1978e9514561bb989f5da7e8adbcf8f1630d7034a3a9ec7118888fe26", + "", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "Turn on Amethyst, go to nostr:npub12hcytyr8fumy3axde8wgeced523gyp6v6zczqktwuqeaztfc2xzsz3rdp4's radio, pin it to the background and keep browsing. 🤩\n\nnostr:naddr1qq9rzd3c8qenzv34xgesygz47pzeqe60xey0fnwfmjxwxtdz52pqwnxskqs9jmhqx0gj6wz3s5psgqqqwenslj8h0y", + "created_at": 1689968401, + "id": "2ae6e0e1978e9514561bb989f5da7e8adbcf8f1630d7034a3a9ec7118888fe26", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c43f4af5b693201d6073e3034dccf272954812547470c6f9a0f73b5dc6afa8b129de124676da58dc52efffb3f73449484508306e9d55154f70af8dfcb3a76d68", + "tags": [ + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185", + "", + "mention" + ], + [ + "a", + "30311:55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185:1688312523", + "", + "mention" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689968322, + "id": "34f6196dd0ad0cdd727f936783cfd705271fe8504c5a114d18a03a16f0b2d825", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1b02863fcd487cbea1b8e6a45ffdcbfefd130e3560af1192398ad02349df452281b6b2ac5b2135791d8f80f64cf21957eba5951fa85a5eddbbdd9781a8de4629", + "tags": [ + [ + "e", + "c9d976864bcbe0a3be5b025c89e671874d16354dc34632f340bb4387124a80dc" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ], + [ + "a", + "30311:55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185:1688312523" + ] + ] + }, + { + "content": "", + "created_at": 1689968220, + "id": "e6e3cc971eaac101d1086cc6f8bdc140a631aba8cafbd12fd795b7c07b6ae333", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1c2bd83a4745bc62684d4a78c6a0b000466ab95b49daba18115a9e92bb57525294f62f3df31e4397d87224f0644e7ede7140636eae36b0094a9cada49949507c", + "tags": [ + [ + "e", + "0a7c4bea9affd6a5614a17889514c7a2aeba60e63dc001743739f7598e3fed70", + "spam" + ], + [ + "p", + "9b0d19ebfbddc17922a0cd8df1d97e73d8ba106fc80a4d43b3f815f3f1a08983", + "spam" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1689968220, + "id": "7878ced274809d1c8d0935de9f4ca67e12f61a6d3e8688e0f939e9616efd78b0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "86d8360ec24069f6736c46edc01dd713555e0a7f56da5c48b9b015878f422f5a36faaf68dee1f77360f56890d7d4b9081df08da370a02489d30777fc9994afb1", + "tags": [ + [ + "e", + "0a7c4bea9affd6a5614a17889514c7a2aeba60e63dc001743739f7598e3fed70" + ], + [ + "p", + "9b0d19ebfbddc17922a0cd8df1d97e73d8ba106fc80a4d43b3f815f3f1a08983" + ] + ] + }, + { + "content": "Keep updating that event. Amethyst marks it as ended if you don't update. ", + "created_at": 1689968204, + "id": "84b084e1d31a27a92f76cefd5e745a089e09f64e92e679c238b268af5406723d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d7f66d9f224580504fd39bd6a8caca7941ec40efd015aa114c8b0b6500b32c74ed96622e57a5b907f39df73b753b2f20e765ca0ed533d36ad5dfa15473825a7c", + "tags": [ + [ + "e", + "6557bea21d980ce1fdf671fcee3d3295a6beb78f63d29729f5d284815cb57751", + "", + "reply" + ], + [ + "p", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ] + ] + }, + { + "content": "hDAdqWH/tj/dzqyXNL+7fzEZ83mJKsbHRXMdln4y+B2nnCWKkNTdrFxU+4L7DdQC?iv=Puix1vCTdriA+pLxfC/qnA==", + "created_at": 1689967568, + "id": "28cdc408713dcc255edcc8db05bc0e40eb486321d25c43b8d203de5e70941f4b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c797ef7c1b05d81c24c1aa60cf491b2df71386634175bb2ebffa9f8c78c7f956d9f56fe586366cca91c3f25a3f9b951688aa8965c690c4b70150baf096c24f61", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "HqXbmGO8PjLriW8nFNTMsQ==?iv=llg6zk1KYvXem7OaE9bl8A==", + "created_at": 1689967556, + "id": "f88c0965f79b79d2fb58f93e844f46d91776c2d366374f226ddb935be6d95ded", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7e1b3c32dec8c5dd3ceb310ae2bc881aa8ce838a1fc9acc33ca9ba9907665e1cc972d0d229294c56c34f4c490b621f1f9a0df0f081181ed0c9e06dee7bf2c476", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Sx4QIMpVV0QEq6oeXH5MylS1Y733BReuPr0otd423lI=?iv=LBuyRw8BsOpaBbU5xG+BAg==", + "created_at": 1689967552, + "id": "6af5ca2203c5b93249bd7fce7d6ff3fb426df38159927c85e0c263905ccd91b4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "68d0d30378769c1ae389df6ed0bf20af7d35311f8fc26ea3067929d24d7a5ae896e78f3d38ebb264344e8f066782b230f51337e0941bf2f4ef83e74829006557", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "xGwYwz9X3f0s3xYD8tjeG0eWoXaDzLwXGth7ID6o4YzWNZv9uj245SNdQvYQuqzg?iv=UnnU6U0xomni3FQFRqQmKQ==", + "created_at": 1689967546, + "id": "90060a1cd906ce9ea5c790839ab1232aaff691e8931c44f57ccd725953f5a40f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b6d27a3c34fc53bfc816693bdcbe77010b4c1f9d7e4817af043922746d7f200133243cf5e2f85837dae935c393e5c4c4b6cfb4ec22c245620facb2f2afceb246", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Video is so hard :( I will keep an eye on it. ", + "created_at": 1689964286, + "id": "49b412af98a3b519f18266a67bf10577a68a7cdb3df26d234e491a54980d87c7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2fb538efc3a33e80100a3dc78ddf779e15945f5b676afa5150f0b66382a409554087f2f9e8a1a983b41435db48911aba6b7a30664e12f7313ed4cd98a4356ac6", + "tags": [ + [ + "e", + "438c70e797d8bb3ddda9a1c15726a25f2034c1c9a96337d801a377448ca754c8", + "", + "root" + ], + [ + "e", + "885849c65696f340433c58a054df5ef27937ea5efe87405fcdcc60741748f7b5" + ], + [ + "e", + "dc6a448e730342262f978f1638137b3621e3166d1fa17efbe0ae972a24de0282" + ], + [ + "e", + "74d50ebd3d2104014265ddf72a81671e6d15cc6fdb68f4706eb5a6b3612d3c10", + "", + "reply" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc" + ] + ] + }, + { + "content": "", + "created_at": 1689964202, + "id": "b2a975890c554d6071d9dd1200cfd2020bc8be752d5e845e8d6ab6e5eb92f7ce", + "kind": 5, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6682fb0170db6a48da22983af8ee44aea60c80b42f303599c0119d96e395c74ecb4ce6a10efb8c6dbc810b427e72d29374cccccac5a34dfdd418caa656faec2d", + "tags": [ + [ + "e", + "8b22b43b5c93e5b84830446c7ec610fa88155dc62ecca4d32514e63d49081335" + ] + ] + }, + { + "content": "Last message: https://github.com/nostr-protocol/nips/pull/468", + "created_at": 1689964112, + "id": "5ee95e9c26d48954d6e842bbde5aa2f54d3cd5cdd1e42eef1625f3fbdcda7795", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "861af1f7cfce98b5d84c728e4a0d666d91fa42b5a1d58eea1cffabd39215b2441f39bf1a8bac41dcf9778ca4b01fd1d45b535a51c423f7f591b551b7ac901858", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "ea9ff60451079f154495ce532c537e0509e2f9add03b24b0d5a16166732e4ba8", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "It's a very effective way to avoid reports. ", + "created_at": 1689964080, + "id": "d2186814284909245032b96a0ca44af635d4a4c2cf77e57c2119704df80ac9cb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2f1032e162dfc502edce54e862eed06e944677134c7e831e2caced19f2a6614d78440deb6e7cbad3c72c6f6bc834308ebb19e4df248a8b9d93bfd9ebc8b4aa6", + "tags": [ + [ + "e", + "409233edfd6dfbe4c052e53552cea2b5d99427107e638c08a5f76412638e2997", + "", + "root" + ], + [ + "e", + "c0c727063a3ba2e9c21604df63444fb97b7c39dc788734762f2bccea31fe45a3", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd" + ], + [ + "p", + "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6" + ] + ] + }, + { + "content": "get the video urls and send me over if you can. ", + "created_at": 1689964038, + "id": "dc6a448e730342262f978f1638137b3621e3166d1fa17efbe0ae972a24de0282", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "62f6f79c609297e102ac206ab6e5d46f6132c0c6db1cedc27afa96e1e1883159cf9c36793aa6bfaf943e05edc67b2e908df87a38b7769c2768eea615848cd5ba", + "tags": [ + [ + "e", + "438c70e797d8bb3ddda9a1c15726a25f2034c1c9a96337d801a377448ca754c8", + "", + "root" + ], + [ + "e", + "885849c65696f340433c58a054df5ef27937ea5efe87405fcdcc60741748f7b5", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ] + ] + }, + { + "content": "Bt0pzANqo6JDNMOVSZtRGduXq7HpQB4pXZxi+jE6ziUhQor/HhA/iDUO5BbtMD5AfpOpqisuLzf7HywST79t5TVbtM7UGBm9/iTfcZeg5Fkyt+3V9G1ef/lTMw8b3BvB?iv=6T5YVB0wcifzlVwyasCJjw==", + "created_at": 1689963534, + "id": "fd438f5148e6e847ff748d564f806cb15a4603be5e0c27f8fb192bd3c3f6ed67", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f4f1e999f951e672340d01a8c1891fb475fc54b4275597f2ffa4d92f5f0085502d5345741542239a3bb508411bc9670dc22125c46e78957e99d4df415322abd5", + "tags": [ + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ] + ] + }, + { + "content": "Thank you for using content sensitivity ", + "created_at": 1689963327, + "id": "796d257f6e06d8145d0ed795f3695bc4c175d7a24890922be3852e4cd246e096", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7b2b3eec7f5c1cad7d655d59d521464c6ed429eda787ef31cb3d44b90e30d770419dc4034e0b887c7091a933f51fde3c7e92880a2bf2e17621f62617a1b5476a", + "tags": [ + [ + "e", + "409233edfd6dfbe4c052e53552cea2b5d99427107e638c08a5f76412638e2997", + "", + "reply" + ], + [ + "p", + "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6" + ] + ] + }, + { + "content": "8Ab731Z61Ct6sIERglBUgqRxjGrJHnyrq820HqMEywNhu1gIIPW+7dvM/XC0nv6BjXBLGuLHDswYbGhVMXxopQ==?iv=EKyBALmNq+k2B46m3v+mPw==", + "created_at": 1689963305, + "id": "0d7ea4e5dbcca7d6c7ce3e7013b2261fbdbd8b767821236c4c059b51da3bbe65", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "625170fff7fde4f0949ec574706cbf39b1eb8813bf676bf6ea22a5973cda0333467e0f748ddcddbdcfc357d6cddb8e4f49b4f8f0c7cdecbfcd80e40ec585f048", + "tags": [ + [ + "p", + "1c6b3be353041dd9e09bb568a4a92344e240b39ef5eb390f5e9e821273f0ae6f" + ] + ] + }, + { + "content": "0ONkFjclPM/XKdRfSCPfpobIMumwXkIEW2y6u13PG3M=?iv=sLeGSt50ih42yHN6V9xIvQ==", + "created_at": 1689963292, + "id": "df3e74b5af761d6f1ef71c0258e27d9894dffc346c9d4df3c3da29e0889975b2", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8c1f8338736612bb6f5de0d4f6deecba028a66e41c74d63f83cc85bb1d4e2b8106a82b7b58e1353a54fd8529363fac11514f67d5426a2aa06048c7c19d2d8a54", + "tags": [ + [ + "p", + "1c6b3be353041dd9e09bb568a4a92344e240b39ef5eb390f5e9e821273f0ae6f" + ] + ] + }, + { + "content": "Super strange.. all of them? ", + "created_at": 1689963074, + "id": "0ec5ff7ca4b1585cfe21db0e071f6d49c839ddbbeae9d322f1c30b016145276a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3f2e3efdc3d54eff17c5ef2d2cc86985582fd8cddad7e2ec214b2f2bd52e0e40b3414fc43ad6578ceed602ca29084a0c7332a6c19ab5710b4901a79964b72078", + "tags": [ + [ + "e", + "438c70e797d8bb3ddda9a1c15726a25f2034c1c9a96337d801a377448ca754c8", + "", + "reply" + ], + [ + "p", + "4b52aa385912d5ec98b773211aa884b1a6656e8fe6bf7a151a6a6f6adf45c417" + ] + ] + }, + { + "content": "Yep, just click on Auto and set it for German (make sure to have a German keyboard installed in the OS) ", + "created_at": 1689959447, + "id": "aab95307fedb358bc79fa51699cb627e31774e5305bc178857d1a38d624932a6", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2461b06ff9a8c141646312c5a59a08a44b842f43386f73d32c925c18a62ae0b993560c6480719f3e1252d5a0d521bca7d077efe614f864e82421a8d19403f249", + "tags": [ + [ + "e", + "0b6153cc21d36e51edbd2c30c7c45dd2dde66c6bbd0b0b5d508a613f277b1ea6", + "", + "root" + ], + [ + "e", + "81d53f4fa555d6aa29175ff2235e34cba40a949eb3be8104176255451ebfadcc" + ], + [ + "e", + "01186521fa18cecbe9bebcf6d3b6194cef6dd36ae43a3b1306601e2cdf0debec" + ], + [ + "e", + "f8ec20358eb2d8c6f3335aa0a1da2ae40b0e3163fada3a543fd2c7ff2a1085b8" + ], + [ + "e", + "c7bf286ba483b4bceaf758b372d3d35d58398962e5e31a6b76ce38a614e5e5f1", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "We should have both implemented. Market decides. ", + "created_at": 1689959301, + "id": "47652f48803fa5e109ea9ffe514feb183a613ada5ad23df64f6be09971ed06bd", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c402d5cd2a50e0d0f26bcb1c15ee09b419d2985898aa83fe89a353270a0900ed6750cb6354222afa316ed29e16c61492f75215580323c1aa5cf47e82e89846fb", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "ea9ff60451079f154495ce532c537e0509e2f9add03b24b0d5a16166732e4ba8", + "wss://relay.damus.io/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "", + "created_at": 1689959268, + "id": "92dc035f4ee6747cf9b12f5d3265c6f015f4e0e2ed89ef78fa46dfe8f4536b24", + "kind": 5, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "300b8bb33684e433b4d7908c51f80286d4d51b2d3b82967aff78355fda768e0cbdce22fa58b5630a68ea622248f0d9363ce1dba86f7f743b25e778ebd6901b1f", + "tags": [ + [ + "e", + "8b22b43b5c93e5b84830446c7ec610fa88155dc62ecca4d32514e63d49081335" + ] + ] + }, + { + "content": "Very interesting... Maybe the app is never deleting the cache automatically? How much space do you have in total? ", + "created_at": 1689959088, + "id": "bf1866daf7c88ee0272062b10c26fd4d77c37e29fead13820b55f4307c25df28", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26bd4ab68f0889678c6c8d17e1a2a612e2435cf3055bdbbbe81cb4795bf9897e930c6b82c5474e05946f4ff5cd31da6bad5256397a8bb0c9db0de411df3d930b", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "c87acc1325604410e041e4a2a363e1d9ddd0c6eb97c05d5cf79a41101729b281", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "0d1dd56ae3204328e45f78b1a64ac8f06d227129f775493ebe84cf28250d1ec6" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "You can do it in a new event kind that is similar to private messages. In that way, the only way to leak the message is to leak one's main private key. \n\nIt's possible to leak it but it has a cost. ", + "created_at": 1689958824, + "id": "19e4caf71bf318b48942caaeb8c10eda847ded4240fc51831ec8e73e52e09b39", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "49bad4e3a8e9511baac9c58d7de984dd754dd205f321c2dc4e92b757d043e1ad4f04fd9e605bcca5244134a87e1471377bbedb085f66c3630bce59f6698a4e60", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "root" + ], + [ + "e", + "3bddcbc6ca72750f7ed63e1468a632dae75ecce119a18a52c624c7c047fe2b42", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "Then just wait. ", + "created_at": 1689958465, + "id": "c7ea022e8d7826ecb7e92a7842e3cb6d29c18c9542b872aa56f08fecd3ec09c2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ee20a289e0d45068f04fb0c7fb6b98af8f020131b03246be70796f93227cf181fa4f637e55c4ca3271741de7efa67bc6f712ad5a6c53e1d2677b9f7c9184102c", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "d1aa5d57ae4fb0fa142acbd0554f92ecbd7b43eb95811e9874710d17ae1c3988", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ] + ] + }, + { + "content": "Once you sign and give that to somebody, you can never block its spread, independent of Nostr. We can make it harder, but somebody else can make it easier. ", + "created_at": 1689957408, + "id": "4145fe4ef02e0f19b43cbf5a360093b828ccf7eff205f6a92498354f10f3e584", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "583f5dbd61495a31b663e9eaa337d3d4e9f63a795bed5d578e85374c289ac75de32de47f7d08c41cb0352d1bfa3ff491484c14e01ebfe3eabed7833d0ea68a10", + "tags": [ + [ + "e", + "f7ab0a2197676c2da1af737bb1c09e4c750b6ebad3223acf72c3648372540755", + "", + "reply" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1689956888, + "id": "fa03a5d6e5f2ea0b52bdb88c1656d90a18709cb1b9ab9ab184a22aaeeaddee85", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1d2ac2e49963532028beb37bec2ab69fa1ad09de2015fcb9d3fe54c0015c768b09096b0133f78e3d51c1e017bc3565ee10f615f227704cecec86645b969ac823", + "tags": [ + [ + "e", + "dd89366d32893dd2ee4f8b22a68c9d19673b031dbe7a780e1ac14a39354f1e58" + ], + [ + "p", + "c1a6940f84cb4d32564837d9ae055b036545c8e5f61cdd24f29786876436df8a" + ] + ] + }, + { + "content": "", + "created_at": 1689956888, + "id": "db4a85c524e8675494bab0cf0c1ef3a83809d1a954b159cd3a27b3fc0bac91ba", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ca9b7e59d54d109f789fa4ae6e67ecad738f5bd266f8d71ae5561d03aedbbe63dd70264a203662138b2e1addcdf1c4039b6a708d66a27555a213b22c247f8405", + "tags": [ + [ + "e", + "dd89366d32893dd2ee4f8b22a68c9d19673b031dbe7a780e1ac14a39354f1e58", + "spam" + ], + [ + "p", + "c1a6940f84cb4d32564837d9ae055b036545c8e5f61cdd24f29786876436df8a", + "spam" + ] + ] + }, + { + "content": "", + "created_at": 1689956881, + "id": "ec4dfdfecf7979e5a5e8240d8a522893d414012e961e8dbe653c278bb82701dd", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "84d0b6acda593495ea135c074c539c062826727568039ca0e4e575c4f30b63416a8501faba8088b5b4d19ba9275d3ad8dde05397c6d5d5d55cb868719af8153b", + "tags": [ + [ + "e", + "91d440406cbb8a6514e225723c42d4efc3d359da1ee57098a1d86cad3ab879cc", + "spam" + ], + [ + "p", + "ccec23ef54ebcfd3bcbf71c4e28d8c32087addda00710197e72275a04828648e", + "spam" + ] + ] + }, + { + "content": "⚠️", + "created_at": 1689956881, + "id": "d2ba718ea610c9f2715fa8e0a65f5568439b31a604ec33383e51654767e03116", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4c0dfa3a5d1da1f14e5d1fd4e092d44e78d1dffb033b5d64af9ccb691bf0b9e23153867f71a42d768ba107988e752b800ff8b9f4972df5a3f420aa4a2200715f", + "tags": [ + [ + "e", + "91d440406cbb8a6514e225723c42d4efc3d359da1ee57098a1d86cad3ab879cc" + ], + [ + "p", + "ccec23ef54ebcfd3bcbf71c4e28d8c32087addda00710197e72275a04828648e" + ] + ] + }, + { + "content": "Actually, it's the second. It's just the first I advertised 😁", + "created_at": 1689956873, + "id": "72bfb72cd240f6fa6ad0f6aa502fb9f0f9d8d862bf61df28bac61c35ea2c531f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "21e168c8bad482131f6bf06b69540d11152911de296e657450c92214fe9073742d93d0063164c0516ee959c2f41610e61d0bc8c87a304695d05acd69c0cddfb5", + "tags": [ + [ + "e", + "fca1af2a576ce6f8455faa518475687e857c8dac514fad4148f56b68b25de64b", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "Thanks to nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5, Now you can choose which relays do you want to post to.\n\nhttps://cdn.nostr.build/i/d43712f8f8ec312dde3b376afe1339cab94b1750c1f89185cb69f5ba0c9a6687.jpg", + "created_at": 1689956778, + "id": "277fbd58c7f1ebcb09f7211361a540a38f07c717663b5494af1b9847236bb560", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2f60d9776f1453f24a70a471f73395660240931e541003154c48bb9e6f6358252ec678e3e5f6213ca209bf768ecd935fc1d266b1eb51b9852c62c0133121b335", + "tags": [ + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19", + "", + "mention" + ], + [ + "zap", + "greenart7c3@getalby.com" + ], + [ + "r", + "https://cdn.nostr.build/i/d43712f8f8ec312dde3b376afe1339cab94b1750c1f89185cb69f5ba0c9a6687.jpg" + ] + ] + }, + { + "content": "Did I forget that part ??? ", + "created_at": 1689956705, + "id": "60b045e3acdc14f003c241e78d6c802420375bd324e85660c43be3e84dbc9de2", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4f6c79243266abba1e55952a95ccee0c33ae8c9d7be5503a9765407c545ebd2e80c4b65a6310f6538dc6d7b4b1ec0672afe6a2bef17b3f11882494e9771a1071", + "tags": [ + [ + "e", + "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "", + "root" + ], + [ + "e", + "bd3a2dcf591c43e69bf1d54d8a5fd7a379ad19458f79b08df6b756059558569e", + "", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "### #Amethyst v0.70.3: Fixes and Improvements\n\n- Stops Video playback when switching in and out of Tor\n- Adds a space when rendering inline images and url previews for nostr:npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx\n- Updated se/de/cs translations by nostr:npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef\n- Moves the video caching service initialization foreground services.\n- Fix boosted notes from blocked users appearing as blank by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Fix KeepPlayingButton color in light theme by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5\n- Refactors ChatroomHeader compose\n- Moves the loading of an Accounts backup contacts to the IO Thread\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.3/amethyst-googleplay-universal-v0.70.3.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.3/amethyst-fdroid-universal-v0.70.3.apk)", + "created_at": 1689956180, + "id": "df9b11fd6029e3b4c27bfb5cb59aa152442a03d57bc5b759684263864ae729af", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f36eca3f55f6a575ea5cb44e73ed5f98f8fb0c44086d4dba1e8f7e57d837e8749c5bf14825898572bd8cf612abeec12d825e4ad5fbdbf911540c77f241428da4", + "tags": [ + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "p", + "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19" + ], + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "UFNglwaQDiNRbqo4P7NHfA==?iv=gRBjwGJVDFtiGR/XEWGfgg==", + "created_at": 1689956136, + "id": "a42b28e1008356cccd329e3fc9b8f33d42bdfc02891d32bfa2218853f325344b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "159df581567f73f2eec769c8b785399eb20a42211a7c569592f7359406c915432d353d6af8011fc723393e3057bcd23f5b8b1ccf52a682544ed0e1f367e249e2", + "tags": [ + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + ] + ] + }, + { + "content": "It's 25-50MB for each translation model (each language pair that you see in the feed) + raw images and videos (which are big because we don't have a central server to scale them down, etc). \n\nMay profile pictures for instance are 10-20MB in size. Some GIFs are 50MB each. \n\nSo... It fills up quickly. ", + "created_at": 1689954869, + "id": "f13fa631fbfd476b06f7027d2944dd5b0cdb67d0755148c95d6030af213178a6", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c78bbcf217fd0b528876dde5443e429edf45536d079d14e844e41e126225893735c4ac025525f5e437ce6546e7eb2a5d21f09dc83bb2677b7077212c34d5c6bf", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "5660cd2929f688d17a0fd713301c4d3be0f9c8c76f9b4fde611f23385576f327", + "wss://nostr.mom/", + "reply" + ], + [ + "p", + "8ad25d02882c05d6ac20fa7c230dfd1c3d7f6de3976d9c5bd5ecc7bbb63495ab" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ], + [ + "p", + "0d1dd56ae3204328e45f78b1a64ac8f06d227129f775493ebe84cf28250d1ec6" + ], + [ + "p", + "8ad25d02882c05d6ac20fa7c230dfd1c3d7f6de3976d9c5bd5ecc7bbb63495ab" + ] + ] + }, + { + "content": "You know.. you can create lists these days.. http://listr.lol ", + "created_at": 1689954628, + "id": "c233de5e1d9e9d342c24484d694ddaf1a8f5b874f10de2b26ca1a76102f3db18", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f9c10d14b065f1d0ad82418e38231a03bb3dcb728c54431e3944b67068cd5405ccbce3fa690bae8f485da2647d8d7c61543b52cde87187709eac0daddefd2d93", + "tags": [ + [ + "e", + "51c78616f2de35d6c0b3a15ae2d23dd3c084bb976d20162ae5accd81a136571f", + "", + "root" + ], + [ + "e", + "30555fb1b6bf99910d21c243c236dc3fbd5488a06bf7151f21fe2385316f7f5d", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "916b7aca250f43b9f842faccc831db4d155088632a8c27c0d140f2043331ba57" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ], + [ + "p", + "e034d654802d7cfaa2d41a952801054114e09ad6a352b28288e23075ca919814" + ], + [ + "p", + "ad9738030ab84c04ffaec64abadd6cc682cb3501d193a5ff1c94b90770915558" + ], + [ + "p", + "385eacfa42fc0831b4975983c485e0c7c55ed0f5e4f56d79fa7a7151fb0a06d7" + ], + [ + "p", + "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e" + ], + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ], + [ + "p", + "cfe3b4316d905335b6ce056ba0ec230b587a334381e82bf9a02a184f2d068f8d" + ], + [ + "p", + "c89cf36deea286da912d4145f7140c73495d77e2cfedfb652158daa7c771f2f8" + ], + [ + "p", + "52387c6b99cc42aac51916b08b7b51d2baddfc19f2ba08d82a48432849dbdfb2" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ], + [ + "p", + "958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c" + ], + [ + "p", + "d4338b7c3306491cfdf54914d1a52b80a965685f7361311eae5f3eaff1d23a5b" + ], + [ + "p", + "6f0ec447e0da5ad4b9a3a2aef3e56b24601ca2b46ad7b23381d1941002923274" + ], + [ + "p", + "ecfa3c5c82d589c867c044056f75d6cff794f1886d5ebcdd48ad851da47adae4" + ], + [ + "p", + "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450" + ], + [ + "p", + "a8171781fd9e90ede3ea44ddca5d3abf828fe8eedeb0f3abb0dd3e563562e1fc" + ], + [ + "p", + "8be2fd2cf7cce65a56f0820b022125e9ab4044c7dc5e444e2c0c0eab7501b0d7" + ], + [ + "p", + "50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e" + ], + [ + "p", + "9f807153876ccb1db650a96fb7ada5a1dbaaa0dc3f22fd3fcf545204aeef43f5" + ], + [ + "p", + "668ceee55475f595ec0e8ef44c64bf9da8d9dd6008ac54dcd24b2501930b960e" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "b5ba65fbb0221a32b6c14400f505cfdd3651d43938a248a9265a516ec0c54240" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "c82bf12d7eb3df7e3cc7766e54363133ac17024a5df2e179c603106cdf203e15" + ], + [ + "p", + "404211c6e9763f49c8c0d8622e6fbf216550b748674247400a2527e0f95e3a79" + ], + [ + "p", + "b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc" + ], + [ + "p", + "47d519cd3bfe5cc6705e32063ef39931946979eebb53425db5f2e4f19c3a3af5" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "f1b911af1c7a56073e3b83ba7eaa681467040e0fbbdd265445aa80e65c274c22" + ], + [ + "p", + "8867bed93e89c93d0d8ac98b2443c5554799edb9190346946b12e03f13664450" + ], + [ + "p", + "7560e065bdfe91872a336b4b15dacd2445257f429364c10efc38e6e7d8ffc1ff" + ], + [ + "p", + "f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8" + ], + [ + "p", + "803a613997a26e8714116f99aa1f98e8589cb6116e1aaa1fc9c389984fcd9bb8" + ], + [ + "p", + "a1c1984994512025327f52c7b6d3a1434a37fbb7a318380cab4832f8daacbb52" + ], + [ + "p", + "9984188a6578eb513fddcf658f389dbd532e54b82b628ad36666f7aa8f731b79" + ], + [ + "p", + "9168772564e66c07a776a3e2849b02d1a0ac88a7f8e621600c54493ca0de48ea" + ], + [ + "p", + "6fda5bce2882176bfbabfab503a1b5281329582d71c4be84bbb567e65c1a791f" + ], + [ + "p", + "a80455732d5bfa792f279011a8c871853182971994752b9cf1169611ff91a578" + ], + [ + "p", + "971615b70ad9ec896f8d5ba0f2d01652f1dfe5f9ced81ac9469ca7facefad68b" + ], + [ + "p", + "31da2214d943b6db29848bfe7e3cf8ec0380014414f06cddb0eeacc9af2508e2" + ], + [ + "p", + "26bd32c67232bdf16d05e763ec67d883015eb99fd1269025224c20c6cfdb0158" + ], + [ + "p", + "c43bbb58e2e6bc2f9455758257f6ba5329107bd4e8274068c2936c69d9980b7d" + ], + [ + "p", + "4ffc11bfa2f8516ae8bc7c6bf82275d358e47045446250be2d0e5612e2140828" + ], + [ + "p", + "50c59a1cb233d08d5a1fb493f520c6b5d7f77a2ba42e4666801a3e366b0a027e" + ], + [ + "p", + "a3c1a5ceda8b86b7cb64d5d6af58fc787ba400f2912b907969d27547f96545d0" + ], + [ + "p", + "1e9d809ea96f8d7227f06025f4ea2dd41e9426c4276d96a70770987c8013d21c" + ], + [ + "p", + "a12fb7fe051724a34ef3409ebcdb377b9d1157a79b7a2fcd8d008995f190d9fe" + ], + [ + "p", + "c02bedab495a8d73e23192fa161a0b8344821f48446811b1e810fac65b584f49" + ], + [ + "p", + "fe2d5cf62e95aab419b07b6f8a7b75d3cb3066fae25c6b44ace0f9f30c59303d" + ], + [ + "p", + "5133a0947abe801445c34e2f56151d8690f4789ab4c763275b14166e74c5830f" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "b43c91f2a7b9d21bba3ddb08cc321840df249364fb716213508bb17383e1d930" + ], + [ + "p", + "eeadea6cbb5018a190f0117857de513cc271d24c947d56cd82c54a6b64ae47a4" + ], + [ + "p", + "de90c5db36a4011f9d584dfc18de1a5724686867984793ef526331b51f8b43e9" + ], + [ + "p", + "6a02b7d5d5c1ceec3d0ad28dd71c4cfeebb6397b95fef5cd5032c9223a13d02a" + ], + [ + "p", + "cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031" + ], + [ + "p", + "df0aed92dff00d4ffeda2f4cd88d967dc576b5549400b92afb98a419aa06e6f5" + ], + [ + "p", + "72f9755501e1a4464f7277d86120f67e7f7ec3a84ef6813cc7606bf5e0870ff3" + ], + [ + "p", + "6e0a1f4852bc8fa42b8047d60a81930d88904ca3f2acdfb5b8413ab8c9f444e5" + ], + [ + "p", + "18905d0a5d623ab81a98ba98c582bd5f57f2506c6b808905fc599d5a0b229b08" + ], + [ + "p", + "90b9bec74789688e515125596ab6350bfe646176ac75742275063922c5fea010" + ], + [ + "p", + "ee603283febc4c31b09903392408a2fff1daf69ac2244a5e4ad07eca3bc79dec" + ], + [ + "p", + "f508d64aeb31bac052327841cc7dd59c9d37c182102a048a439098a8bb816dab" + ], + [ + "p", + "f07e0b1af066b4838386360a1a2cbb374429a9fbaab593027f3fcd3bd3b5c367" + ], + [ + "p", + "b82b98dad630f797edf2ee7e48224577ac93cfe899b3ca2a26d91b3edfaa2ae7" + ], + [ + "p", + "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0" + ], + [ + "p", + "7b2d73a3ac9714b06500005454b1b24ff255304eb488076822122101d0fcf7a2" + ], + [ + "p", + "4308ff20ced73871b69ed8bcada2a051bc3a05610d1183c54d67b47cd0f7035e" + ], + [ + "p", + "7f1b2d20466fd5ff836c6bca1a0849f9c77162cfc00b7e708a9142763c021673" + ] + ] + }, + { + "content": "Yes, because you can receive an order from the state to provide everything you have about a given IP. You can hash the requested IP to find somebody's past actions and deliver it. ", + "created_at": 1689952040, + "id": "b35f1b545a37833991adb956498d55fb652e3e19a9cfd1cb6cc658c0da547a6b", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b46fce54c4e8e3136f73c769240149bf1fed84c293c6b19ee1909a305e92a943f889ebb9637bdf1f632058c1e80a4c1393112cbf18007b54a544ca2c8fb6b23d", + "tags": [ + [ + "e", + "d5fbba30ba547c6335e843400d698e5d9e87463fa7f1d03ca555dcc70fb17c12", + "", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "cxVTHgjC0TXaOTfTi0b/KJk+MLCff4HbxDT6zjwDG69KgMHIw/Yvn0NjHxW87PmHSDTlDaBGXKeDS/7BcY2nqg==?iv=f2tzSqTrlQoXwG8/46dOlw==", + "created_at": 1689951312, + "id": "ca2cd04e3cf4efc9534aed57b1899feae816e02c6a2e8671c2027d4d9f6893fa", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "af1214a7deb8c740b0e7ebc1f55d04e813fc69a4b1f225d7b4a47176230eda817a70f7730a21f518128abb415fb0107475d05a8a0c935a6f6864f26861810421", + "tags": [ + [ + "p", + "4b6147b45bbde75c2ce4cf93444675945c47f41ffd51e3446287bbd56ba668d2" + ] + ] + }, + { + "content": "https://github.com/nostr-protocol/nips/pull/673", + "created_at": 1689951049, + "id": "416181632a67703a81b42b9fea5ce5eae64e8d6c13e7abf46de4a1d66a2c509c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "70fb4cb049e4f4399327272847007fca1f7958c818053aa92679e7e3a665b64af019c0000f0004a13f2ba520d69e61529a8b7c35acfaf85b37cc9e1c7bab1c69", + "tags": [ + [ + "e", + "88c9c9b3fa3b378414e5225e78b7ec5c2a93ba5a508f43c380e70cafa3ade713", + "", + "root" + ], + [ + "e", + "cf98d38c498d3c21669d9e7fa9843589a0a481d17392c1d72848bdc0f9743e99", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "dd840e433b978795bb35a408bc61ef7e99688ba0b166f8cb4c7cebcb5318ecb0" + ], + [ + "p", + "c02bedab495a8d73e23192fa161a0b8344821f48446811b1e810fac65b584f49" + ], + [ + "p", + "77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7" + ] + ] + }, + { + "content": "We use 'e' with a 'mention' marker ", + "created_at": 1689942025, + "id": "05a786d700033851ba5bce7733e3259e9cfdde7c0cb0139406f5ab42744618b9", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "82707d97e8fbd4e5075bbfe86c377385113db6cb231160153ba9f21472466d7217cef48c0fc161a1d1aa83115dbfdea6abd568789808c3d2e5123d335130a716", + "tags": [ + [ + "e", + "acdd96b1297da7ac202c288e76bc7d85ebbacfb2339b1837cb35a0bfcc817a58", + "", + "reply" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ] + ] + }, + { + "content": "It's because our terms and conditions is hosted on GitHub, and F-froid doesn't like anything hosted in a non-free service. ", + "created_at": 1689941382, + "id": "664ef39828f333615e41daa08e975d69c4bbdf48eb94e4d78be94a2c8c53cba4", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7c767e3d95a36374ba2f79ad6eb3547309067e5f87d65dbd12cfe126f373eadb70ad20e3d5edd55f7eff165f0adb91503f8d8b5b52c501ae3cc580e508ddfe36", + "tags": [ + [ + "e", + "42416b010364982b8df9b9a696083b9df982b57fcf4dc907d3b978f42cdb36c3", + "", + "root" + ], + [ + "e", + "b648656bb2536f74e3ab7528e660d93c1400ca12ad6aa8fd06ba59e50643c905", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689941261, + "id": "0d71d842517f97ecd4b8df42a46e0dacae609e5db7d48540a25cad3808eaf346", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "34cb3ebc088c09bd4ddd9ae21a00efe862894bfe2140bfe99139b08aa8189a59456edf3dde9da08fb9870c2a366905cd9e96d41daa0d9e9353c332ea2be450dc", + "tags": [ + [ + "e", + "846479a923ab551c827422073e09b704249b5540e7a8728d4636bc27fb286647" + ], + [ + "p", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" + ] + ] + }, + { + "content": "It only does it on the Play version. F-droid doesn't allow the Google model we use to translate in device. ", + "created_at": 1689939108, + "id": "f8ec20358eb2d8c6f3335aa0a1da2ae40b0e3163fada3a543fd2c7ff2a1085b8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e622c5c274ab4ed8acd99d1ccc9918a47deb9e64aec0f4a5172740ae4e0118d2f3743ee178f77bac79c73f3f7b14b9cc07fa1a314e6d0a756b659ce09895482a", + "tags": [ + [ + "e", + "0b6153cc21d36e51edbd2c30c7c45dd2dde66c6bbd0b0b5d508a613f277b1ea6", + "", + "root" + ], + [ + "e", + "81d53f4fa555d6aa29175ff2235e34cba40a949eb3be8104176255451ebfadcc" + ], + [ + "e", + "01186521fa18cecbe9bebcf6d3b6194cef6dd36ae43a3b1306601e2cdf0debec", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "Not in the plan, right now. But since Kotlin runs on desktop as well, somebody out there could make a desktop app using our code base. Put a bounty. Let's see if we can attract more devs. ", + "created_at": 1689939061, + "id": "5646ba53503e6a10c538f5f9789b47b4cc55a6a0d51f9cd00fe026e8d0223b9c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "25a749d25096b1e8e1fc8ec0c078bfc24b7ce7c9b453449a97017f95636d797dbe001f7f9b1af2ca5ef770de5492739ecaceb3ec06396ab774ce36e4dd4d2615", + "tags": [ + [ + "e", + "42416b010364982b8df9b9a696083b9df982b57fcf4dc907d3b978f42cdb36c3", + "", + "root" + ], + [ + "e", + "f6ebe924faf115031927d851b4d09bc981370d38bba28895078f0f5908021afa", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689937268, + "id": "fe40d759d27e61ffe6be02d68c45c65bc9545b90247eefa3f668f4bb7dab6566", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "35002734dd66eb2c3088f053673c647e8ba7d6e4e5fc6bf73a6460d8b5a1a4691c828a0ff51ab4764bf4515b3597654b499bc8b1c54d65df472e94303d8d44d0", + "tags": [ + [ + "e", + "b43c9bb1520dbb4a30405c27b1dfe442232e53ace78168bbfb13258c6a7ae569" + ], + [ + "p", + "84eaf37660225a312d27dbf72acf588106664d444bd432b1c8004c18fa109d63" + ] + ] + }, + { + "content": "https://nostr.build/av/7b9b8019fecf3ae30a25f82ddd22c9fef7adb07cf444d87e078c93fc9ca74845.mp4", + "created_at": 1689907768, + "id": "e509d1900262f4ff82816e5f3250641cc29e7b07f4be966629b193718c59979f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e8f1a409c356a736019eb4a0d210fded04653bc1a1c869d245fd6a0b150e11c455c64bb838c868c488c9104e55cec2a0b187f8ca24786402f7e83a0d8d018257", + "tags": [ + [ + "e", + "50dafc0709a63aec5a1ccb97e979b4e6ab41faed89ef15590ed498b1bd654a7f", + "", + "reply" + ], + [ + "p", + "c4776f31e10298e4fe5b6a4b9d5ae240042f2ca17b5a37208f31166f2e88f05a" + ], + [ + "r", + "https://nostr.build/av/7b9b8019fecf3ae30a25f82ddd22c9fef7adb07cf444d87e078c93fc9ca74845.mp4" + ] + ] + }, + { + "content": "Imagine when you see the live streaming :) ", + "created_at": 1689907532, + "id": "019568d32c6507fd8677006e47afca2cc78bca9c3806158eb3e84838a23eddae", + "kind": 42, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "da67f7739fb06bc8faf7a8f5bc0b3672fa190b896ed4c8ba40f55ec1b0d7d2cf4f9a98652baea76dcca5409f5649ec2cb467852c3ee0ab3d015f0aad4708b91b", + "tags": [ + [ + "e", + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + "", + "root" + ], + [ + "e", + "4a5c12f52b19dd1f9c62a90e6a05d2dbfe4a0c04bba189ac2f7991732ed0a20e" + ], + [ + "p", + "f3ff8e7774e3b3a85c54041bfd73b60fa2b32ee1c5dc316a1d564234552116d9" + ] + ] + }, + { + "content": "XbR03T+g66nPBK8Qzo9y3YKZJVnusRAkq6WJDgZr6HaoTre+IMvp4t1XyXc2nft4rDy8hY9ll4dkdD7Rz1TWwykgaPTY+Y/SjmUDfiPzoG7gEFaFUUPTXQLrOuTfZUmXy85NwHIsHRC/O6P/YFpfVAzkaSZZ2HWWEn5xDCQ2wodYJNxR1o/so9RAyzRg5PUOvS/5JigbohCQea/yjedxjmFdA5YqNJEKi6QoOvUtEhA=?iv=OQVBz1rnHxytQ+E7rQv4MQ==", + "created_at": 1689901355, + "id": "0d778eeaf231b9c5cbf775446e520e265465ed4e1db864c0415e55eeef45d862", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ce58c8d40edad01244fadd38b5819f6fd11062e023f3244f21bc176abad1e42b9c286fd6f963869c1fc96c860b746fcebecd915992762c843c4ec38e5c7c5aba", + "tags": [ + [ + "p", + "7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf" + ] + ] + }, + { + "content": "Usually 1GB. But with the way many profile images go for 10-50MB, it can easily go up. ", + "created_at": 1689900854, + "id": "05a9d71cfe8ec6b931d980c2993b696cd7a4fd5043a3f5c35ea05b301a5411e3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c46a68c8d2918707061b283c20308c397570c086953d54442b9fac8179a97ef5e2cb6f9e84be0d4db25cff63f13f5ed3316edd20ae0847be93ea10d87aa5f1cf", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "49b67dd8f173d56b99758522087aca6ab2a757e9ef1a17aec988558a3c0f0ce5" + ], + [ + "e", + "ec2980452b7025ac26449110486794c76fee7d48444d5435e983c72385457654", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "Ouch, 2GB? I recommend buying a new phone :( ", + "created_at": 1689899880, + "id": "49b67dd8f173d56b99758522087aca6ab2a757e9ef1a17aec988558a3c0f0ce5", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b8c2b7a564b932f2e582e4d53b063877a48b7414ace4c72a6289df20740e4eb06997045ee37c575673fdf4bfe25fedb8478dc80728a5378df49a1277307b0a30", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "2e94de4d2a46d61aa549f9e83545f5dfe4bcc404d49bdaae9a42dd202b24dc16" + ], + [ + "e", + "9fbc8f192e8e6e3006aba7567cb29c6f60ad7c583b050a8765ea91af95faab37", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689898662, + "id": "8ddae69f32ccb558e49222bdbbe9ec4137a0fbf4ae9614990fe34860f8ba173c", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d7ef27a7a3ee6a1cdb1fcaf131f045e9fa98d8a7f18bd507e051bae262c5ab51643b4321fea5bcf733e22a48ee61f612792f5003c97b15f87b40a1bf32cf90a8", + "tags": [ + [ + "e", + "a3f71ef8b8fe3542df4c8a4bc88539a53db31c23f3a780543ea2c3ee599f9a3b" + ], + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "### #Amethyst v0.70.1: Quick Fix for F-Droid users\n\n- Restructures the spacing in the first and second rows of a post\n- Fixes Wake-Lock permission line that crashes on the F-droid version\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.1/amethyst-googleplay-universal-v0.70.1.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.1/amethyst-fdroid-universal-v0.70.1.apk)", + "created_at": 1689898519, + "id": "42416b010364982b8df9b9a696083b9df982b57fcf4dc907d3b978f42cdb36c3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a4f06bffed259dfb747c001c4e58ad9547e1ba5eda174a4d3c6f63903efc70742027d12443fb845b6a86e9de6a6d397b2cd2f520ab8274213c8f5b50c1c0293b", + "tags": [ + [ + "t", + "amethyst" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689898498, + "id": "effe7e685b36a6cbd3dadbc74ff6d59c44c103f784ebf3f9faeb58ad2a8afae8", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "af8d2f662d454ed7b221118af7a1324675dfbd5dd90a3582c4259b848917df01b1a6373e2c5233035508e578bfb3b8f14cae18306c0073d2f7ffbbd93b4c5847", + "tags": [ + [ + "e", + "99d97b784f54e315731e04e2fd3ff9166b2ea2093eb494844b10ad37b86c36ee" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ] + ] + }, + { + "content": "Well, I only make this particular app for Android. People can of course install others. I'd just would like to avoid the extra work if Android solves it for us :) ", + "created_at": 1689898471, + "id": "06d414ca9e36bdfa02eac2f27b62298dad203dbf784e69d5c339e138ceeaa476", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "812cdda0e068cd30b21b4f700b1e738dc55e85bdaa1f03073479958d345bce6ed5a1e6c735cd9eed0ee773ccb26f1306b06708b5b70054f40d584f16db0e23b8", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "bfe8b13fa0a8a63ecfb3b7ad3bc26216efba5ae6aae7b95ec5749db8124d33e9", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ] + ] + }, + { + "content": "PlGM8/MtS+vQnbR5HHbc9YXs+DaKNeKeP/JQwTHL7Gw=?iv=+CyNjnad2EJ/djDiMYeJKQ==", + "created_at": 1689898356, + "id": "901df0b16ee3ff8e864d278876093827f15dd9a39dff49b059775dbb319831c9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "126f482832059ead0ea012dc7a5508517d4e08e7000e8681d2ba637993d49dc3b36735cd1a7b5d673c04245e8e02f2a27844a2b28fd2da6c02418fb5bdb11536", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "See if this version fixes it: https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.1 ", + "created_at": 1689898240, + "id": "2ea9c229e5cf6d8e6cc8c2c018ef08d1366ab70bd9d7dbb499e2e388666fdbeb", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "52c8716c3d7afb35c58c1b7412db2cd44a08e57366349e8aae1c6015c5b078d3fd1b0c8f4ffb718cc81c0c462cecdf97f816c949621587cd635caefe56457cf1", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "107ea3c6074b2be4096c5d0233553db7ac326ede4678ba551498c13c80f0794e", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ] + ] + }, + { + "content": "They should store everything on Nostr. We have better infrastructure. 😃 ", + "created_at": 1689898211, + "id": "0b4cea57de5eadcf57efc6d1b44511c944085086410f3d400f13554d5c3eb132", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "732cf27c63e6fbcc4e7af2d7fbe2525af2aafe35f6effa270231f38393b13ae161cd9f58acf42ab3e7bbaebe15cab90b8fa4946484480d434e50d8dd36f6169d", + "tags": [ + [ + "e", + "3c4a16878ca8285dc65803a239b9ef7c6b01badeaed9b41b6e5af88a580c76a6", + "", + "reply" + ], + [ + "p", + "ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49" + ] + ] + }, + { + "content": "g5U/iOElAmZg1czy4FWXjbRuy3QG8LsGmW7QbwEYHjUEslUXqGNvG1P67+kt2IB6UnXUfUy0CCVTTHZ3UhXqJsfYfYNsME3JqRV3WOqDU/7Hb4zShj5YgbQHkai8tIhe?iv=aaWZ7SvNudW3QrZR+w7Ifg==", + "created_at": 1689898075, + "id": "1b0562bceb74fe5a051c0b23a1c76682268cf9857bafe3c7df15ad386623f797", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fa4fb247f84456b731e82fe511d806909df64c4c0a1f407f78dd97c5c65152b970ca4c204adaa1304dd2985ef7bb59da8b7c684d1528a214fc973783cb99fd1c", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "Just go back to the previous version. \n\nI am not sure what's crashing for you, but there is a bug fix coming up in a few minutes as well. ", + "created_at": 1689898027, + "id": "48cb09f199294e25bc884fad3253c92dcbbe5b3b03ade1e9c4f079c97d0803a1", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3b09c5b8d314d35b6302203557bc7e01fdd4c94e2264e9cd895a16a31b14a0f7c106444ad84b137c3f34079b19aeeb05dc281b9bb4128a95c31c4877feee2c52", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "10443395bebea840ff883d98379f814eb694486a0f616efa8e53e07ef3b1dc92", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "8e567e90f3214bb221a726c53c0b901dd23bdc4281e4dfe425014e33f1dd217b" + ] + ] + }, + { + "content": "rAjdaIbE8aQNV4wl26DYoZtpy41jbPK60zFJ3eysPzo=?iv=Ef4GQxwtqFbfJ7iA10Halg==", + "created_at": 1689897433, + "id": "87702fa85d8d3b792be82275ba1c0763467b9c51c1b01970c238dbe601dc8e3e", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f8f4d672666d2856c6ab2a6b323610da9e13b1839413f4258ba6d479dd8d6266ba25df019eef35ac84e5160f5b66edc828deb6f26e9205a01f02b16d82780f25", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "WTFQjqBoYqgRFXnqnGwfd0pNaeoL3pkbZxmCjuP6DSU=?iv=HrCg46zasY2X8fcVsqsMgQ==", + "created_at": 1689896526, + "id": "cadccbd35fa4e3719612a5441ae5165cc6d3ee083ed940184bf392e5fe3142d9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "72004a76b723ef7bd5fbb3093279a77546764840665bd5ac92849f284b5a7f12f489346ce251385c9af39da3b8e6bfdd15672b2dd940aaf33ae473b5a58d897a", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "QLzMfgQsc7ODjSrJrLxP6DUl+yCHFlLGmMINyYmp6xoi42+qLO7Ucssh4GqWCyY9?iv=niAXV5K/KMasL+eNCr2/DQ==", + "created_at": 1689896516, + "id": "fcdf61a01298305920b318c693f71b5f9ac322225a065fd4fd70d97bd69cdcce", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4606e2fd335552dffbf33bd6f619f2b8253f07d25ed38c2a26b3fb490800bc23c551d435da5839f1d7541c8bd4867fc5c6228cbe4355bda95f83e6c485d787de", + "tags": [ + [ + "p", + "fa47eab76aedb2895442da765a3a11d8a5a9a679884ebef8b977e0f36c912f0c" + ] + ] + }, + { + "content": "Which android phone do you use again? Is it a memory issue ? If so it gets slow before crashing. ", + "created_at": 1689895792, + "id": "2e94de4d2a46d61aa549f9e83545f5dfe4bcc404d49bdaae9a42dd202b24dc16", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8ecc5eb799f021c715be69345b8209211203e65318248dbf071cd2ed24b1479a217354e192ac19fc50ac9ba20c9431d10f11a140377b4521d196fd240ecbcdbb", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "a0db5f1affbe065a92b0909ac480d17ea517d9ae03cc28f268468e7c82e7fe41", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ccc0c31d1067eac417d2cf562e09df0bfb8f6724addfcb01be9600a75116446a" + ] + ] + }, + { + "content": "Isn't that automated for Android APKs? The phone doesn't install the app if it doesn't match the previous version's signing keys. ", + "created_at": 1689895740, + "id": "83a06ca57bfdab4f635221e67f05a5827ddd0e91f75756b05692138b5c055e12", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a74648c78086553bf3572e86c2bea55421fa510f1a7804da1952b4d095fd2c1280890e54643bf78b5402c3565e8ffaecfd5cfad509498f208a7550471c264dcf", + "tags": [ + [ + "e", + "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "", + "root" + ], + [ + "e", + "3eb596232de49ba67b1f47873d388cb389f93eb6b3a0449889588b3099402ff0", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b45aca09dce5a9d8af39f5b116f306ba5b9cf175d54b99ef7fe44b14e176dfee" + ] + ] + }, + { + "content": "### #Amethyst v0.70.0: Background Video Playback (alpha)\n\nNow you can \"pin\" streams and videos to the background and keep using the app/the phone while watching/listening to it. \n\nhttps://cdn.nostr.build/i/04b00315f81c574afa45ae9c5632952e9bd7bd472bebb45b6e83aa97a692546b.jpg\n\nKeep an eye out for bugs, this is a massive restructuring of how the video playback system used to work\n\n- Moves Video/Audio player to a foreground service.\n- Migrates Feed, Stories, and Live Stream screens to use that service\n- Blocks screen from going to sleep if a video is playing.\n- Blocks WIFI from going to sleep if an online video is playing.\n- Allows the app to pause while listening to media and continue playing\n- Manages cache for up to 30 videos in parallel for each of the 3 categories: local, streaming, progressive content\n- Activates the use of popups with artwork that points to the screen with the video\n- Creates a button to allow any video to play while browsing the app/phone\n- Moves app to SingleTop mode.\n- Keeps viewed position cached for up to 100 videos.\n- Restructures the starting screen from App Navigation\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-googleplay-universal-v0.70.0.apk)\n- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-fdroid-universal-v0.70.0.apk)", + "created_at": 1689894483, + "id": "15280e46bcba956ac0be0ebb5de1c19d25beae8db5223951e16ecdd24b524519", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d1a1bcad4fcae1592e27ddc086d8e9f656e790536038a82a7560705348c964093c89c6058e4180a67f423a34e91157dfd48168a3db3c05e5d33aa261d5ebdf29", + "tags": [ + [ + "t", + "Amethyst" + ], + [ + "t", + "amethyst" + ], + [ + "r", + "https://cdn.nostr.build/i/04b00315f81c574afa45ae9c5632952e9bd7bd472bebb45b6e83aa97a692546b.jpg" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-googleplay-universal-v0.70.0.apk)" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/releases/download/v0.70.0/amethyst-fdroid-universal-v0.70.0.apk)" + ] + ] + }, + { + "content": "kyZ2J/PX5HjuuY6Fnw4Wwbb7aigSPVVL5nEieJ2rxeKOtIxKkdk/rMuo03JVuKOFgV3+TmyUQS8KEEI0TCv1zu8mBvrQ66gk8SFcVFRlDHINWO4gqMV3hQub1+kbgO/YZv/ez4aM90+TAH+gLqb1Is4gAOOJ+aLCpeT5mAkmpwx4WPA99Q1jgqUix6Hhq9vAZWfbB+YnoCkkePlMYdM6iqhdMAPrPwTj6sauatmQCkBFTiPD5kCro750tKS63cvkx3AyYGDJWxkC8yURnqp8j9MPzXsGoNRTdVyimoJXFZyY/v/iRzeWQwFL2sHa1PIqKrqwEE/9mZY8YAqtrMx+5WB8tco0Aj5QnCJpocJ5XEB9M23Hl4/fMDrRGUrf0KCSmiSd5oljW5ruasb5VEEMgwO6RudG28Qafk/Z2cZ2s+nx9fjkOHkOaorOhCotPq9d2bd6VpqXc27BnKU/LyxRzFqXI2bVl7Gzl15nTobELgfjSYFIeUWYhbzr6n9MhCe2dqgHIt4FR5NUnHbLMHylo5zVBBQTkOQ5TX4BiE/55BbDqmudqNuQN1ZaFmKvRIaoIutFhopNwYRVe+Lj8NGcZ6jvR1sw+AcngJBdYYsAPkRisMELbeUkVNkmUcWbQIRNPsuTSKM8M/HSDAA9SBH2V5Rzd+8G9SrNoCGJoueABfJqjz0JHg9fc5X5qfa9/Ly5S0bgHhWU2wueMX85+UiD5G8yJF26Gd/wEr4YQrMnXT9xmTJQ/YusKmygOxcZ4yuC15stNV6hPnx1Dwr4KhISbw5+i76f/GFgbY7iX6nfKLsl+A6UxNGHkVUt1QJWRP/BxcXjVY/1ynQMmUlyIDu8E/ZbHCGvZuOnYow7+BDfTmKzgwKRoO8HcbijxsPG/nKF4QUIP6177l+wL/46E0EZGxroyT+w7a4QcD9lhqD8Bm4+6qFvBt1Ihl3RVlVzN6WzaKHVzs/WUhRYXNm4qzyYwjtCPR8jJ7MnfgFqFqDYye8Ac7CdIZvU/j2B3Xqk0ROLpcigFw/53sHJzfPz64QeE+Iq0Ikm52Vd9NqBCzQWJpMpi2mDPEL39P6AqE17sEFrEiEz5Ul6sl9gI0UywPSgf7grY3Thl/LmmRVl7LtDqWJxQ6/0BI05pUtFo9PF6ZfbA2gqt21gB6kRx3FXpxiHN+1KB+eBkyTUa/GwyAFwO+1noID2c17HlBYwrxikxCH8WD0qCjXmy2rzAUopDTbLmtrREfDTDSqm3Wy/3bDLYcB6rKcVVZUGQWp7heAbhk1i2LLouBQ4aCBE+Q8NoyFct7WWNZ788s2gBwA9xYx6haCnWu8x/72Fq8EayH8pakEWqNsAb9Ig7oxUZUbUFVlURp/4hFA6ypVEsVxPSikJ/iS9g1f9hO3vQ451jKWmVboq9ViRNSvIwI4xzB8/iYi1/R1NVanEZLovOP591TcDs+rCRL6xBpeZPM8NFWiqMo0K2Hr5/SLpYR2SFTcVG5x5OGCvAQO1CsrK6YxXEkRI7uy1oo7bYZm/LgzPzGcbESRhZrA2VmwtdE6LC/vTt7PlW8zxPAzja8ZeLkuKSZPlo9ahYHNtuHAVTHh5JojspOMJlGIjOrnZaSPZF4kjtOfhevF8b15YuE/EDLnv2EAyGR7U0zGm1ZSUgecOW6D9dBX1DrMLXbnSydaDxq76bmMIXzynqlQNkTHLPrj+leiB95gNaAc8NnMJoLeK7E9FiFxD?iv=0wUJgJRH2qM6M22nQWTtzg==", + "created_at": 1689894398, + "id": "ad2118124d93a34e78de1868a03b5db77269de9bd847008aa56d234ac435d911", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a32e5d31c055b090fe60ba719c8691c8500eaa9f0239e9b3ca823c8af7ef0251c470facd766d0915bf495d8414037a16692da9c487bc260cdb43f272a4aa2c22", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "gXTNhA9dmULS6RWEeUnGpJeK8jO62z9IXGTKEeZkFyLPyzR5cwhIyVDCPZra7fDs?iv=S/+MHIRTOC3YvN5SRg544A==", + "created_at": 1689894312, + "id": "9fddd6ed3006a4b02a4f61106f93422a875d9104e18ba17d44b41eb0d29118ba", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3332078b36bcff14ed9b4079c4615bf2a73d07d5982dbc0c3c225ad768d400f1d152de5912119a122c7366465a193bfe47817c2a03c575664074fb47dc5a56de", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "SVapbPIcn2mKwCD8+0X/ssrakEARyfHdunFpBqKHJXCwahK9G4A5+wT8Tn6HcmK+qALX/eCIEeotD9jQfT/yFw==?iv=V3qBEP8xd7627UWztQb/1Q==", + "created_at": 1689894286, + "id": "ad0a2b77c0575b49e3f05412a09abbd7575e76547e9565fde0315a89c16a5c26", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8fa4798a22452bffe5c56eeb0fabfc3cbb9b9ebe1ddf861fae41321befa3d67354fc588f3e664c004b5d5450df27dcb6687dc5261bcb24c986fae40d81114f0c", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "We could use another update from the user to identify that the stream is still live so that zap.stream doesn't need to keep updating the event to sustain its status. ", + "created_at": 1689893549, + "id": "6ce9b93a4fb5967d2f70225d0d8c7777c1f86d3fefa135229f8efcae338d783f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6126b61b95f23e5364f13a7f75899e582641398ad66aa77277182c839243f503e03fb36f5445366336ec6532dd03a3aa90951aa41ad2a153ffb114be9438d096", + "tags": [ + [ + "e", + "173eb29e7402ef1750db662d06b88a187ea9c0b31d4ceff1df5d048fe79be3fa", + "", + "root" + ], + [ + "e", + "4bee2e762c7d296bd66dd5b4a0e84dc7e0a98355ad5f18277ac65c06da9ab25d", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "This could help in the updates to the LiveStream as well. ", + "created_at": 1689893064, + "id": "bf3bb95525800c504cb74c0c60a5e532fd1bc9d774613af6b6cf8125900a33b7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dcc36fde64e0b483114cec6ddf5f72b9f53a7deb9bf06475a4f36aed81b2d41d0b2982e77dee6d1a015c219db7bbe8672c5c43abd49646956d7a1ac669a6ee04", + "tags": [ + [ + "e", + "173eb29e7402ef1750db662d06b88a187ea9c0b31d4ceff1df5d048fe79be3fa", + "", + "root" + ], + [ + "e", + "fb6f8c9055d34b3fd2eca32f96a5193f9a491a623a57819b8c904e0504477190", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78" + ] + ] + }, + { + "content": "To the best of my understanding, that is only true if you use a different IP for each contact of yours. Otherwise, the server can easily bundle your queues and map out who you are. They call re-using IP an \"opt-it\" security issue. Even though most people are reusing the same IP for everything. ", + "created_at": 1689891763, + "id": "48735c6e1caa54c82015c1a11d86c9c3ff8e7e6102992bc32b78f6c5c4294f43", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b5e3b53de8cb327f7da73d2c9a09bf409d484e3dbca8ec7c844baf9b11ae9e3cef535b82d6c716260eff36bb672c1ab2a5ec85f445a539dba4cc919af0e8e0e1", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "0bf10092a54fee81dbd3ba2100eb80766276e93a30bf752961ae890be2993617", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "9110fe860a5e40a41e03d52d306a7b337ecf32d1090812ceaf423ec9b94954c8" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ], + [ + "p", + "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171" + ] + ] + }, + { + "content": "+", + "created_at": 1689875139, + "id": "7ae6e200034e48f561c22648e41f677cfb71a7713bcba6bec683c74727ef9619", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7d73b8fe475b99b11e8041728e32f55b505fa57f141fe9a301d7112336fc2cc09829734d27100168ce3a20894b8321965c3479f544346ec4e2828ad313e36178", + "tags": [ + [ + "e", + "39d9dae2bbd253e85c78c55ab89588a245a9c40ad03373fc355153d35119c80a" + ], + [ + "p", + "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce" + ] + ] + }, + { + "content": "Same issue. The metadata is always somewhere. On Signal, the servers at the company know the destination user and the time of the message. They claim to not be saving it, but who knows... For SimpleX, it's similar, but on their relays instead. ", + "created_at": 1689874092, + "id": "06756e30fb3a5a5adcf0ce8953e428dedb6511b03cd5d219cb3f394e58dc1657", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6a26f8fd80c2ffef7ab5c0e505e2223ab57f4b4b049e7a5135afcf03c2fae44b71eca7a457f406c4fdbf3cf74c52a832eb354d303fa236c431ee0638680fc219", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "fc7fd52ed1a79e8f7a1dd6a84e38a04a73a38b92a7a43e4c76df9708adf70946", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "Clients generally filter by date, so you might not see some messages if you do this today. ", + "created_at": 1689873381, + "id": "cf1b4e3048fd829ba241bc84b850937cf8ad256208309fff0ffb92c6ad20cc00", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8463639628afe7493e61931e6613da239d489d2c9c3212e0c58ac622a166c6664cb13cb33b80531f4037da99d2bf53548ad4553104ce55e161aa5cfe11e53584", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "d8d90496d54ebf856ecb5624e4b9e07950dd5afb1c57ff2b1a25fddbef98185c" + ], + [ + "e", + "adc0937780518b6a35c3ac44e36b2c48d0e6643d3609847652c8a4c245bca2c3" + ], + [ + "e", + "c98d7ec7858ff362a45b60c8a6dcd03c9b66f30ad65e89755b66d0c38320c858", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "🤔", + "created_at": 1689873007, + "id": "0900a6bf9d2ef4b4765074b26e277bd8ae573e8dd697fd2a2a2626f2e6deffc3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d0a69e056d477f73ad70ad00ffab45cf33d685bae46a674552871eaf47f131ce2e063fe2064a4710f0393869a96712d0ae85e626b895e1f979e4478bdef8e56a", + "tags": [ + [ + "e", + "1a3cf67ecc40a0861fd39830417671a62c98ad35de0ee0ad9a78ff2b0170e1f7" + ], + [ + "p", + "21b419102da8fc0ba90484aec934bf55b7abcf75eedb39124e8d75e491f41a5e" + ] + ] + }, + { + "content": "🤔", + "created_at": 1689873006, + "id": "330f32a6c42c2f555cc1b2def25e521ea35823a81bb9fe36d08cab65bc5bfaba", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c1dd416e6ba387ce6b74a5e8e7040e2f3e01b0a3ccf95660ffa8383da9f0e7cfbed1e9294416c3f386a9cd071a30b9767801bb2cd47dc3026e6d3116e7c8f6d5", + "tags": [ + [ + "e", + "7f4c1bd91549cef6c1e960bb3beda056f559097cee1fc957c131b4029595d6f7" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ] + ] + }, + { + "content": "Nah, governments can ask operators for their data", + "created_at": 1689872829, + "id": "deac3db2b74976056d8f5a4543aaffd9ddabe2209323861605bbbacbb1a2309d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f827a824cc259fdd1b222d29ca59c63afc5d5926039457cad968d9461787f4d6757dccfebf9c60cbf2d47c9cdd3388d2aca713326530bccc3bd8c33066e4db7d", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "60de51c3eedf47496b23b5bb16c9b0513d776d9eab4326255adfc9df791f5eb1", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "2ef0fccfd5a55e36bc8be3a525c1ce97f20eabaa94e69d82febfd641d9480c35" + ] + ] + }, + { + "content": "Governments establishing a stronger evidence based on a time collusion of separate evidences. ", + "created_at": 1689872769, + "id": "adc0937780518b6a35c3ac44e36b2c48d0e6643d3609847652c8a4c245bca2c3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b46c04c7a396461959a0da797d2efb9ce1cf59fd00bb48bcb3f5b8985b7eae1168636119fdc982914acf5f5419b852d78a1a78f3453e6b1b0c561cb5bb57a441", + "tags": [ + [ + "e", + "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "", + "root" + ], + [ + "e", + "d8d90496d54ebf856ecb5624e4b9e07950dd5afb1c57ff2b1a25fddbef98185c", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "04ea59bf576b9c41ad8d2137c538d4f499717bb3df14f5a20d9489dcc457774d" + ] + ] + }, + { + "content": "How do we hide the date/time of a DM message? ", + "created_at": 1689872583, + "id": "dba7825c157548cb16be88cb771db063862ce2adb1969193cadcb29a164c536a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "69870943756995ebd822c0d56cd2ffe451fd8713bcddf7e8828dbe3f833823005f3e4a34170eaf4020fef416c66af03ddf00755eda42452c0b53c8079771a403", + "tags": [] + }, + { + "content": "O2u7Hl+o63AeQNONtyIXjDI/2Xi0WckHVWZ8aGJnO8hrT1XILWJ49X11Tz8eM25sW5vxceKR2gnYUh+paux3ZMkfhTYdcm63f9AYsyQBS8k=?iv=/iMP4rhqmyQXGU8+7llTuQ==", + "created_at": 1689872451, + "id": "175fc151ab6ba3d8c567e8bb372827ec94b875290068d4601251401042027edf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4a3e683e8a5073f191de3d49cbd5dfd4688382ceff1050addb5f5f6495bc7780d9d225ce346c23551141cb36442fedc2848f09db68d27f25fe2150200d1614ff", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "e2dv5uyGynDKSN73wEsul1qw9v6Lns/jEYGcZud+u9Yh5gO6cuHHjVcwlZvIysfiEEL/UkcM1LSkpsrzrk55z5Ruy4f3CJsacb4DTFX7eI0=?iv=rCyLaRVvDh5PyMR4rZrIUQ==", + "created_at": 1689872401, + "id": "b3fece9b46c5cd3a5243d2bb4d23db4ab8648ad7b0fa6348a519c3da895c3ede", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1ebeb7474c7a58a87d9738119fd692a7aa19ab65e231f43deb46c2d51814fa217194224871a7a8570e93eaeade1b7517e9cd13c54166d57c18aaf3a2fd0ce778", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "It's sad, but true. However, we have a bunch of new non-twitter apps to balance it off. \n\nnostr:nevent1qqspadh2506xtrpjtld0mu0e5ly29cdseu2u7vrx5thsxp88t4uy6dqppemhxue69uhkummn9ekx7mp0qgsph3c2q9yt8uckmgelu0yf7glruudvfluesqn7cuftjpwdynm2gygrqsqqqqqpz67xy5", + "created_at": 1689869163, + "id": "e35dd1efdb8d688b2dd82d8e87c10891c2c15e0bfa85d75302366397abffff6f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "16fa751c379b5107f0a8f7c975daacc95806f7d50a0b9f1fc58fbb8008c32278581f0937e3aae646e01d6bdb34a4f9eed8b783fe756378c8c20463e875b9dbc0", + "tags": [ + [ + "e", + "1eb6eaa3f4658c325fdafdf1f9a7c8a2e1b0cf15cf3066a2ef0304e75d784d34", + "", + "mention" + ], + [ + "p", + "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411", + "", + "mention" + ] + ] + }, + { + "content": "+", + "created_at": 1689868629, + "id": "7f13a40f500a511d643ee6115877753a788f00c2b00d342a5c3cfbc99190b16b", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "22d6b5deedb017d291ae94d1e638a479397ae76c8eca229b193555bc436afc30e8f795a27224417b68a2b0a41e96414a14d203193facdc9ea2d0760ea7a4bf5c", + "tags": [ + [ + "e", + "9c27822da347c0083c2908d51f2429917e079e0fd1d623e334cc2946395dd123" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "I can see that you like good UX. I just wished you just asked for... say... to increase the space between the text and the image directly... instead of this snarky post: \n\nnostr:nevent1qqspxnxmq8gvh2zf64ktvqt6ahhme7qy86cpvzqgnmszj5dc2stsrpgprdmhxue69uhhyetvv9ujummjv9hxwetsd9kxctnyv4mz7qg3waehxw309ahx7um5wgh8w6twv5hszxnhwden5te0dehhxarj9ecxcetzvd5xz6tw9ehhyee0mrzlhx\n\nAnd since you like good UX, you should know that this is not how you ask for improvements from anyone, anywhere. You ask for empathy, but you didn't offer any of it to those building the product. \n\nThat post could have simply been: \"Hey Vitor, can we increase the distance here between the text and the image?\". That would have created a more productive conversation. But no, your wording wanted to shame me and the \"product\". Basically a \"look how bad this is!\". Maybe I deserve it, maybe I don't. But that language goes against what you are preaching all along. \n\nOn a last note, Amethyst is here it push for everything, all at once, and move forward as fast as we can. That comes at some UX expense, as you realized, and that's ok. With more help, we can probably offer more UX improvements, but at this time, the majority of the development is still on new features. We are still on version 0.x for a reason. We are not even half the way of an actual product yet. ", + "created_at": 1689868224, + "id": "209fbae56844f5d16e64fdb4026ffd966864de649062c30146f8e588bb5eb452", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fc3e11ca74034193f5c7044e80f6c5a4b035b7daa82147584c91fb8b931638daa43c5e7d576c4ca7bb295d4ca955a7e7f63658dce6e9ebe489b3a0473efaf3d8", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "7aeb2f3ccca028aa33a3ffc287e6538e492d61c0ccae70627275730fcc354ac6", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Dude... I am here listening to you. I am not bothered at all. Why are you so angry at me all the time? ", + "created_at": 1689865738, + "id": "7edef713c3d24ea0271368c68e844ec1360c026f21c045c7b517a872de094259", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7ef93ac8f1bb45c1b8b11a8c7f168c82c7deeb3d3b31221c60fd0ec21a0f7a11ed4443f194bed7cfabdaa90e912d6cf25bb7e2ca85dfd813e9d74611ac469b36", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "cebe888ef52d02e679c32b0259e441095576bf9ca5a4508f69deb2a924c9f926", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Yep, that's the thing I was trying to avoid. I don't like disregarding what authors do, even in these small details. If people add a line, there should be a line there. But I will do some research to see how to improve it. ", + "created_at": 1689865669, + "id": "4b771bbff33a16eb80f6568a5473c1415d8db077884b8135b105c661c64c0af7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9517d5805d660b34e2574d7d95b3bcb25537dca821df8188d7367ce2b2189aeaf1eb53da84010e0d248054a3f9c8d6e616f0ef5eaa0e50d306eaeaef5bbd7474", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "9a8ddc77b81891173936d4d3c44501ec5873c1ac6a246e34f89425e4bc4d33bc", + "wss://relay.nostrati.com/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "I was just asking because you seem to care about this detail more than everyone else. But if you are offended by it, I am sorry. I won't ask you anything anymore. ", + "created_at": 1689865536, + "id": "72af445176b583364787978c772c929a550202cfb588da2e2a97bdef3601c93e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ede069883773114b2303a9981db6d6f72125aa423ff1a58acf0aa01af0ba498240bf60cd2a6a8350f0a58c972754ffde1a267fdcaa967fb7200d6a5605249193", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "f49066620597bf34152ec79fdd44f4647016f9f519610bd2b470127915593a13", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "If there is a new line before the url, do they show an empty space, then? Or do they just disregard how the author wrote? ", + "created_at": 1689864705, + "id": "c469379a1f15f7bbcc2a5da7a8b5eb3cbaf1a90c9c1e28c2b424ba88114fa496", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "914cb11c0f23568631c72b41ebe14b5d12f665a7785e5cc28c2e77b8d959c1e62d9af1b4d5b056783f7534f605d4e462750ea34c9cdc44a938764fe1b35eb1a0", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "a4947c5d9a71d2d944e0cd1edcafaf25375ef1d4eefdc495d8ef58c38cfe6d48", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "+", + "created_at": 1689862624, + "id": "79d66d3f15bea48b6d0ac94f1a940ea7a0ec936527f56b7296df12f127560999", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "5e1749add3f9f02d3044c2d2919313f8e616a7ff4bbd7dd0735ce475425dcf1d65f388f4705e4a18224ffe86e69a194037ebe21f749a515d18e537e75eab2080", + "tags": [ + [ + "e", + "7c3b8478c7fae8943356cbc72fffb91d0972402226da9d84a3f63d3a77bb44c9" + ], + [ + "p", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" + ] + ] + }, + { + "content": "+", + "created_at": 1689862150, + "id": "dd53ef21c2183dd451d8282291c54ffe30aa4a737b2111621f1a02beef772af4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fec7169c25bd4c995794b079f1acbf4212ce27555d4e20a57b5ed10be85e0a53c950db078c9c4460a2590baec3f0c88762e212411799b76da6ce529e886568ed", + "tags": [ + [ + "e", + "c0012d024315e94af4a3b78d88b5764b3db502689cc40e59f82051e57d69cffa" + ], + [ + "p", + "50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e" + ] + ] + }, + { + "content": "Do you count just anyone in any list you follow or do you want to break it down per list? ", + "created_at": 1689862073, + "id": "5836b1b6d60d819f7cdb9db7b723734ed46a43ee3905c712c379fe8430bea635", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a5e34cb72dec3b80098a9d092c122628e192020c9d96437c63729f9c2fa892d5cb73b43bc08f7b18ea9048db5938abf0e3cf5688d615dfc879f956154cdb2467", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "3a16231d3ae0dbd75a99c7d6038acef7ea2c510b123c566436965f813a638bf5", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "+", + "created_at": 1689861399, + "id": "72c71b828d4ba31fd341a35bc2ca90e24dae437cfe8d1b4aea48354154222d09", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1d78e9b3459e05b3fe269ec8ef4351bf86d54783d14585bbb2c0a370b3e9e47f81998c0b47168e16f568208ab7e55c4db33e1375b5f1b840105926a0efa5cb0f", + "tags": [ + [ + "e", + "0c1560b408c5fd5fba808d72506faf0de36b684beb39ae130b625ff48ac78e76" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "+", + "created_at": 1689861396, + "id": "43a89e40238a6eb9cbf43bd5df47879e6952b4bb7f4cadf93ce57de2a16087f3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "07801393346d8264c7833d4926c6daa456630be169f61b504e73c86e5976cf1dcc283bf9334238457523ab0f6feddd9ca4f9d4c005e077851654c8070b10b27d", + "tags": [ + [ + "e", + "f4bc628a982288fae1e8e49b4858a64943e6cf9bbf25cf04faba489ec26355aa" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "Ohhh... Community tabs for different kinds. Very smart. 🚀\n\nnostr:nevent1qqs2auwg7glxpsqg5nvwqyuvpnzx6vgjvw40v2kyg8ptrr5dj6rg6zcprdmhxue69uhhyetvv9ujumn0wd68y6trdpjhxtn0wfnj7q3qalpha9l6f7kk08jxfdaxrpqqnd7vwcz6e6cvtattgexjhxr2vrcqxpqqqqqqz4hge7g", + "created_at": 1689861199, + "id": "998372074cee04fc8b89bb385dd6eb0ceba8cf5012446222ebef7fcc33662f04", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "39ac7eda70f0fe0711db4bc00bc2bc1cec8e7a45fd27daff370660fcc074a75b1057956a2186a4ce5dd8617048b79e0770e09b80a57fa187e3025a169ac3deae", + "tags": [ + [ + "e", + "aef1c8f23e60c008a4d8e0138c0cc46d311263aaf62ac441c2b18e8d96868d0b", + "", + "mention" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0", + "", + "mention" + ] + ] + }, + { + "content": "I think the issue is on how to link the list choice and the relay choice. Does each list have their relays? Or maybe each list have relay list options? Or maybe the relay list is global and works for all lists? \nMaking that make sense in the UI is a challenge. ", + "created_at": 1689860877, + "id": "81911e85a3c7de2db65564853d4914a244ead918c2a9d2a17ab9a4f707bc63ec", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9a2caf4c047ef61a84e5c1d54e5297707eeac4b6e839cd696336e3d55101fb77924689fbbe167e9d60460f48cb755e27805c0b478b6d98a96fd05e2264e9554b", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "4f26cec8a0e10ebd1be60ab8debaefd2de1a3f4e2f6c05910cbaa0d825c9439b", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ] + ] + }, + { + "content": "oJ8doMpCQ6ZfFxbyPiaH1Omq+QlT00sigyEEdmF0CqE93YpfSkTaawk69Y4G3RKet0JBbRZgwPlhtdhU2KBiRpQ8Ggf/W3cHayb5NeeaYB466R4GPeGfeAP2yV18ASnoUURqKH9j7kCazec6s38BKofrrlr/O2uARqTAZz+aff4MqMRqkjBwJXkUqwGQNgjZ7KXcSSi4R8g6eUfChTZaBEryxQh00zK+skAGhZFbvilgCSZ+PjDAohC/4Nz9kxK9jlRJZ48cjbZJlhSpcNv8Z4q5x7l4CWjZhomBjFgrbyXOXsdMUXb2Zy0ZaBkd27uk/O4SlWdT7oui+LC+sYd8CkfFln3Ho5kgFp7nnzi8emFowPKVVv3sU0J2Sc9Rw1nNnZT0ZKeOhqZq5MaRBPX2gDMhJf61n/JMImAKN7ZITgA1QJaw1mDplV+CdZ8kXHfn2gtOz+u+rGa9mJR/fsfgz30klfcixBUlKrQu43jorkU=?iv=wlPb/Dk80+TDdCjEVj1zxA==", + "created_at": 1689860762, + "id": "3082d8546d083e4c02e513e31fc7e8fa86d86d760958619a62fa9328df0592cf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26f8d38baa4b62826b067cea227fc2c169c1055f632becc9c6dc2dc26e4807c296c9d97a41f83a75d63297dc67a2918d27c0f2d42e5a815075603ddfc9a7524b", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "NmGECBQokRbj25yC1GLM+0EQp4uDQnbCUMAGr6BkfNGTPyGRV9Wp+nSMxU+5QWa6tfPPuT53FhSairq9lGgFxA==?iv=LWNGgfl2ZxKJaOulvEt+nw==", + "created_at": 1689860574, + "id": "786ecdd73e40b5a8df20fcd26a792b49fef7ee599002afce4c1286984316db13", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "27ff060daa027c8945b0908c25ef216e5442ccf92fab897e604a3dadcc7348c6a9b7e027c26e6777f5fd17f9b73cf1f1b5220c8bf3036c00c8215e772870138c", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "EmytAFZloV1YnquNnF4dS/SMoenFynEDbYcWjQF95sQ=?iv=uADsN7t5Gac2HA+6X87Ung==", + "created_at": 1689860432, + "id": "23e2d47970dd18fc49cf39e0e1db5ee94d03f7370809cd4de9748fc77bff4a0b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "282e43b702199690d8d62543a09d3d469ba8544350b022c24cd2bc6838a6169a471c7a1839a58bb6b6592e0bcf9687561ca91d118d90d01d729cfd383407e611", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "zpV5c64Gr25S63G3P21ulXKzd2pItJXtq8sE8P72wtY=?iv=Zq+BthTWRbtlYb8mKvdmhg==", + "created_at": 1689860314, + "id": "3633e70aaccfc32800d2d435aeed6f447c55e646a3e94f2251e5deb2fff926a1", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c358edc8614575bf56b207452274de2800befa7d8905f306c66bbf4f8e44f5b1b2674fe5bfe1153ad3298a0f9281ec1135b2c1f57a05b006003c085eda755a24", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "For posting is ready to go: https://github.com/vitorpamplona/amethyst/pull/511\n\nFor viewing, I am not really sure where to put it yet in the UI. ", + "created_at": 1689860166, + "id": "b6fea7cc4087d399fb7b8b9e74cdca10c27d9756816665271737630db01d6326", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "361abef7aa4ece11a8cfa6371a3e2f5b5315ae0d31c3a10ac3d548940f62ba8982d1643d6c6228d84dda1aa9442872ca01fdc2830ba855d8494826a10f012f2b", + "tags": [ + [ + "e", + "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "", + "root" + ], + [ + "e", + "b6aa079b9b7c174a42441857fcac737a1924229a7895e3cbfa74466256403fdb", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/pull/511" + ] + ] + }, + { + "content": "Amethyst is the Swiss Army Knife of Nostr clients. \n\nGM.", + "created_at": 1689860006, + "id": "d8b836d465e495d4aba08c1ec2ad934009824c0bcf4274cd5a36828b25138503", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f6a03ab1f6715956b000e40ac4e524ecd8112f6a985d387b671a5c8c96f74b5edaa75ad7364b2e0efe6e10cd61f28b76b6cfc16f31e6d0a98132c22852153660", + "tags": [] + }, + { + "content": "The off-place heart has been fixed, but I am in the middle of a massive refactoring to ship background media playback. I hope I can finish it today. ", + "created_at": 1689859895, + "id": "f0d79ad5e47832399d6e758c63e7ae6b3c4041abd60a6c3f2baf7bd3b5bec847", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "efc9be562ec6b9827408ab1343443226ef638b8b70ff7bd875ae4b12a9c6dc19c5eb10537dc09b8b19a321230e8441b0e88519d70a0fc464a1a22855570b6357", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "d29521c859bce7262b1e1a988c9288d88466851c2baffc6f7578b0d9f2937347", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "648c0f5302c75f38382a4d2c85a482b927cc61b2828a0794e36c6cc796de86a6" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "Sorry, I just tried to provide feedback on why things are the way they are. ", + "created_at": 1689859533, + "id": "23aad9e03bbd73536e22e830f819868447211e8ef66953830df4b1797c50cf97", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f33c542e002f2d788b76cf55ee3fe144aaddf993f5a5c7e786fdd9ada1d3f180ac3686f0b504444abe8cf050d503052f16cf07b169da0128a5612f9ca378add3", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "c5d0cdba3717383bfdeafe50c1275c95de28b18d036df0a60699b9ca019522e2", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "You can already do that with lists. Even if you have not created lists yet, you can pick between Global and Follow lists to see notifications from. If you want the feature right now, you can create a list of the people you care most and that list will appear in the Notification filter. \n\nThe only issue is that you need to make a list in a list client, like highlighter.com or listr.lol", + "created_at": 1689859394, + "id": "b4b1f87e23e0287022def5727e902c550fddab066777ea788e85202613a01876", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "161b8ff1c1bcf4b20d88d17a41dda3f28d937a5228c1db4d1922557c76cd16a710d836a8cec1639777882e676069df86225500bbb631096ed4c21112385b426d", + "tags": [ + [ + "e", + "2f93fdd2f638c4932deff5b2dfdeb8e0f3c464d837e579defc2f95e428574112", + "", + "reply" + ], + [ + "p", + "01ddee289b1a2e90874ca3428a7a414764a6cad1abfaa985c201e7aada16d38c" + ] + ] + }, + { + "content": "Yep, it's similar to how GitHub and Reddit do it. But in Nostr, reaction counts are less interesting because anyone can create an account, inflate numbers and change a reader's perception of how the post was received. \n\nIn Nostr, it is more important to know which person provided those reactions than in apps that centralize identity.", + "created_at": 1689859264, + "id": "98e44e39877beaae41a73c2ea0016087e1a933cff20978715b77abcbd15057ad", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0b5ac8ac7a02c0afb50e4b1f65d1d72e5c69cb6b019ed61fb788f476ff35767c219e324ecf81298cb91dab3afd8a16c10f56beaa87b3b9b5ba1f28963f7ef94f", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "ffdd0c0aa3242dfcde3a4dd2b40a8d7b72b1a35c59199b130936c311a6524fc1", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "5fd693e61a7969ecf5c11dbf5ce20aedac1cea71721755b037955994bf6061bb" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "> I feel like the reactions take up a lot of unnecessary space \n\nThey are designed to be a little party about your post. In other apps, reactions are relegated as useless. And because of their UI choice, they definitely are useless. We are trying to bring meaningful reactions back. This means there should be an easy way to immediately extract meaning from the reaction set. The current interface is not perfect by any means (especially because this is mostly an old design updated constantly over the weeks), but that's what we are going for. \n\n> should maybe be collapsed into a consolidated view that can be expanded to see them all if the user wants to. \n\nThat already happens in the posts in the regular feed. When users come to notifications, they want to see everything that has nappened. Hiding things that happened because we couldn't find a nicer UI yet is not a good idea. \n\n> The stats would feel more natural below the post than above it.\n\nI agree. But this is \"new\". We changed some of the reaction design in the feed and also added inline replies at the top which should have inverted the reaction set look in notifications. We will get there some day. \n\n> there is no space between the pfp images so it just looks very busy and cluttered. \n\nI tested adding some spaces, but it made the experience in low-end phones (which already don't have much space) a lot worse. So, we reverted until we get a better idea. \n\n> The badge on the pfp has a lot going on and could be simplified. \n\nOn this image, it's just the profile picture and the badge uptop so you know which ones are the ones you follow. I am not sure why it is a \"lot\"\n\n> And there is also not enough space between the text and the image in the actual post.\n\nThat is a problem between clients. In your post, there is no new line between \"I’ve been missing you. \" and the image url. So, it should be as close to the text as possible to correctly represent what the author wanted. Adding space is not what the author wanted to do. ", + "created_at": 1689858675, + "id": "e8d93fb9fe8aad86af190d0c9780ca4c4e5019381914b8cb65fc521eba6036d0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b2836ab980831edbf8b7ebe4948f8e65e49a172a8c0bc583264069fb2253787533a64ce294d60b876324d4646788ba8588b8ddcf33f58266f83b6a87e812de35", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "root" + ], + [ + "e", + "d300a620a29f9e09cca2f8c81270742729ce2593d1cbdcd2578a870c73e1355e", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ], + [ + "p", + "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805" + ] + ] + }, + { + "content": "It doesn't matter. Download counts are largely inflated, double counted, faked, etc. I can pay to get 1m download in my app. And Web apps are never downloaded. Since lots of people do use PWAs right now, there is no way to know, not even a ballpark number.", + "created_at": 1689854101, + "id": "1a930a4908d732e5b6956aba473cd6dc10344bdc0726ca384daf2c5621b4bd11", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94ae3dfc434fa223c050fde595cd8f0e18943ad51b4c165eda129215adb250d1fbeff28a47b3969f4e04ea6213dfa3cc0d71eb6ba23e5ea4fe23ad7abdafba11", + "tags": [ + [ + "e", + "763c8078c7dd051a4a690001664b8dfe984070dd3efacd78846c4cdd2da3d068", + "", + "root" + ], + [ + "e", + "9dcca85455abf03edd6812c740d708370077922204a74933548b8c49a5a39f47" + ], + [ + "e", + "aa7fc08e620a6c44c409783356d866910df2247e5b0a4e8493f6d1611eb8ed1b", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "8dfbea712e6263402297a88613240948231dec9172bee9afe2c82b5af27a72cb" + ], + [ + "p", + "09ed4d0ef81d77ee634617e36c505bdd9a8a3b3030686dfac5f5e12a08becdab" + ] + ] + }, + { + "content": "No.. not at all. Any small relay operator and even clients are at the merci of the state. ", + "created_at": 1689853928, + "id": "c3675151e21f23455a36ec7611a45fc69e49da3ce6f7fbbb3aee7d696eee4533", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fac6ed996c0b989fc7983340e8a683ee2b61f058bdc75cd88b5fd993af257072a7f6bb39e2ae8faa095f70f7c7ccbe27b40805e322b8dbb85b0d5d3f35e7e213", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "3212c3bb3d6607241aa71eaf28ecc838873fdd83a051497cfae39d2e34fa7d2d", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "09ed4d0ef81d77ee634617e36c505bdd9a8a3b3030686dfac5f5e12a08becdab" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689851999, + "id": "2a493246e80b3763dec1f04042607a3c53ddbaec602de94bcb61bb201a0f6994", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b3bfa5173003ae8b9217f7cd88226738545b22cbed21fe83adb1e28162525e5a162991bfd09bf72c87d317d1fcf98deb0f71159d12bd20b3a0c86233d6999d47", + "tags": [ + [ + "e", + "40e4da7d99af6b74270f6a41403ad4ef6fc221c4d1311c2afb878468b2a31ba1" + ], + [ + "p", + "1b9d72d38b422a09cefed126e88f82361d4b1cc11cd19a2fc04e17b00b0c7d15" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689851984, + "id": "98fa2f068d0993daace0d34916318fc08ba24e0c8d9b61474666104af24b008f", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2a13e46d8488437d06000d1eae343a3cd36725badd4f6028b019f6ed8a32e285f7cb0abab8f04574b54f5bc73743cb5033b08e5815a2216d2973a5fe44091937", + "tags": [ + [ + "e", + "94f3156cb584696e9ed2559b7aebaa57465518b0493408dbf93c8a6b27d6ca04" + ], + [ + "p", + "49e3ca8a4e680a8914b8736f72470c9e91f609905b01650c75d3e189a49f2172" + ] + ] + }, + { + "content": "XZNuUUWuMuNLom0yTq38jzxZaR9WkSCsdQRjt4q7FAU=?iv=F1s/jYGrdc2i+SMPTqBFgQ==", + "created_at": 1689851679, + "id": "ef72e739b14aebfa57b6c5d42bbc65f4a413247809c592f3b52b4f4b16cd2963", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d2b3bf56fc86a2d6f4274dc0d2fb46d47935e31bd952ae7753863dcdd0101d35978a027fd4f6da316bcfbf6aebbef323c460b6dcfc2e1e2cf35c8e9c52859dc8", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "E5diFaZYD9BL2VnxsPbFhszv7gPnULv+CqfY1mo7kNo=?iv=XFp9d80vSOYbIrs9e6H8rg==", + "created_at": 1689851672, + "id": "061c473461f3a4c067d8438630874451c43082695756c774b8f2bdfbbc8a94c4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ad8d277e27dde2f1ff3389a47e744625f1e672413e33b8f6fe0b1e7a597e8627d6db6883c1cb23643ebcb40c281e9a57f25296b4455aca24283cc0cf342494e1", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "", + "created_at": 1689821830, + "id": "a2a0b38cca7cd77861264b47e27439180d9e8ec0c07257c824e29b7ae6cd7807", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "30f4a24336bf1f0e422770d4ac83b6fc9a14d8e5ccb925c25802ce8fe50e9684f6d32324d0a5702d0fceec85f1f38e8c96b62d405a2e150d4da572900a86ebc2", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_0e75717e2422fcae74a5728783dcab922aa26dced8dc16ac.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "efd7a96c5095ea679d4019a3eda955c430b021a917a350b022bb3c169e67d499" + ], + [ + "size", + "2720953" + ] + ] + }, + { + "content": "", + "created_at": 1689820944, + "id": "7d4d4011107a1728880da56b0f470a0304cbb05e3ea0cdb810ed3eb861e410f5", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0af6a6f8b8e0933691c1644345a63fc1f91e2696faf592790b295e4b903b4c5e4f54a08a6a2911d6c06960df9a3d8cd36f21998161af3f24a37bc9b214996b89", + "tags": [ + [ + "e", + "2d4d14bb122911d7843a934355475528571f34d478d940db3079d9af58d3c448" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "6e225189e0ca6deaac822e3f48fcc44d6132067bab26e59b1dd8f96e55e30969" + ], + [ + "size", + "45775" + ], + [ + "dim", + "495x669" + ], + [ + "blurhash", + "_7G*WnqG}Z9GO@IUEg.9Os9ZaftQ9tni^:%jM^KQRPNbV@03n3s?$~xCWBR*RfTKob,Xo$r;R+0QwaD$-;ogspW=%yR#Ef%3V@tRe.4oNgkDRijZo2Rj-iS5WaS0obV@oz" + ] + ] + }, + { + "content": "9m1+vEUF9wuhiBexLna0SA5wtRJOE5nP1iu5SpzO7II=?iv=Stum2j5gFWdgSUkATftsMw==", + "created_at": 1689818614, + "id": "e3848ec5ab89b4b4a90f1578e695fd5551f8396291ea4b11dbdf6b6a4c065d42", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "57a0bec436faa420590a50fa870e1de583e2615c6878d882c5bf117e4110e4aea9aa70c0b948110fe4d27123fd40d9ef9e4902b0dbbffcc76fc986c59bdb95de", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "IgoBo0FWxSXhIfE4fpowHM7HKTcC8H+O+xTVsIeLYpA=?iv=7mzzIDMSU4G+9pi4cNUnXg==", + "created_at": 1689818605, + "id": "b08b843073ef035a485162714c3df63f049d831cdf9925683507eb6b4c040ed4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "51db96a77a2a85ea0c6b4d50defa60c569614640c83a4aff933e02d103a88b7b59ad67aed9f23129f4f4c85c3c1fc522172d4ed7842945b739343abb4a3879ef", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817063, + "id": "d7fbbcef0a1ba76404891938c72589557b10f032cf3338e0a518b2285b5bc956", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e91283c33eb2525038cb88bb749cc58de7f7c527a63e336b8236a0ae198c812b69ea81e27f9ba4df4399fa75213b3b07a1b4518dbf1f5bc724c9b03081760b73", + "tags": [ + [ + "e", + "d4960970b0fcce6a8e19e9eaf88519d54df616e3dadcd260fa584cc3181711f9" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817021, + "id": "fb97468b31105ca6af4937048cae0eeaffb01576c79f6b06aed84131b0bdf188", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "485e37b048849165ac76e177d546da3ef5606cb4e4641260ad0660f20e52d0bdd53f7d8b4a87cd9584e1a63ffd0b183557c85258f41f3ca8c28d5ded697b8779", + "tags": [ + [ + "e", + "c87e225890d3d6d429663f4cab9057221088c484805b9ef2da0573aa558b9e91" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817012, + "id": "fa72f4d3ca47d559b0b5e627347aad6630c202fdd4000b0cdd70085f051f820e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9c6a4e063706ec03bbf99b1f839ad95b4b5e98b527bb3102d0b5d83f629ccd89294bda01f8d50c238d2e0e96c5967ce41f649e8a73a4686e349256ca8bc41193", + "tags": [ + [ + "e", + "0190e4e11059dcf124eea8387529429c4fc24b7151b0c098edefd364ffd80274" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817011, + "id": "b083690af84d9c7f1b7eb4da844e9132d0e9e20f94dc17dcf43e17ec1c285470", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e2adf0fc81bc1601da5b28835c946762479a3fda09d7bf3b0888b0e7df8cd3f5d7b731ec4254b588426b9376e67caa777c8e8df5d7cfeac5699af33e57977662", + "tags": [ + [ + "e", + "ceb3b15d4564ba20c8ffc2d7753059028ceb5c3679d2229081c62eee84782a3a" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689817009, + "id": "76ba23b7a75d204849ed6104a1c7b35b81b54aefdc90448ae97285d2908bf6b1", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8d8d5a2d851199943a1d3a87e93954ce6d90acd55d294ad76015e2583fabb95d39288953697a9eb184a74447d9bdd38e8a4435c736defd3237578ec8aa522291", + "tags": [ + [ + "e", + "9c289fe421d434b5f8800fdb6a5f064412b809218d11571beec7fc7fa5bb8877" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "Let's go! Nostr Calendars are up. 🚀\n\nnostr:nevent1qqsgyhxkj9etqared6eg2c9d8zjndnpr8qyc37l9cuelk02zyccn2sgppemhxue69uhkummn9ekx7mp0qgsf03c2gsmx5ef4c9zmxvlew04gdh7u94afnknp33qvv3c94kvwxgsrqsqqqqqpp5cakn", + "created_at": 1689816781, + "id": "ac64c67c3dc8b6d43a7c15b94c0d5c382e235f65cae783ed13abe4bff6084163", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6d2c7e78d751c1f175ca559bea9da61b9c8b3dbdb916c2a15754b2d510642a2a27c176aea9e94f985c9a10d7e47b49443a164365fe9757a7ac2456ecd308587f", + "tags": [ + [ + "e", + "825cd69172b074796eb28560ad38a536cc23380988fbe5c733fb3d4226313541", + "", + "mention" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", + "", + "mention" + ] + ] + }, + { + "content": "How do we get subtitles in multiple languages for Nostr videos? ", + "created_at": 1689801767, + "id": "276064d45865bfb223b1c7156ce555ee291c51b82e3096711c7fc9048bef014e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "93dbfa2a47499ed5d9567fce061399a636af7ec8fe2b3d9e02d6db67dd2024fd0db7ea0c696f925633c9b929ca0f1f83b1617372af182f7cf2a9d26ccc4c8e2f", + "tags": [] + }, + { + "content": "AxnOIG1qFS+PKCRIDl/vOfhNlZpMWWxgFCfMAmXR5VR0WyuHgq51owAycbQMDTneEr1fdCQvUvij0VzAhtsWMQ==?iv=fkuPtAZC0sB3f5z95Q5OOA==", + "created_at": 1689796668, + "id": "fe3050a24ef6f71bc7c6f300161e963bd93d8e35b46d6896a679a24bc234def6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "63e0e21357ccd969d00d8b8e3391212a21b99d37eaee66086bce4a75a580f1edb403010a9554e93735eb496199fc1d9dad8a5fb8c6a562ce15b081d8c8400283", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "seyXteTXE7xcuLVoJFxOxrioedOcMZZA2kHO7D5czLW7qIG9djYZmotp8/4AKUvW0ML9/Zg+cMB/l98T1+mFgS+j0LG0v5MYBH1EzQDZpyLlPIZSu+U6FjVX+mQ5xP/1VYbw6bYeN724EJE8A5uR/CeR5ba+7mU/lESmMSQbcG+81nVbauESmwN0/CtfameLcvWQksSclKUVUoK1T8xPdg==?iv=OhXj3Pdt/MWXB2vLQGKQ1A==", + "created_at": 1689796648, + "id": "bd4a822b08a4c60fff4e9643bb524bc10a15ef413f05b54e6d2c06c8f93d175d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "330156d1bf8dcbbfde9a9690b9a2c470476599275a949d1a57b07904e1cb34d6832a4b0f48eae64bf9d990d2a75ec371057ac4e213780874288319173b9c021a", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "dOTrkPCovcomFiLrZaT5PHkdhIjUBA19y1/AI7i05h30rg3KFHNcAWAoKGIDXdVYd9zwp4614oaB5OwU8v50Vg==?iv=zYLVydZOtyn4esxHM2POUw==", + "created_at": 1689796524, + "id": "8a72bd5d8d93e511bc4b90f9c06db9a835533a41244b7ab8dc603dd8204c4848", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2e8930dcdacd7932c447652f8da5d2638fa64bcff4e4c04d0806472d7ac3b378ce8ed41dc7b5c32000d4117d217e6837438557964c24d55659fb9c415b0c6119", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "afy+lH9UsXeMDOXOIDcGWIbqGluAAOC9+Ou+hfz8YtJozeATNVXONKW9vywhW4uf/ug0daIa14ua+Htjru3k/4ZsSt4Lf5s3HL89JVvY8dNM4RQWY3ujCojCibipkHT3?iv=5ZpYFy/C0/lkBA0jUDitWw==", + "created_at": 1689796215, + "id": "1d4b12c76b6b59161c8167e4de7ffb9e6f8bda61abcc24f1352e37acfac5302a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6be01d493cb564657b75b4a3f46a2e8c2f66c08e3c16266b4a39e2e377632f007e2c878971f01ffa1755b744fc71054a5ca7285993a3d6d5b9117f47d5fe18c1", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "hTpYWaI0Y2FGBRnAPWXblTqdiCrzqrlFlLfqCmDwjBYpa63jiCE2pS7bVcqSWMitjWVIyKhCN9fgVBYBFPvefamqJT9z0IZzKebvvs4BqB7Nd+Iz2nokOo4jOPeJqIu+?iv=OqPz5bYdh0+b4haP2XQKdA==", + "created_at": 1689796190, + "id": "3e2dbe64f489fb36298b6d9bebfcc6aeb0ed813a9743d64393eeaeede12d3bbf", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3cff6a0cfcac3221b028cff5dac1c5ff6db6237680c0c6b30b73ef1196300709671f1865c50e0f7146d69e9a7fedd320959ed777d66fb04776ca4be32c5faea8", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "fK7K+JZQjd+pGDqqY7iITMHhXNeqtbYM4yoRniviW3YAgOH3KW5htMwL4iE7BP22kbRCGD+KAugKCQS82eaADG0H4UzcQwRzDkcWfKkOBJZN4hyc+k2h/O73SCT2hjj3?iv=41MU3ZirxPhr7+5FRKko3g==", + "created_at": 1689796155, + "id": "a9cb0ef636892cf0a4c22d29da2b418d2668b94c75966550f8bca4be4b54f4f0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "40da153bc8d11be451edb175ec9e5d896fd6415f1b288ddc83f84775bc990c930faf6061ed01152a8b69f0858a7f10025155e707e426bab08a1e57d13fef100d", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "6XrKnpAX88V6Htj3dRFhYJIrpvoQygXbFRHQ7U6Lvulf2w+Wi8ObX0duVSPhbTdx?iv=1pKalTwNvr/773dNsYxrsQ==", + "created_at": 1689795660, + "id": "c7423761b1b250f98795e26c52aaff8fe85bb28c23ed30bd206bfc4d84ce9580", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "26bab236f84777a5f7478744aa689795f1dcdff523f2f4e3f801d6a73f6b221f5d1f55e664ee4b9880566eedc5acdc3ff8fd39e85aae27b04a2f4022c0a71a65", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "d+fEU20cosND2HbmwFIJauroNtSmj+0vjS7JFTuSpVRZVlqsBYAudVHjIoBUoVhoZ6nRf1mYcf/DxCVwn08ZMvJAXqVgT5WPhylmKYm+6Y0=?iv=eJW6o5wA77YxIOLVyr08ow==", + "created_at": 1689795615, + "id": "f0d66292c0478c5876b12b330d5f8bb87ccce54249203d87440110c1e1e3f358", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f5e9328dd7f581af984fb02f41b62bfd0b14961586b612f668a8f9a1a237e1d47a5f7e7ecef4cfe6988c18b8ac7c58701e858c27d747510e3dccaa16a8235e99", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "jYW8dxVeSx76co1IsQTpqw==?iv=9TpMslZaeGk/Qc0kn6d0Lg==", + "created_at": 1689795614, + "id": "f4232c1aef99cf09ddf794392f4c37c4eca0d9d85bb2d1ae3ddf72da7632bc0b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "bf5d8458087408d3a652d26c162fc405d31743a5f3de2c12fbca593ba3b3662e9052c2b3d7c9086dd79e4387a36762b55873544c4c762f104e3727d734365c83", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "wJqpUSs9Tqc+LJMiwF2Tl1d11sl7y85iM4qutodWeBNn3UMTpyA731WmTMxtwo59HwgDx7Y2wi/B5oLY7oBZUA==?iv=0stWUqHs9xD34aJcRP0Z9A==", + "created_at": 1689795571, + "id": "df846e0e2628a0102dc5b5839e151dbd483cf5e6ac29b7c5212a20a058a9aa33", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b65d1f339c88793dbfc0a0e46b91e53cd506732a6ece9d585041d8d1e0288b9cfd8b81d02a0efe4e6835d2b51258b2a2291af42f311dd1571977bbb22d1188e7", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "ol3R5HKU2HN5/6TlsnqPEw==?iv=0y+mRP8iHhHiIHlIb/Njqg==", + "created_at": 1689795555, + "id": "de8f70191affee06d10e83abf8a8000d5af3b6726f636dcb204631672551d206", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c98a7d07063f1f1f69a93838348123d6a6dd321cb8f720d876958e002e413e0dc95b9716f36ce1dfcfb51240c8ce7e0d8e835fe2e3fb209f3940a4fde582e0f3", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "+", + "created_at": 1689793683, + "id": "ba35efe39f4d25ef3f79f578c618456a04d27115333a8ad4b5558a70b6ba20d0", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "193233755ae8283a7e0bbafcea1656d21a68f90912a4e58afa491a11590d262ee9f7cc57ad625f8b7a5969d6266968783037282c7171413cfdb3308d1c739da4", + "tags": [ + [ + "e", + "ca1d6d0979a9892cdc6bb6c9f6f0ad752abf91a59e2ab047c8dc40ba864d94fa" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ] + ] + }, + { + "content": "WW1FqQbpCQpDxsJeX/w3qSMNW6OMRN1FoWU6pE0fxVl2dsisbuts+pZplL8S/0AcQ+WrBcGIxd5O8w5ky8Wz6zWyge0VQ63fr07NWuitHr4=?iv=ZehOo9LmLkLSt42WdXYnFg==", + "created_at": 1689793047, + "id": "fde523a39bf03736ae928db7d709e6d50193e7a6c20747cc529656db012a6f6b", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4a72e902dede3d8ebbcf1f662f8194573d0bd2cc26a66d66ff11a67d11471d24444329c4a3f75dadd7ffc51740394ba83f54ff6337882b5b7ee332e65f468d41", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "11d5n+hT39BBDvhG6kwgs3QtITRCilwvu2t1EyfnbW9zQDvhGImaVwcNSBJogPUWND81PxxSevsnRunX9IlpkE+NlxFvoRJETnjB7K7SYJgEm1AJS0gvOOZoJay6YgV+?iv=5O4lPYjvIlFJhZtbcOqBNg==", + "created_at": 1689793029, + "id": "617562fd855d45c2f4ee08f87c4f8cce815b4d2e94a51cf6c6d25b50789c6cb9", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "504ac9d67669273fbcc6d92535c7a01cd50915393baa190cac7d913b50fcd3606e2945b67370e37366327b70ed24fe2f45bd9be29c26342fd5fd9f5ad1db5c40", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "+", + "created_at": 1689792227, + "id": "4afd3e5db2ec5e356e2b734978f0c142d46dd547e2a74bc23d3a0a01c15273f4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1439d24fa6b4b40ad66ef01799d7196fdf70f9fd7f8cdf8d114e99235af0a0d994c28bde2f665dceda0cc31694ded3534e1e8d55e316dd6605c84eddae875eed", + "tags": [ + [ + "e", + "794fb8ee8d06f5961c40a11f6cbda46819ef87d6907c0d9fb05038ca13aa7a4e" + ], + [ + "p", + "876e724d762be346b89f47070758da679cdb969a2a17d3c5df93da832bb71acd" + ] + ] + }, + { + "content": "juGxq6AXk3XCL4JEp5A+BJ4A7hwL1n5AZjD55zC7+EI3PjXiYZcGDta1c0CUH4qVcxmTLSqUFOcs/Dwtd5Qea/hemj9/nIu3GJYMh6Ch0+SPDo0sfXaVecSCAaAmcObl?iv=l+1TL3C3hwFh+Izzz9MVVA==", + "created_at": 1689792194, + "id": "5425f9eeab69bedf79ca59cd730aae076b04b33cabf709e78560929680164294", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ba56b1630ad6dc18ff9d3169a5947011623281fd73dac87272bb6cb0b2cf316b0108171689728d49680f1b6da47169ff0cef2aa420d86326da2ed11ea7ec0394", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "For sure.", + "created_at": 1689792082, + "id": "ad71d5164ffd119c21f1386f5ebaa23faf93b0860bb2a8af6d2af63b5143a43f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e466cd8f8c06b0acbc66bd4f59cb0852ab4df16b8d70e63de280b39750c44a58f49972b06563fe9b4f74760a3a8be835111a12e874015b8b32016fa78d22ff90", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "4c37c971eb747bb8952650778094269d48047e470b392129967e412ac80fb604", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "876e724d762be346b89f47070758da679cdb969a2a17d3c5df93da832bb71acd" + ] + ] + }, + { + "content": "peJPIP0HJSqAp5QnclIiewPTW6D8ABE5XFjsRLNExq1+m0ofqGlK+p6eUYTtXEkVY708OLjsn4n1BwzZ3VvBrRhAFTW1qiBgot9Mh3g14bwzwe0qTVIMFJX0Zh/Xt0eu?iv=nMfwVItwt7/FJ8wPd/Eqhw==", + "created_at": 1689792036, + "id": "2a129c01330a59c5e517af6fe0fc3e5c2c2ca1514a19df5351d07502efc220f8", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "23a492d737219cacf65b085a17a2db72ddfc3410616b297b59eaaee9bd10522754832cbbf35c7e422434300b50909d2ce7ad2e3a95017686621d2126634b199a", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "7yKCNmFIkil1iodXHUNPGg==?iv=QhwQSNtrZXzi4jw/06XOaQ==", + "created_at": 1689791999, + "id": "f7108bc890bdd5bd37ae38f7d11a02a8da5f02042b0b77423a74658fcddc9934", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e20c98f26702482353a7982cf48665a7fe9f6bacfe05af338339a70dea82d2575235ddb1b6ddcd752579d6a83171c47e0df5fa282992c4aac099730900b91d64", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "40VWtBtcLP3YFFX1QTw709kpQB0pTfY0UleW7S2iOJU=?iv=m7BNPpW5hyYeV/BuNR19AQ==", + "created_at": 1689791992, + "id": "19d8ee5db99a5631f71d4f4711bae78b5cc93c0c3552ab78fdff0df1c674979a", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f416f0a88ea4de2b2487fb5b07b353516ad5ca4c17bcacf2315d7ef612b312281f1e087a14ebade82e91f6cd4c27395bcc88af61f51cbfeac4c2ff2c6d7e6378", + "tags": [ + [ + "p", + "8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934" + ] + ] + }, + { + "content": "5p2yM2M5i3h7H0nuTsP5RrsvWmKbUpucyHzxMZJ1wsf//jObdREH5wLvqPQ2w6i2WKD/T/808UTk+rb8ZPzlqLNUgYBMlD8bjz1IEVY7Hn8=?iv=3749TEgAq+mmP68ICxcakA==", + "created_at": 1689791923, + "id": "341bc18bbcb90e8dd6736376d179a137e42ab2d33b9a9d9af60f82b2069be88c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c89bfc23afd50a4b669c5000a244bff10b114991983627c3da64b03de38f220792d14c75528cfd89b2ecefdb8093086d2ffa3b2b109ddb2a4be0b6b4dfe53bcc", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "n/Zh7u6uaozj+2mCIYhXuT2Oqn7uyYwYkqaBWPBOwsgO1dSqxHPLWwR5ZoiCPij9EWhtSI2BHEEy5OeYxqlLr51Cli4gzNpq03zxbEb+vulcrC0mXRBJXd3SIo/Cu/um?iv=putWwaSfX+fajySLJXgBeA==", + "created_at": 1689791780, + "id": "18d4f7440a02c83680b2a5744c0359fc088a80b6ca0dfe922c3e13103746926c", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8dcad60b4faa5727302a3e3f2fdca3ecbddb85d3e87724d0e8d7053f647960d5174482a22957776ef8f84a33b9af5d19c45971b5cbb43428ed754c3839c25459", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "T7+/09ia5+pzY0WDwzhGhQ==?iv=iAV94k1Vl1X1OYpekGbgLg==", + "created_at": 1689791735, + "id": "a044df6a86bb24b2468609f74a3fb639ad599d6bf14293efd6b257148d1df8c0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ecfcb8838d80dfb943ee0d8fee094402484057ce6a85d35754a818707bf04becd299d41cc89519c378e545a461263f44d3e6adb6c201c2fdbae5cb7cb6c627a8", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "What's wrong with the image? ", + "created_at": 1689791634, + "id": "1a32a1345dab56de57b1d0e5e3e0f82197e782541cabd4d4ecd1b1ce55d495c8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0e893b5c1c6adadc6d61868ea542d8320924234f8e6a8df75eb845e06abbbd386f2cc8717692bf18927bd52b58f936ea5bf0fcaf66c03b1bc7219c33f1508620", + "tags": [ + [ + "e", + "134cdb01d0cba849d56cb6017aedefbcf8043eb01608089ee02951b854170185", + "", + "reply" + ], + [ + "p", + "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74" + ] + ] + }, + { + "content": "We haven't unlocked the power of communities yet. Amethyst will have a few UI changes to make the feeling of joining a community a little bit better. ", + "created_at": 1689791536, + "id": "5f74955acd83df8b9ef6031f19956685172beca93a4b03dfb56954a66782e9e8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9d277d454cefe5e6313d8c10820321ef2a6d91ff2ff305f3ed7639869321ca78d3d7aff5205f5d02b2884499e16171d29d1959fca185e35f73f34de79f6966b7", + "tags": [ + [ + "e", + "eb612971888141cd5abeaaab8b7472028574f26155074f57598ca9eb237080af", + "", + "root" + ], + [ + "e", + "39c1e289861226f800d915a60484b498a531d4cade8cb3caed3dafcdb4ee891a", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ], + [ + "p", + "ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0" + ], + [ + "p", + "efc37e97fa4fad679e464b7a6184009b7cc7605aceb0c5f56b464d2b986a60f0" + ], + [ + "p", + "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "D2c4aPXsJCcwJ9z6NGsQALQ/i1+bbhIHgOsdloA7mMyLqxWkCXNtiSa0wvJN8Bi9fYhjlOa1zT1tj8GWUYVbkH7gsgLnboTwCLr48JjKNZRHY2ske/sFpF+V3OMYCtkDRRh74p6yKv5x4b3KSvAdZmuGApkP37wf4tA9QSYj7Fg=?iv=E+UwaN6Acc5VuYlhJo2wrA==", + "created_at": 1689791426, + "id": "4c65727b37d96f7937024be773f048cf291569682a6ec93d1f2677eb872900eb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "17494231e1e2ac82f6f5c0ed39bfda56e76687ec29fb45ca6eb56c2621fea1f1612b47247c0a1d9f52fa2f2502c838ea5401cbfa414adcabbafad38be49c17d3", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "hturKi69ZiXh/zuDehkuYisaZulgLhQweuejTUC/NgkSY726pRvx2eMaeeMp/GyXLIiXJmHBbGHN3WcriT1eWg==?iv=145wzmomdE0AgjYK90KDHA==", + "created_at": 1689791397, + "id": "4145c43d088936140a0d03f78fe65d075c09560118a1d10eb9a61f754ac0c3ba", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "28dd7c2ddcf93091f5e24afea68410f5b77c505a271e6eeb17396aa8e7acd47da4ae65f2c7f8fd7b416be2d72955e81cddd884b0e6b2d041cd269c17158907de", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "UGNJsE4FdyJR6HsTJKRZiZ2SjVC+Rur8wg++uOTi8iLdnDTdVhv4hEA41MbHYCjZnCZQO50fFsBICzFnziC1DA==?iv=GpYALl36b8bHbqAL14NZWg==", + "created_at": 1689789316, + "id": "881bccdc3e7ea2e211282406d8a24f9d8b13730be7f7e4aa20cb2a87c3a813ef", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c36ea50e7ad612a1e377851882b2db9f878d591115c769f83bd59344d24c45f4639a9e7b7e281bf2443b57612df932afc3f53f567218683c3b28077f2359c37c", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "Of course. But the question is how are they technically going to do it. There are many possibilities.", + "created_at": 1689788786, + "id": "bf39c47e2e5af5751dd5265744185df3d8205ab76a467331ef0bb97d288224af", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c035c989ecb3a567bd0b7d09339276f93d2973d99bed28ec7aaf8a653a27a3f80674804abe0b2eca980060709147431ca910cb7da795648becf72014be0cef1f", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "69601e47112484a0fc1f173d0bceef8ee33ed56c5831a3d30d12693dda4fdf28", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "206e580157eae3395cdf954ff30501e16902074656a4b9493f36f896167496b2" + ] + ] + }, + { + "content": "fRxYgGAPTXND3ruRL3wx/w==?iv=jsuZZX/FaK2XciQ2Wir7yg==", + "created_at": 1689788740, + "id": "81c6f03692855f0e36a4b80d864aaacfecb549d39e390a5f44813e6df5c9f8f4", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ea0b345f1d05f62314c6d68a2938b888ccb1a3c0ebebe8848b3df72dc130711042f0144d9e3041c3521bd61ba2845a56bfe3bf745dcb8ebd9e01bd1e77710e53", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "LKXDKYSKDCjyq+6MkQgfGo5esbtZDs6yNBoY2WOiFHxBqa7Jywg556XC4eD/xaM1xh1S2Bd3eHRm3UT9sYJdk1ubyxDdiuKXSEPyhGuLBri76at3UgWfSs0wQknFS+cgnX8V9LhGGGpzMb+8kNZR36k3AEZfcjrbOvOVp0BLKIg=?iv=Glj5CCIi8+1zDbxw+2SeMg==", + "created_at": 1689788734, + "id": "d7f01cdc157a28c915e6077a14820cfb6535321e17d3199420f05d875353d1ae", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b7036aa95b6e68d34089897e1f8edf6263ef52848396894b2b619092fdb2f59ac13f68fb5666a56a65deb04409de0e4fc1e94afe0c3c09098e8b2edd3e04ad42", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "YcjKnGE+tJH/cds7X/vOroR6NVYYpRh0otIc/ZgeZvA=?iv=xsJsUMr3It1EXfc5ZRHPLA==", + "created_at": 1689788612, + "id": "18a06699806b3202f7eba5736d89f8d30302539f7e306bfb7623c1a21c600092", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c8e3241e90b60fac97c4457589816a56b22995be2ec2e2317d51e1d66fb897efb387b6cbeacbf261dda0804c9283211fbf1af1ac5bc9e9c5fdc78a2b2060014b", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "q/kqbaK7wnyDvJ4ORsOwfg==?iv=6rToBnTYx2KUnRTxPEHthA==", + "created_at": 1689788601, + "id": "ae37ff6bf243668b58d3b12a11123912a2e6f71ed1e60206d4e2234df6b9b6a3", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "eb5593f481e1d67d9e6faf18c17b59e5ec06dd8e6c5026b4d076cb3320e1c3ef8115de470005e7afdaacf1e0f1cebecfe8e9d5fdd73510b6fcbb71aad51b8494", + "tags": [ + [ + "p", + "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + ] + ] + }, + { + "content": "No yet. ", + "created_at": 1689780075, + "id": "8df02766da387d6dc3bfc283c6b9c2e99650006fb5b1c88884b8d5014c25854d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1040be4fa70fb028dfb8bd622ef37407b7246db688e0e6ad942d8cf3cdba86b635a7f5f1a33feb4046ab77fd1f52ea28ffe98517070b942b1273e670a0f8603b", + "tags": [ + [ + "e", + "e5f11e003dfb5ed948bdd67525adef44c6534df4b529ea881337307810c3e6a8", + "", + "root" + ], + [ + "e", + "45e52aac94688eadc432a5ba9e81a213309c289c86618ce660617239cb7b7d9b", + "wss://nos.lol/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Yep :)", + "created_at": 1689779927, + "id": "de53bc355935d7f20927b9cd580f8351263a0e78664678898267a70769e33573", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a45551d3f326875ccf410c546d770fd1b87af47022ed6125cf0c4b58cb66251efd0089daab8e19563c25c44690b314c227724ff9bb9fcc39c16727152cbbd119", + "tags": [ + [ + "e", + "e5f11e003dfb5ed948bdd67525adef44c6534df4b529ea881337307810c3e6a8", + "", + "root" + ], + [ + "e", + "6fc9fe5427f818be975d11a5647aeee0d669bd417a5422e669a679114313b42a", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Sorry nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 \n\nhttps://cdn.nostr.build/i/e1c9f560acf3e3fbc147d83792313bca299010e5a259b00a8e7ee9724eaa6946.jpg", + "created_at": 1689779837, + "id": "e5f11e003dfb5ed948bdd67525adef44c6534df4b529ea881337307810c3e6a8", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "64516164405895e616f16240ee6e0b2aafeea2e78f93d19dd68c22b17e8dd6723adff8508edd20e23c3e9fcabc2f04c86d2fc35d50918169c9dd207aa9f4c873", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "I am waiting for them to start tagging content I am required by law to filter out... ", + "created_at": 1689779811, + "id": "385078f5c6f4d466900e5267755a1a2db58ff0cb9519cf695c5a69391eba29cf", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f1890c97830f613abea4ab6f6958bab0d2afeb0bfc91261f0a9c517aa85c8007094bd6beaab44dbb85084952fff2c0207bb58e509176aa15c4cc5a99475410ea", + "tags": [ + [ + "e", + "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "", + "root" + ], + [ + "e", + "e19338dadac50e080bccc1457d5923aff9aaf88012401e9bc353c4c4b3c28839", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "c43bbb58e2e6bc2f9455758257f6ba5329107bd4e8274068c2936c69d9980b7d" + ] + ] + }, + { + "content": "We don't know what's going to happen when the state starts using Nostr.\n\nBut they will. ", + "created_at": 1689779396, + "id": "25025a9422e006903a8bc8b7370f16bc1aeeeb7714c872751f02d8546bd603e5", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9a4740dbfb4894634fc40d6c34151a4e5c2771daa9af119bac96e36fcf0b65a938ea1a8224ddbfbc70034e81dc0f9ab86033c7425488359392d604e1f8257765", + "tags": [] + }, + { + "content": "We are using kind 30,000 mostly because I think it is a good idea to create a feed of the muted folks. Since Amethyst shows all kind 30,000 as feeds, it's an easy trick to show the \"blocked\" feed as well. \n\nBut we can go either way. ", + "created_at": 1689779276, + "id": "24e3fab99020ab31228b2ed48eb63fa7800be963d5503c5c2598ee61990ebeba", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fdf665f4df27fcb7d407e43b888b9f25d96ad77fc95c9114ee3f50b5477e6cc8303f3966a031cff71ca0a9ab492152ebbbfcde597b62c22611d77a3628167e13", + "tags": [ + [ + "e", + "bab81750570876585535c8ac4a4d5f983ea4cb0675c94882af25b256d60f5a7e", + "", + "root" + ], + [ + "e", + "b9d1689cb4c5c3220fc2d8a3ef0731f6bdb2fb7cb2593a085c57488616932dfa", + "wss://filter.nostr.wine/", + "reply" + ], + [ + "p", + "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e" + ] + ] + }, + { + "content": "Not only relays will have to deal with this but clients as well. Agencies can tag Nostr content and write in law that clients operating in the jurisdiction must filter that content out. And agencies can easily enforce this via the PlayStore and the F-Droid store. \n\nWe don't know what's going to happen when the state starts using Nostr. ", + "created_at": 1689779139, + "id": "6d592b4cf06a528138af967f69e425c4175c7dd149776ead72718ca06c7c10a0", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "aa5d16975ac357fde1c7814e73ebfef5c326f98cf622ee5150c530c476f545d2db97e94a0278e1cbce7fef4e4546379083b71bf277351df57aea15a54e3e4fc8", + "tags": [ + [ + "e", + "810412a20e676fac2119d0401830601ad2b9bf640d39c5709ae047570476e06f", + "", + "reply" + ], + [ + "p", + "8dfbea712e6263402297a88613240948231dec9172bee9afe2c82b5af27a72cb" + ] + ] + }, + { + "content": "+", + "created_at": 1689778259, + "id": "218d0bcda05431434e82da0689cebd0f29edd9880dba67bed6dbd7106aa21402", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ba1c0b1bc595450196b982d8fde84751ce16b4e2da577a9ecb44c16f1c8fe0c2716edec606ff3cefc0569836f9507ca34c6a9411d9a463004fe5521255ba54d6", + "tags": [ + [ + "e", + "5a7eac5ce4ec6ffad268fb6bbd5450e2361a1ee20efc2aba3f94f41c49ac6d64" + ], + [ + "p", + "564e2192d2c4df33224c15d787cd0172fc8c7e6a7afd67bcc3f8e3447d765981" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689773079, + "id": "f2bae9c9b2c5b1db90828c1cbdd5892485a1a239303a7e7425922ec2757705c4", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a64442be4bd5d5d7248f120a966e5d995f8958cad3242f05b145e08ca859c2602d88508f9d8bedab29ad0027349fe5cbfd8e12832208f7ccbb7f0f4419197a2d", + "tags": [ + [ + "e", + "ceade31f339b0d1ef64d951e49153c08d33b995ea73f419cb9d65501438857f9" + ], + [ + "p", + "f5f98ba54045a2fa8df2a97c35fc30299b9e0d51a701795c040ca80cedaf39b4" + ] + ] + }, + { + "content": "+", + "created_at": 1689770561, + "id": "bbf5451dfa185bec87f2e274e451792d8d203d4f257649c7a54b699d159aebce", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2a74f27d5914cef15399d1a4b52f17c1825755b8a7b70e6d05dce67db7696272387dabc4b6f27cf76c62708b0ea167379ee17db00451a91020cc7a734c967df2", + "tags": [ + [ + "e", + "9fc46ca0fbdee0f4436f1d0b48d1303ea6b10bc6c5d47fad13bd96a6a1968ba6" + ], + [ + "p", + "7f5c2b4e48a0e9feca63a46b13cdb82489f4020398d60a2070a968caa818d75d" + ] + ] + }, + { + "content": "Qju4ICVAki2Ead6iINTA5GaOnJSwZy00Gilvyhm/LmjdnNEcQdQPXifheYc/1eMchN6VUxJDabiafPe1OjX+RD2iciBUlABOgpy+y+weUd8=?iv=BaIQNwhp6g4PiJu+MWdiuQ==", + "created_at": 1689770428, + "id": "389386ae9cbc4b1d5e4fefa464136b4f5eff12ad0e030b15076976a08c9b40c6", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1e14b2d38f73f965f987c05c909eafefd97bdb29c04b3cc6d78aad708c0106559282286f9073a45f6f7d4f840b94249de21d5a6e964708cf0fb800401932068e", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "D+mvo+RodsQgWegn9RjuWHyawp7sHFBpLgHW5+2yQLw84TKrY5svyR2NfEUmJcl3?iv=BkB0cjrode2kmi5foZf/qA==", + "created_at": 1689770406, + "id": "bef8d4b6c114ca46c03093577e72ae1aea26b1102242ed1c9d0220c78f31d707", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "87cb9e2c165d9bb931c73a6c8899363ffa975f423989363580090919219406a13c5be2932e0df676931583d8a2be86def4b56bc0f8021543dd1aa584efda08ad", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "Sd4jDtAA1cp5RPs3vlXKgKEcnnwyBCX/6gAI8iFUj+vlFhblJc7UCQ0E+FtgPeeK8GYB1lrmll9Z+Xcy8S8yAQ==?iv=YJ/u5liYySfupmcXDjGu/w==", + "created_at": 1689770387, + "id": "f75b58ebfaca4a9697397fe8d2b6f848d3d01908bbfaad613e37b995e0fe0629", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "59bace0ecd181d2a29c13eee6cf238ac70a22a45b6e0ef6d9df39a46a7f5f6243c26b223fc085027cf326fdaf1e8f63028071560abd48fd24f2d912be12bc0a2", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "V+RxWXXhMHFF9yQQmbWK6Q==?iv=QrXWO3cMXO6+rntdblFulw==", + "created_at": 1689770372, + "id": "331ec02999a4ae6aaf33eeb738dc4c9e8535f077b9573f92c78aa75b1c0caa00", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "edc43ce3d7ddcbcb5b72e9c8e869250ccebd76244cfd883f7a4ba0833c7a2d19a0052a6aafdfd0cab1ed7a3d31a58c3b976f5a16c3499e0ddd769849d027699e", + "tags": [ + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ] + ] + }, + { + "content": "I will repeat myself: It is utterly embarrassing that YouTube creators have more monetization tools for their creations than developers. \n\nThis starts to fix it: \n\nnostr:nevent1qqsdcan9s3j2pvhgugnfz5vkfsv6ehumxeqzl6lhgkx7vh3h6hg7hegpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhszrnhwden5te0dehhxtnvdakz7kzntv4", + "created_at": 1689770176, + "id": "2526d5ce37b700d416fea47721d3260a605b3a3edbb916adddd8f547cccdb6cc", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f5d7339fa2d1be538e33d67e7cb7c267c54eb79a555719f245eae431471e50ead7735892395bf1bfc876d37c8849b0ff006188e22ceaec322d867f960699cd48", + "tags": [] + }, + { + "content": "An external service for larger purchases is great. But I think it is extremely important to also be able to one-click buy things from any Nostr interface without navigating away. I have explored a few options with Amethyst but it's not good yet. ", + "created_at": 1689769859, + "id": "f0bf7d248e55683e008eea97ad017f87b49e724539148bdfa937cb57caa588a4", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffddf687fc80146e53901dc55547ef00dc010453efa337eaac7c84768daab445bd0b4d1187ca97b35e6d47c7440dc9a995fac7226bf79f60b14cc8784dfc8279", + "tags": [ + [ + "e", + "1a1be57cf73c27aefab4f0b733fe8ba433dbccc7ba2dc8c53c9764d077f79add", + "", + "root" + ], + [ + "e", + "3b24ba5bf6026fa7b210db0895df602084817c5521bccccf80a7bbf64607cff9", + "wss://relay.orangepill.dev/", + "reply" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ], + [ + "p", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0" + ] + ] + }, + { + "content": "Strange. Isn't it a relay issue? I noticed that the post is only in 5 relays. ", + "created_at": 1689768641, + "id": "aa82841f3da7f04319e9b5b0ef0d6a934da15fd575ba9ba2055bc7e542fb005a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6eece2f872bbfb9459c7bb3e6d8b73328d3d40b300b8ec31c47165dabf69d30a039103f32c7acd4620e03a1f2a3a4f764bf6c8687524ab117e677c7c74eb0107", + "tags": [ + [ + "e", + "bbcc50d1d1487dca1ae3791ac651c709d5a0b91b139ed5a54f4a1aa2f077c465", + "", + "root" + ], + [ + "e", + "4289c7852d03444b1075f3ca4dd5cf79ad324ec0cd464144470c9db796918604" + ], + [ + "e", + "3c4f2cd1fd77b5f00b946b58a71fc1bc0dd3ba74eb70d6e89d1a1dc025036b44", + "", + "reply" + ], + [ + "p", + "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ], + [ + "p", + "564e2192d2c4df33224c15d787cd0172fc8c7e6a7afd67bcc3f8e3447d765981" + ] + ] + }, + { + "content": "LnYgC0RLdzVdatNVXo/iD6UN+dP4xDT/9sw2E95T958=?iv=rikKQozxy/jFNMVIDjqTlg==", + "created_at": 1689768433, + "id": "6e22cc856c25dc8154959147f4c71ac9ad687385135390bba42df621b0daa1fb", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ced9ab7e521432aa54d433b32869c21b402d9fb7e009eee6e70e6b0b2100edc4d78a3571dc79279c05ad11214e1d2429e5c764ac366b932bde2493beb71deee1", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "84qIV+GtKNOE6pCikWXafQ==?iv=IH/gXx+bmRGJz6SoM5pkVA==", + "created_at": 1689768426, + "id": "20ccf572dc67d15526c6a2d39115769a37d3a8dfabab54dec30307e2e137d634", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "34409de0e4e1c707d11d352bdbbbfd45eb5be266302ee5b701cd6f3df97ebf88958b011f41f7e1814c70d7716960d9ff611712c1f261c450ea46d084af43ae76", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "5w39JZPqwCg2wYp3fZi5DxJOfGS3ILijznewkli0xa8=?iv=3rXD8vh3AtHVd0RbFVlyRA==", + "created_at": 1689768408, + "id": "d67ff8794bf1e427c29c978a277e547bb8783799d8ba558a789636639fa64d5d", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "cf6ef7ca24fcceeca9095de854e0046098f508d5c4e13e05f283f98ef8b915da1300f6ca3e5929025151b128243980f7c7ed7a3ae958c8c3badb287fba2c0ec9", + "tags": [ + [ + "p", + "672196e844d54702dc5933bf339d53e1b2641a767264384323957f424fd57b04" + ] + ] + }, + { + "content": "Block the app? I am not sure what you mean", + "created_at": 1689766554, + "id": "95709faa74b8a044c55155344768f868035f4a2efaba9fdcafd91620a83c7ad7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d9348bc5a286dfd6bcabf2e778f85b9aa85a2ce53892951232900dfa964734e9ba4ee6177b4def01c4b8d29478fa228ecee19f474ddbeacee5b5ec80c82787e5", + "tags": [ + [ + "e", + "ab53ded32616980073fc2c9c6ee2e8056ecc5bbd6d46c10a925fd96077a0a574", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b75b9a3131f4263add94ba20beb352a11032684f2dac07a7e1af827c6f3c1505" + ] + ] + }, + { + "content": "Yep, since February.", + "created_at": 1689766494, + "id": "67f9aefdac7e17525dcb9b9a8a4b62e8df734765c71df1b5a653b7e4e871b246", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "02d792d0446f213e8d0624ae50d376552baaeea7be6714aa186b5e2299546cd3e6227ec9e89692963e0ba50b680f7f2e3aa7a663b69e53ec48f96e9977ac7859", + "tags": [ + [ + "e", + "5fbedd48a63ed0a4c8c411b7c4eff4cca22b4430d0c2eabcfa1fdbef25dea8c5", + "", + "root" + ], + [ + "e", + "1b3898f53abd0fa5ad782f8d6175a441e14128d54d65a8ef008468cc5f9123a1", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "331dcd701d4785c9ed7f2cbc0d37e72448c810186c9066f16b9c6c69d454df6c" + ] + ] + }, + { + "content": "Over 100k, but downloads is not a good metric to follow. Daily active users from Nostr.band is way better. ", + "created_at": 1689766394, + "id": "9dcca85455abf03edd6812c740d708370077922204a74933548b8c49a5a39f47", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "4e22986a80e3fe0d76f06e70f19be3a1e26b3ad5bb918a750744033c6d7add144ab08532bec52c5edd8f010cc9c114b35ed973ca7f98daf5337c83e8a70c8fa4", + "tags": [ + [ + "e", + "763c8078c7dd051a4a690001664b8dfe984070dd3efacd78846c4cdd2da3d068", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "8dfbea712e6263402297a88613240948231dec9172bee9afe2c82b5af27a72cb" + ], + [ + "r", + "Nostr.band" + ] + ] + }, + { + "content": "What do you mean? I see this note on amethyst just fine. You don't see it? ", + "created_at": 1689765862, + "id": "4289c7852d03444b1075f3ca4dd5cf79ad324ec0cd464144470c9db796918604", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "605e4e0f4389037a02f6dbf16d72b9d616637d5ccf360c1fb40721febe76ac367839816263c20c99c3c4c381c2cb36789784888b7ee1ef780fcbac2e0eda9964", + "tags": [ + [ + "e", + "bbcc50d1d1487dca1ae3791ac651c709d5a0b91b139ed5a54f4a1aa2f077c465", + "", + "reply" + ], + [ + "p", + "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ], + [ + "p", + "564e2192d2c4df33224c15d787cd0172fc8c7e6a7afd67bcc3f8e3447d765981" + ] + ] + }, + { + "content": "", + "created_at": 1689735264, + "id": "568aa3a8feb50e84be9e4eb23e67a1c6caf64477a3f5beed397fd813fb341c51", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c17f12eb3978813697517020585f79ec8f3fb0d40e304d96f04fa187534c9726644782aa2efb38b88cb096f0bdc982836d1d8088ef5a4330deb56f661aca7eae", + "tags": [ + [ + "e", + "2091531f8196ac2334bf5249f0593d25a47516d7b0f05b72f75898a4767105d4" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "44746cee88a03743d35757aaf5b3ba178d527b71831387fb6df6b7ff1c1dfed5" + ], + [ + "size", + "70744" + ], + [ + "dim", + "700x787" + ], + [ + "blurhash", + "{bJQs1xtx[M|aKt6bGkB~qkC%MaeM{ozkCj[_3R-xuWBoIofbHay%gxZWWfQj[juWCayJCR+jbj@WVa}kBWBaeWBV@a#kCf6ofayRij?WBj[kCjsj[WBslWBWAj[j]j?bHWBR+fPRjoLWCWCoLWV" + ] + ] + }, + { + "content": "", + "created_at": 1689735142, + "id": "8baf7f4f57b2ccc2959d4013d8ce8984f23ac63fa501fc4779081183a8d1e607", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2e7ff6a7d5e66d20ebcc295965f5a3819d8b0756f100b3fb2a174a6988b8f989d8abfd81afacb607406019144a19830dee5ec0e81d621d93c825b632db2c152b", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_22f927d3b7728cd4a5e4a346fdde6dc8a5572072ffbc25b4.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "b9f569c3cc11bf0bafc96acd2ecbbb947a291ed28c04a109194122ecb16b96ab" + ], + [ + "size", + "4552970" + ] + ] + }, + { + "content": "", + "created_at": 1689734960, + "id": "257b87c845792f00af0850ec6c1c2c303c03c7de5ced8809d18886e731da4e8f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "08949cd2e8c188fbc65879163fc62b449037a4540c9bdd918a5e0cd505810d61db0a221d84582a24c699f364db7439cb52bea21788370997f666ea580270ff3d", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_8e7b035017ec1de33d2b8566de0f29efa9bca2410efbbe49.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c660cce3f512e9264baabc00b86f886b1a765507c318604695c2485843ee5568" + ], + [ + "size", + "1534508" + ] + ] + }, + { + "content": "", + "created_at": 1689733717, + "id": "523c5afe1411641ffe583c81e838fb5760ac86f933e731eeed1608ed6e469f2a", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f7eccf6879cd24054f088a317c65fec271c06b7c688ab03524321f02699a92a64384c343b14b4300eb21d04925dbb954b4d5fd929f7edfb2739964670f6ba276", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_16541715a9bcd12306d4f61bd5e039284128edcaafe3043f.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "f383c5b2f585a6a986f062ffc658afab97d3e12496037b336820c2aaedc21708" + ], + [ + "size", + "6121702" + ] + ] + }, + { + "content": "Reviewing a NIP vs writing a NIP.\nhttp://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d1d268a25e132f26352546760b8f0fb22fc3c932760edd18.webp", + "created_at": 1689733437, + "id": "de7fa61fdd2072ad8d6b9e3c90cf970268de48290d24e67c0692c394eca466bf", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7f88ffb115a2ad74d347a336b85f858ba6daeee05262332bfb9eaa0502ffe8bb5631909a6a16a2ff0a70e513c640aef3c4eed8545448fc534dddae5dd374a026", + "tags": [ + [ + "r", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_d1d268a25e132f26352546760b8f0fb22fc3c932760edd18.webp" + ] + ] + }, + { + "content": "", + "created_at": 1689733322, + "id": "f36e5d3ecbb914f4861ff170b8377d571e6395a02d5d5ac7c17bd99e2df78e3b", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "de6ee5365d0e84255bfef9dca800c94c022ed7569d97069c7ef7d562556d3e9a20a852fa86579931f7370e9f85bcd4d24a64e78e64b1348493acfa6c519311df", + "tags": [ + [ + "url", + "https://nostr.build/i/457757ddef7ccc7c4306282d3442f9cc9a977a58773cee39c1d220055ddd07d8.jpg" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "5fa74a9024ee9cfff829a4750929591f542fff4d45262bee6e22a241a2f77731" + ], + [ + "size", + "189954" + ], + [ + "dim", + "1170x1524" + ], + [ + "blurhash", + "_7ECI9+t-p%L%2~Bofy=Mx0z9sIoNan*%z~CM~E2NHIWX8%c?Z^%?aawsk%2TeJ9%0-:R-E1ae=|xa%2-pM}Nen$TIkoNYxrxYNHfj-owgxbxbxas:bcNFS1NbE2SgsSIW" + ] + ] + }, + { + "content": "Also, this is a tentative go solidify a single API for all service providers. \n\nhttps://github.com/nostr-protocol/nips/pull/547", + "created_at": 1689729262, + "id": "c3b4f7a5fb5b73b48acd49c00097bd48b334c506f7ba60f7e2f784e3ef6f86ad", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6724905af656ff6a3fce1a4e1003c03283b4a9b34ace65915d55431f84edff612ba7bf1a0bd7eaa301fc11372b8ac14bc94d0950d0c793cd3efc7ad372d6aac3", + "tags": [ + [ + "e", + "0fb88d12f3852b003ab2ef21a0d4be2fa5b1471a347fb979d93ae381014b740a", + "", + "reply" + ], + [ + "p", + "0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6" + ], + [ + "p", + "76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/pull/547" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689724634, + "id": "e9c257a0586daf0767e313bb9ba569e432ecf17e3a9a8a7beb81254e29957020", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a66d4cac9ff56f55ff998a665a347485c8e985a6ddbed24dd1405aa3a21291d1ff91ca01b7d2659bf774e5fdf59ad38f17869e1426ed8fe74e53ead866fe25ff", + "tags": [ + [ + "e", + "83a23b4f0584bd4a34c210cbbb5d4dafd03f25b886d0f5f7e5f638139e69d8f7" + ], + [ + "p", + "89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689724333, + "id": "a64c025b82d09bb1fb7d2eeaf810793d880cdd7f3cbb2c4cd30ea13582ca4afa", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e6f16f6dd109d84c54077a705d9e912d1cb2c15fab6b6d12ac434902dffe5dd36cacbb57c760686dd6097c828d93a3ac979b95ef239cb0d42a9041d0cc187234", + "tags": [ + [ + "e", + "473007f975b7e612910adcdb9979e81cc9e3a8a37066c4d03685cc1a8a33928b" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ] + ] + }, + { + "content": "W don't display all notifications. The app only displays it when you are directly cited or when it's a reply to a post you are the author. ", + "created_at": 1689724319, + "id": "c220414cf423280cb3e8f88174a6ee9b8df1b3ede4febe24fbcc1dfdcd0e2208", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9974f8f47a6479bdbf4f2e05bec747a43387a0d9752481ce80a0295c34e96df41b7523ad87ee78e30955f01951ee928ab3fc03a3a586e003932eecd5d8a3c543", + "tags": [ + [ + "e", + "6fd5fce21e31c71d7d27f5cf740f3570b296bde46894eccd5958f234e0e5a820", + "", + "root" + ], + [ + "e", + "bb271e57c3b96c9f7bfbb0e03964c3820d7dc629b46c30899dd11be7af4d4691" + ], + [ + "e", + "497eab572a2cd0716e92e63415b923e7c38f73a1dff0d1cd3216fe1bd51c8787" + ], + [ + "e", + "5e9aa9876a730974ecebef4f52742bd4a9877143146eddc4168424517667307f", + "", + "reply" + ], + [ + "p", + "89d1ce9164f1f172daaa9c784153178cb1dec7912bf55f5dc07e0f1dabe40e6c" + ], + [ + "p", + "180a6d42c7d64f8c3958d9d10dd5a4117eaaacea8e7f980781e9a53136cf5693" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ] + }, + { + "content": "That's because another client is erasing it. We have always saved hashtags in the contact list. It's was never local.", + "created_at": 1689724277, + "id": "eeae5dde847dec45663970e4fc8423ab4c2a372de0badc1730029bcbbaa39f84", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a8be54bde3be1b9170a3df82823822dffd1159a6561a2e2e84619528e212f0bf606c6a2527194506fb0536a598d0681109d63cac9394fd66943bde06392387fe", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "46a660a9a9b87f8bcdc8471a61c53e2111537285a78f28ee62c495b539a7bd36" + ], + [ + "e", + "5057914f8a3c8425d6c2160c130a256c343dcd4943eaeb94c3f9bb83f03c19be" + ], + [ + "e", + "c67fe72ec0895e4e5c698ad669bd7b5e13ca936ad120a1dd574a86fc20e618ec", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "f0fb31d1810a9f95df3d178fcd67ca0b09879ad11e8689e56962cd839fb8ead4" + ] + ] + }, + { + "content": "Nostr devs are like: I am going to spend 2 hours and make a new app.. why not? ", + "created_at": 1689721709, + "id": "dd4375c77daa2335d53318fa39276b0840651e1e77fce1ed61465698e3af3fb1", + "kind": 1311, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6c109ac0f321b61060241790d85acdfb223b6143038aba381f4eed27a20bf175dd44c779981aa0b1d2897e42d0d145fce4557bcb710726252cfba8054c11072a", + "tags": [ + [ + "a", + "30311:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1689719669", + "", + "root" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689721672, + "id": "cbbc2beb927c61a157f748d993d87c52fa0f097b5e468f3aa20b5029f5c08dc7", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9e8106799c6812d6ada4ea8db38ab58d978e33d14347cf6b38335a9594de6dfd95bfeea0a447e016c4689ac2528a01c5e79639643026ba41564260d1f69634c5", + "tags": [ + [ + "e", + "c9911b6d35b4c87c53feae668005604ee0e620274322dfec6570dde6fdd68aa0" + ], + [ + "p", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + ], + [ + "a", + "30311:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1689719669" + ] + ] + }, + { + "content": "Because everything already is. ", + "created_at": 1689721283, + "id": "173122f70b71b0a057ed6bde5094abbda41540d68c5b1cccbbea5fd428578505", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "e82e5064d89be686f02d55ddf6dd7f3ccc206c154db3f18c9a89cdeb9ab399f15e2551f195db839b67c747ca32fd6362a3e6570dd5acdee4486cdffc992cd0c1", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "19aa577d11b0dab38635548fb830f7f3eeb7f4de8c987d2d70d3526e23537c79" + ], + [ + "e", + "f03d390a0416e822a965d83d17ef61c0439979404eb0b0a79080053f64fe16f3", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "8e432ad14d3955d0863b975778f0c8817ef88c9f119d626da1a3face584bda73" + ] + ] + }, + { + "content": "https://github.com/nostr-protocol/nips/pull/673", + "created_at": 1689721195, + "id": "baf9d389c6b1e18a1676efd8904e52270198129c1f61dcc3db97e0c8cc49a000", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "73e5b4f28b8989e3aae1e7c31ddbcc43a392ac945186cb98719c9fb05d93e7bb90a5033b2fea99f5b3b9ab19128089b8f85ca0d0865ffda8daad5c81ade8e4a4", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "46a660a9a9b87f8bcdc8471a61c53e2111537285a78f28ee62c495b539a7bd36" + ], + [ + "e", + "5057914f8a3c8425d6c2160c130a256c343dcd4943eaeb94c3f9bb83f03c19be" + ], + [ + "e", + "f8881207bfdca139717745ced0e86e0ee20d2e73417f21bb813c6ed151e920dc", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/pull/673" + ] + ] + }, + { + "content": "I want to stop doing local settings. Everything should be on Nostr, on relays. ", + "created_at": 1689719485, + "id": "5057914f8a3c8425d6c2160c130a256c343dcd4943eaeb94c3f9bb83f03c19be", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "44a2c6edda25b215cd5187f309d0292bc188607384413bae08625ed6915d73bb95a348119f4a58f991e9bb97a19569c0678eee8599d9339f1e1e21d6d84923e3", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "46a660a9a9b87f8bcdc8471a61c53e2111537285a78f28ee62c495b539a7bd36", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ] + ] + }, + { + "content": "Devs, who is using kind:10000 for Mute List instead of a kind:30000 with d tag=\"mute\"? \n\nWe should focus on just one way to do Mute Lists.", + "created_at": 1689719265, + "id": "bab81750570876585535c8ac4a4d5f983ea4cb0675c94882af25b256d60f5a7e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "646f33f7f4c005d31c73b9687e041e6b49080606a71d789611a5129f9f1025ed6ada39d2ecb31f4f7c8fb50e33f3946ad11aae23d37c0388d964684772f89098", + "tags": [] + }, + { + "content": "Is this a global setup or each list will have their own keywords? ", + "created_at": 1689719025, + "id": "4738396f74638dca62d58a88764ededd2d89748e20f81f4fba574fb1af1a9b40", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "323a7cc7373289a83eca16c3696d37c05d0860539188eec90b931f54f29ae5e1ca7b6b561dffd4d1b65f5038dd0adab5af21063047667def894e72e9d0d35dcd", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "25c76135357f79245f401168ee01aecb748c7434d8f12c3627a02fa41dd2d15f", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ] + ] + }, + { + "content": "I meant on Nostr. Which NIP? ", + "created_at": 1689718940, + "id": "25c76135357f79245f401168ee01aecb748c7434d8f12c3627a02fa41dd2d15f", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a39d29dd625e2af0a636a09fca8047ea6abbeee9258713db413a9f975953ca625448878cd0db49db15bc6aa54e2df67aa8a72ba2b0f2dbdf44229bd4a3e7bdd6", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "root" + ], + [ + "e", + "6e8e7ab0fc769cf059fbc93c40d7007bd8847a80433cd8e68c15be45bf1594bd", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ], + [ + "p", + "d0aa74cd95a651d3d2ab4fb18b58fe71b447d78d40f8dd46f1dbaed5603d35cd" + ] + ] + }, + { + "content": "NIP-53 doesn't have direct invites though. I am very confused about what the invites are doing. Is this like join me now if you can, kind of invite? What happens if they reply yes? ", + "created_at": 1689718906, + "id": "6aae65ba6f84596984069e914b441bb2e29fd031c63a1e3647754c70a49576ad", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "680e70e6d5a2786349fd566cebb3f5678545854e7cb890bfe5ff68c8b374640acc04989e1285b27eb152ca765e938e8b0b39e8d3fb745857fcaf3c125da0ed68", + "tags": [ + [ + "e", + "8a7780984986beef1406caf4139a338349f8d194725eff881157cdc6e69cb983", + "", + "root" + ], + [ + "e", + "573cb50aa1485d3c41ae7324b6c5d469a979bed956417e6bc3d95825c61fe5ff", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "b8beebaac1fea45e8907a52b6d6a57707328276f2f000719de1cbe20b3b9fe80" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1" + ] + ] + }, + { + "content": "nostr:npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc 's Facebook Marketplace NIP was merged! 🚀\n\nhttps://github.com/nostr-protocol/nips/blob/master/99.md\n", + "created_at": 1689715846, + "id": "d7c2b6093d9dfa3f452b871da8b3290a42159db6cd084ff17bcd6c6e25fc2a06", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "90b8a3d9f17ba803b7b2e188a6acad31cda7179dbd9ba6fd13cc9a600d32b9330999d5718c5d34f7823457c2dba964ee46ba7dc34615b4820544d753283ea7d4", + "tags": [ + [ + "p", + "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef" + ] + ] + }, + { + "content": "It has been on my todo-list for ages. I am not sure where to save it thought. ", + "created_at": 1689710487, + "id": "67cf4b517fb8cf8cde6ff822500fcfa7fde11d0677e8096b7f98f38b6c1dcd1e", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a6689c8dab94bf784a18ecaad285c74ef4332cf098eae040c092a1c05054b1479afe5bf066ec76d9eb0f8c86b2274d1688196ed9d7e5c01c68f89b6bd0b5ed84", + "tags": [ + [ + "e", + "2485f76b226cc2489b20509d1042e4e6a7d1e4e1c0b540d1c8da86fc18804937", + "", + "reply" + ], + [ + "p", + "9267545d2917b80f707ffdb44a8ff979182568ef7baa04ee756b1f01d4e3688a" + ] + ] + }, + { + "content": "I would guess they are requiring users to have a 'user' attribute such as a contact list or a user metadata kind set up. ", + "created_at": 1689710282, + "id": "69aa2f252f0d51e76147257b84866c237f4d05b6eeff41c12e22f717906268dc", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "461eb497159cc49ed2e44fe524e7f3658aa4ea561d9f74c13187f4fb2efa9ce4852c717cc057b85d1060044e69de294f5b8d4e174f1935d4d931d96344b07445", + "tags": [ + [ + "e", + "08f4c8cbe5cba4bc0c68863eabf8aa7c6ad22be19398cfd6ae137f541454a9b8", + "", + "root" + ], + [ + "e", + "fb96d54dcf8128f0ddd8994d53b81fff998e80043b45f15db257265df4d5dc23", + "wss://nostr.bitcoiner.social/", + "reply" + ], + [ + "p", + "43a22be283a77d24a3fa218e063e782da195d1adb0edd528b88ac9ba1bebcdfe" + ], + [ + "p", + "8a9402c71384dd0a387d3def483b8f89736fdfe26bc28276671820af942cca7e" + ], + [ + "p", + "532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb" + ] + ] + }, + { + "content": "https://github.com/nostr-protocol/nips/pull/597", + "created_at": 1689699142, + "id": "5571cc3a41f0cc0a36c38711307cefdb30504cf851e1328a2b4f852c29a0fa7d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2ce8c864668bb4c1b63a88727e796a870cb8498b20307a40a735fcc30e2759f93d73a96cddf95d9599316e9fcbc060938f87868a972f00fcc6bd24c2494bf44c", + "tags": [ + [ + "e", + "8a7780984986beef1406caf4139a338349f8d194725eff881157cdc6e69cb983", + "", + "root" + ], + [ + "e", + "3c4c22b112912148bfcb2e65ba02a200cb3363d4be8d1bdd77c5e2bf43b9a065" + ], + [ + "e", + "2c592975b96913fe5a856a67b41dff42e70fdfdb72922c86fdc59f31704e9c0b", + "", + "reply" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68" + ], + [ + "p", + "b8beebaac1fea45e8907a52b6d6a57707328276f2f000719de1cbe20b3b9fe80" + ], + [ + "r", + "https://github.com/nostr-protocol/nips/pull/597" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689699060, + "id": "c43ff35ea35d8d58089e68ed52cf9fd6dfaf5f9e25f475aeb88a807818d58812", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fd8b3a15b4dabbde2dbbce1b1dbf3fa81bbc52c653b4473afee0498e1253379c094086a13d2c0e0a3c074b33a752c8faa22caf8faf0e403e64dd027a07228a4f", + "tags": [ + [ + "e", + "9909236b03e8581c67fa93a0409efa71638bda585943b0a1abad59a6196d3255" + ], + [ + "p", + "7ab1d3867722b4cbabb6c8503ab3f9265daa4f82e228cefe302621f4e5ee1f1c" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689699028, + "id": "20a5feb4699b42f94c0ef86f698d4b39dba306ffaf1a296de086185b21bc008e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "f6856444b7ca2df1d3f8b20791b2068ed670c925f0be05c01444d4cd56a60925a35f0114a26ad54fc9799a4c0ec6390cae58755e56fafd47e923e75a6c84e720", + "tags": [ + [ + "e", + "f1dbc22d6c52e9ab63cdbd7ae563b317f00c6206aa7e378965e432b1928ae36c" + ], + [ + "p", + "3493a605e9c26c31ebe1d86a20f96082c4f584d6fde3cf98b9afc2d783ffc952" + ] + ] + }, + { + "content": "2 possible issues: \n- if you were using private bookmarks, some clients do not support it and might have erased that info. \n- if your relays failed to provide a bookmark record before you decided to add another post into it, the app will start a fresh list for you, erasing what was done before. ", + "created_at": 1689698818, + "id": "f50b94d7b1c87b7c9a022a6ea8815f5ae94aace648f1b8f154606d011e14e3a9", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "6054a616397ebb2331804aad0eefd7763354bc2a3900d33a6325bd23d38f5d53d30c1b6f244c4935e232302f5a542df2f9a3b41b0bc36d091081ae386a3810e9", + "tags": [ + [ + "e", + "a43fdf0ef52cb242995bf8d173fb5a8c4f308cb07dd2a6b627a5816e929b1f23", + "", + "root" + ], + [ + "e", + "9de05c2edd1d5221dc8428cde09467dda4dfb84a0431015b65cf8ceb347a3d7a", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "880b21ccbe2e079c9ac5a5d14314ce5d47adbfe6cd5c00116e75f472d9a3f910" + ] + ] + }, + { + "content": "Groups of people? Use highlighter.com or listr.lol for now", + "created_at": 1689683437, + "id": "51868a97d597d756d01f3bd54d73946783b6cc217faca7ab288cd0b2769146e7", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8bbfe64b5b37a07415f20b90703e7973578ae7213ed8b8eca6b080160ce426d1e6c3abd9c8d5f9440f3cbb9ee6456f6fda0f239c5c01002dfe777108c5b4106e", + "tags": [ + [ + "e", + "b220fa47738212e6935be76b73fb6a6124948113cda700c04dd349641e86c3fa", + "", + "reply" + ], + [ + "p", + "0d97beae567fcec9c6574f1c6ef6126ea969d4992c3198e51c0fac52c5274a14" + ], + [ + "r", + "highlighter.com" + ], + [ + "r", + "listr.lol" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689677043, + "id": "f5b58d047aef44426bd06c0862b0258668ba9ff48f007269c24ee955d9d927e3", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "93f58aaf7438b0e630fdbcbfce5df066a2ab8a7fee3351a10fce01b9b100fc48f03d9111337491fe917a96ee677e76930d59e42828c6598244be0a990d6cc10b", + "tags": [ + [ + "e", + "8c1360284291b9602f1ed196fc221b7b4349f8be31795f7161c931fbc4c0b264" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "170,000 lines of code for a video player alone is the answer 😁😅", + "created_at": 1689676994, + "id": "8c05e5903746a676c53f5951af60b204aa74f7ac034bf0f042624c34d4fba0d3", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ffa6d381a1f5a5b578bf031feb5421ab2c5c9922b13b53a4a28a78e36ec022647ea8584431fdeb1b50872284226b9e1875ff566920096153e2771871d64fa1be", + "tags": [ + [ + "e", + "b3bcf12483811024e77cb1575b4345b88549fe0ee6a26df942c16547b34d7f36", + "", + "root" + ], + [ + "e", + "edf021fc9290fc7f7fe3bcd5c33904536a1710b6a525a33e696723fffb887627" + ], + [ + "e", + "2dce8555ed1824e129910aa57301771a0c1aba3d0f6b5529efcecd0a3f031e2a" + ], + [ + "e", + "8b4b4b56bb850a32efe4f45e68fd84d0ae2d5183062f507c6c68aecbaab29d28", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b8ef68a3073dae2a78a02e1d5921013bffbc82aa5039f77b6f7434512cc65de7" + ] + ] + }, + { + "content": "The relay architecture is terrible for curation. That's why relay-centric browsing is broken. ", + "created_at": 1689676644, + "id": "96f7ebeec77ef2945527c777c9eb0143cdee0a634461a0b52131396c0635650d", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7b75d01573bfee59f38469f410ec13f24155d87f913b26a88824af57f5f8b4b6fc87202558d167facc818a73f9286644075fd9474b84ea78382322085d23b04d", + "tags": [ + [ + "e", + "0133b5466212bcf7200ac43448cd44c996d09ec89eec785061637987f9e423de", + "", + "reply" + ], + [ + "p", + "1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411" + ] + ] + }, + { + "content": "No idea", + "created_at": 1689676119, + "id": "2dce8555ed1824e129910aa57301771a0c1aba3d0f6b5529efcecd0a3f031e2a", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1af2fb3bc6acc1a56ba42a6c0a6db9c4921ac399552de26d03128c69bd7e99b22b769cc2e309c95f7553282715dd2c8640fed3f04f160a1fb68feef83d3c3cbc", + "tags": [ + [ + "e", + "b3bcf12483811024e77cb1575b4345b88549fe0ee6a26df942c16547b34d7f36", + "", + "root" + ], + [ + "e", + "edf021fc9290fc7f7fe3bcd5c33904536a1710b6a525a33e696723fffb887627", + "", + "reply" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "b8ef68a3073dae2a78a02e1d5921013bffbc82aa5039f77b6f7434512cc65de7" + ] + ] + }, + { + "content": "{\"content\":\"Introducing Minibits Wallet for Android – a new Cashu mobile wallet with focus on performance and usability! \\n\\nMinibits aims to make ecash UX less technical.\\n\\nSign up for testing at https://minibits.cash or get one of the alpha releases on the GitHub repository! \\n\\nThis is still at a research stage, read the warnings before use. Please provide feedback and report bugs!\\n\\nhttps://github.com/minibits-cash/minibits_wallet\\nhttps://void.cat/d/SzRmh2jNzHKgKvKQb7bETV.webp\",\"created_at\":1689675049,\"id\":\"eff1e425d2790ec703bacb0373b067499edf24174b845ac1fb9172987552945b\",\"kind\":1,\"pubkey\":\"50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63\",\"sig\":\"1d868c7ec460c110b6861dadfcc6506e3edf9887bd1c5d3e40f35acbed10b9c6f6ca868c69cd6c9582665ffc05c73bd70905a92e26143c77f1a7ef121ccbfb1e\",\"tags\":[]}", + "created_at": 1689676096, + "id": "c2c56a4f0afb483d2e6d9bcacf5662a8b3de06db891a30c1da2f6e91114aa256", + "kind": 6, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "2da6d302cc4d8a0072573dc1ab4919d0e79c747b45f52b41f67e001a3e865aab0ada32348324adf490ef86e541b3497857d44eb8c74c2ff8212e8e3299b84438", + "tags": [ + [ + "e", + "eff1e425d2790ec703bacb0373b067499edf24174b845ac1fb9172987552945b" + ], + [ + "p", + "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63" + ] + ] + }, + { + "content": "Possible? Sure. I am not sure if it's desirable. No one likes these translations services that require all your messages to get sent to a server. It would be a lot better if they created a local model to translate in-device like the Play version does. ", + "created_at": 1689676003, + "id": "855a4de95fc1bce83a4da7c28fe823365162a1413503cfbca9cbe322269b07ba", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "16566000229b6a4cb882f5337d0f6ac5513f80345c72f165739f6e058bdec9e9a34d24755f85111ae76f6e5b71c65b4adc669314ed8d1ac7d3af7868eafc6e66", + "tags": [ + [ + "e", + "305284085aa2e647f5f8ac0da34a3898674c10087c4d7a9653d5292d97585a4c", + "", + "root" + ], + [ + "e", + "4d1ce28ed36892e1c3892c05c36a49fadc00a628f194c99e88dd4f2d54e89024" + ], + [ + "e", + "f7f0a6cd0833758383e74399a6f92cef9cc0ffab1243562125d27a283a369b22" + ], + [ + "e", + "996566ec7df1d641e8842673c0b798626e31b8a60c6045e83d07e8c48d4ef948", + "", + "reply" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba31f050a66d6aee73fb258405a44fcffa662e24784e6650944f8fc2d3089427" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689675731, + "id": "808dd71e183d66fbb206ba02bd8f7167e3192851091e70daa4fb691c69a7d4d6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "94b58286c108e74b1c3428d7a1c6453c2f27e76a34400af9dcc4afd5f8af863d066e4e6bad885499cfd72c6fc6f10f19bbbb683e8ab4caf8cd2d9dd3524509a1", + "tags": [ + [ + "e", + "f75a1684c2982ce450bbfd1c6e6eeee3aac0d9c57e3a542e2d038f28e54fd2da" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689675726, + "id": "7bd385d259bbf9d331eb4e1ca9ca78ef0d7ea1bb43d44daf2e2029d4c575014e", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d5801e6e34971ef41ae34dd1e714823ce8096dcc84f88ad566fd4abf9ffb5eeca82ef8152a88945cb7ec3ba9ffe50a7e21595b2d04daa6b88b586a3bdb790b6f", + "tags": [ + [ + "e", + "536959c20e829ddd2516afa9774c22c332cdb335770d363201f547b9918e1cf7" + ], + [ + "p", + "18affadacb471657941485d8e3053e2be0daa95aa42f9c6f8680d62c758a9ab3" + ] + ] + }, + { + "content": "GM, freaks. \n\nnostr:nevent1qqsy9u6jkzy4pa4ktcmrnhwa33uqqkyk6yugjgrkqcrefwlsewxly2cpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygytpg474ahta7f9arnclrc2mfql0zvt3knj4zt3hzvc30mc2lfknupsgqqqqqqss5qlwl", + "created_at": 1689675330, + "id": "a6c102acb21bc9229f23722b4f1d55232c91901b1710be8a4a7c71c9ea128c16", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "a6444849124e0bd3479620c2102c1d6defb81852ef3c2118296452b5370901591b02102f5c148005ac8b69ba4fd86d106cc8673306c3a89150a09ca8b4feb790", + "tags": [ + [ + "e", + "42f352b08950f6b65e3639dddd8c78005896d138892076060794bbf0cb8df22b", + "", + "mention" + ], + [ + "p", + "8b0a2beaf6ebef925e8e78f8f0ada41f7898b8da72a8971b89988bf7857d369f", + "", + "mention" + ] + ] + }, + { + "content": "No, only on the Play version ", + "created_at": 1689674872, + "id": "f7f0a6cd0833758383e74399a6f92cef9cc0ffab1243562125d27a283a369b22", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "1ba2852e22e55b216ab87c5e2e2b3edc62accf811b033a51d2e4ce79bd015e517d909785db81220d9825c88e976e47498053c9d2995d26a09c9d3e82e6e0a944", + "tags": [ + [ + "e", + "305284085aa2e647f5f8ac0da34a3898674c10087c4d7a9653d5292d97585a4c", + "", + "root" + ], + [ + "e", + "4d1ce28ed36892e1c3892c05c36a49fadc00a628f194c99e88dd4f2d54e89024", + "", + "reply" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ], + [ + "p", + "ba31f050a66d6aee73fb258405a44fcffa662e24784e6650944f8fc2d3089427" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674801, + "id": "b79a18aecbb705999848e430aa48ca8e4a02f205baf0abf1893ad78be5bd097a", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "8ff23133abaea20df1a6bb81e4d0c17184b10ef4a61c1d9420409b05a155b1765aba40ed409dafca1f1404a70d13f47b8c17b7192c361dd3552f80c3fb40117c", + "tags": [ + [ + "e", + "70526f1c71a579e9a7d0ca60f0c32ddcaa2fb0588e5ccc854ffd5eb706913c1c" + ], + [ + "p", + "d651b0cc52fe150819266fd62efb79e3f859161b33fc054ba98e43537e4a9601" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674786, + "id": "9e44d17fb36c23f8122dcd1a052db0df8b77e67524efab0500b3bfee490fb150", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "52ea3aad9c774be769bd6129aaadba7cff8cf06a573887d2c64f6f4c1c04c7ffa54e2d2dfa60a2323d06cd57b9034a2db1991fbbaad331b8981542ef739ca8a6", + "tags": [ + [ + "e", + "88377d4a0fb02c73c8c9a9ec0c90d29ebe80c30e7e0dd357bd9fdc34719b467f" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674730, + "id": "9cdd27028fa23568917ccee7fe7f1de15b43504e006515f7a0cb124075263b18", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b6261ca964542f01cb714f5089147e316cb30ae016bfa76462840f41c645f6f8e115998b4221a33ee78c025c78f0a3b3dc2961713640e561dce76e7bcea388c4", + "tags": [ + [ + "e", + "f637d77cf787a9f772b7926f9752fa5c2eed3e21e10c41f2e8ca0f7addf08821" + ], + [ + "p", + "b2833792cd5c95662538e620fed371728e90671b9d6bdefebe8f706c1f1a04da" + ] + ] + }, + { + "content": "🚀", + "created_at": 1689674506, + "id": "6714667912c344a403c50998ee0c3dcdc66b03174625525a838acf4ba99610e8", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0394f188cb33d3c177e384a639deb8992cc8d0fb94600b9176439b7e02ca4dd77f6e1e706bb18e03d542a2b02db6309c2f09bcce194de2342c1108196134ae88", + "tags": [ + [ + "e", + "2744bfaea2c88d8e1337f635f0e57e14d4ee44d69c67e630c9d15f1eeb86c822" + ], + [ + "p", + "98d3b91eb93faad55a2a43ee21d71262f793f4de3db9a5d7710970ba8a251881" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674497, + "id": "d9af4d5f22a2989278ab2a5e8eb4310bfc8cf3ebb2331d5df74dadedd1beddcb", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "989b263e6f46c5b55cd56a78d3c38b6fb7d66033826d89b61696965e6ac584f7a487ea294612ca0ec256e64c22790e1c7b47e75ad1e6651c54bbe9122eea21e9", + "tags": [ + [ + "e", + "cdc0cd6cdf63c223ba85ddd0612ee3aa6b8a6e222d901c6389e54a9af7d72eb3" + ], + [ + "p", + "7635328aeba6447da86a1b6cd0608232718c045454861fc9e98f432eed66e812" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674432, + "id": "3069edc36af6612cf734f943ae1fde2dda8bfd6c9ae90e92655fd4f6502280c6", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "0503f95c23a2aa99d0bc3bae631767ea08d64d161ec2a4609c6a0cc3f27b10b2c663442095458e9c59ad90942cf841f37018a33b44dbfc089a85db2397cf5996", + "tags": [ + [ + "e", + "c576696bb51e39aa44e3ca8ecc089ac760f5771961b5d689870f355876c0a12c" + ], + [ + "p", + "31da8e96a0d372f657280a3b678c5c8398b053d0891d458b7c8b0a752737a9e0" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674426, + "id": "0bff08abd0c2d42180c7ab81b4605c46dd425d9a6aaa46796bae9e52b196d929", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "3df849993210969fe921274e42bc85dc7401993095bb6ddaa4441f85c08b29220e9851f5cb65c023f5163d55b3c142ec15a0b5464b68f618a73ac9e9fdb6804c", + "tags": [ + [ + "e", + "641ddd94ba362703b7d62819b0188d548837740eb7448a05390da2829c7ba11d" + ], + [ + "p", + "180a6d42c7d64f8c3958d9d10dd5a4117eaaacea8e7f980781e9a53136cf5693" + ] + ] + }, + { + "content": "🤙", + "created_at": 1689674423, + "id": "0daf05f8c55e150201937e621cdec9405b9e81f2305165ed36a5558321a581ad", + "kind": 7, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "ef61781ea0683789dadbe55c8448069ad3b95dd7fce6b174b91e0461d29a2d8c83dbc26e17d0ab686e389cf6850d5dc696a344da374861d84a66cf33a29fff2f", + "tags": [ + [ + "e", + "31ccd562c205061d6052946e7125a48c24a371a9459403d3b8fbb58c4c2d52bb" + ], + [ + "p", + "1b9d72d38b422a09cefed126e88f82361d4b1cc11cd19a2fc04e17b00b0c7d15" + ] + ] + }, + { + "content": "", + "created_at": 1689671952, + "id": "29f243eded2b46d7e96facb9bdd51e1a01a759ff5bf80f816e22c34b8fc6d89a", + "kind": 1984, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "082154083f66095ec70b8ae15ebd113e6ee57011b5e1c2d3adaad4fcc9612859e173c364eaf7ba2167387669555497b567b21ddc355cc4105f03f7ed67a0bcf5", + "tags": [ + [ + "p", + "5c070d057298297ff0dd72779a4126efd30c743907371cc9e44bb54861dc3fc9", + "nudity" + ] + ] + }, + { + "content": "", + "created_at": 1689647540, + "id": "01f9c880cff98ef2bbc3348f904be5817bc00a64ee4afb90ed357e3232ff75eb", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "df6eff31de9fb678dee844ce1334802e4578b049b7e4f3a5abf187303ab7acacd0de63f9e6c8ed5cd70a42b43a30c29aeee7fda28377b50677c702e58e156fa1", + "tags": [ + [ + "url", + "https://nostr.build/av/01b69af390dac66fab819c17d93de7b2c9e070f13f444d3a1a4db6cb785222d9.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "01b69af390dac66fab819c17d93de7b2c9e070f13f444d3a1a4db6cb785222d9" + ], + [ + "size", + "1099399" + ] + ] + }, + { + "content": "", + "created_at": 1689647463, + "id": "846a0bd2419f5faf68587a544dd8ad1302675f28c3643938e51377b855581739", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9dd403c8f62393c03a3194cfb25ff444197ee2fc1de46e0c0c8664f65a939846add90d768e6bc0380147d39b44e5600f68cd7102910bd96313df10ccec3b4f13", + "tags": [ + [ + "url", + "https://nostr.build/av/a921fefb2485ca7a2f93121b6eef1f868133eff1931a99e3c847bec96bb45df0.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "a921fefb2485ca7a2f93121b6eef1f868133eff1931a99e3c847bec96bb45df0" + ], + [ + "size", + "189820" + ] + ] + }, + { + "content": "", + "created_at": 1689647421, + "id": "e2a3d9b37e0d6d98bbd8b36a26e8825aa67a5328ebf745dd9f7239fea69d97f5", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "7502e7527cf42b647dfc936e399f44e8ca97943cf659b4d98ce541328b90520e28084fd62e13044478845de9b1f45af1b1c813e434dbaa692004f7f3a9cc1196", + "tags": [ + [ + "url", + "https://nostr.build/av/c32f2572a8f23053590a69cbdfa0cb1463a9c6d874dfbc745c2929d69da62ddf.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c32f2572a8f23053590a69cbdfa0cb1463a9c6d874dfbc745c2929d69da62ddf" + ], + [ + "size", + "999370" + ] + ] + }, + { + "content": "", + "created_at": 1689646963, + "id": "3c7e59dcf0b5b682d1983306c92e6173b1247fca0419d837cf912933f670a148", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "129cdc94b7cfc8b034731c0c93fb28cad71f96c489358ea63be5431ebbd0d5b78f813ea9430dfffa46e848ae2f524289d149951c9ad47188200d8da54f759aca", + "tags": [ + [ + "e", + "ad8095ae59d8202718df0cea762210513957e56ca13896b19793f167c2252494" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "27685303c3efe890e7995959baa3c9c3fd46146801a61fec5039100dba0f659f" + ], + [ + "size", + "99764" + ], + [ + "dim", + "700x700" + ], + [ + "blurhash", + "U8RC[6-;~q-;-;j[WBWB-;ayWBay-:WBj[ay" + ] + ] + }, + { + "content": "One of our most important features. \n\nnostr:nevent1qqsqkc2nessaxmj3ak7jcvx8c3wa9h0xd34m6zctt4gg5cflyaa3afsprdmhxue69uhhyetvv9ujummjv9hxwetsd9kxctnyv4mz7q3qkmsnnxw6p8vlk2l2dh0443jkj0txl7jrww8qpx4vd428he0a8jqqxpqqqqqqz344mdr", + "created_at": 1689646713, + "id": "305284085aa2e647f5f8ac0da34a3898674c10087c4d7a9653d5292d97585a4c", + "kind": 1, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "cb0c8d9d2a800b445fd0091ac8e0d90eec909c626447b54357a66db89a4e68a2786e81fa69afe0b3c6ef1d32b42134d8acc3d0c3dc3b4aa4554931dbf2d4fcd8", + "tags": [ + [ + "e", + "0b6153cc21d36e51edbd2c30c7c45dd2dde66c6bbd0b0b5d508a613f277b1ea6", + "", + "mention" + ], + [ + "p", + "b6e13999da09d9fb2bea6ddf5ac65693d66ffa43738e009aac6d547be5fd3c80", + "", + "mention" + ] + ] + }, + { + "content": "", + "created_at": 1689645973, + "id": "fcfbcc09a29bccfacc30648f2896e98a743fe53b9575c2b74e22ef27132b418f", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "9afb7d2d297b056c6ee3ec07640158d89b736e98711caf65d9af0c102d5edae9f967d9138e9318c1ab5e26640527b0826bee942c631990e41cb303401317e440", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_037bcb8edf70a9558ac91aa3fe682d52629fc5d104a560a4.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "c213bc58d4976a10ad979803510c0354b35bbb4388c127513179862f513bc49c" + ], + [ + "size", + "634647" + ] + ] + }, + { + "content": "", + "created_at": 1689645460, + "id": "fe0e0f2ee6955b49c6b669e7a837a89c8f62bacb3c965659acfa2cc62a1aa432", + "kind": 1065, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "11225b4d9c32b97ab6be603f424304e022c92b1ecc8fc2398b93f8e1e1b407c7fc43fa02662d1d992dfff73801df36aca8c0f855bded7e409df6306acfdb6865", + "tags": [ + [ + "e", + "763298a22775bf8dcc7bd53a680c013772ca89684ebd7bcefc7893ccfae55418" + ], + [ + "m", + "image/jpeg" + ], + [ + "x", + "aa0c52d9b8a1ea1d8f13a8a7ce5347d54ae9498d4596720cc9a6a2d107a384ef" + ], + [ + "size", + "19585" + ], + [ + "dim", + "700x439" + ], + [ + "blurhash", + "rcQJ$X=]$~M}xXNHj@NHt59dt6%1s:fPRka}WXoexsRlj[t6WDfkazoeWC%0t6RlNHoes:oeayj[~9IqIpocRlt6fRt5R+^hIWM|ocRkoea|s.R+" + ] + ] + }, + { + "content": "", + "created_at": 1689644892, + "id": "cf1c11434611087cf4067cecac3d3cbf3a4c9cb3ab7d0d2ecda568f09922446c", + "kind": 1063, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "d9fe14a564778961e829343e5532af5d02ee22c74229ccb162252a1f22819d13ebf3c71911acbc9dba42a1cba53d83c4bf5cb7b47967ed20fb1db0b988b4a314", + "tags": [ + [ + "url", + "http://nostrcheck.me/media/vitorpamplona/nostrcheck.me_23d68bdbf34b80739e049afca249e8b1657865d97add8530.mp4" + ], + [ + "m", + "video/mp4" + ], + [ + "x", + "aabd46855472a53dbf4b53fe1a635e21866846a13f4dc3f64db1ea9f753994fb" + ], + [ + "size", + "1839932" + ] + ] + }, + { + "content": "FdG8mzOV58zqAkp7Qxv0WLTmWFvQloax9jYy974bV40=?iv=d4w5JdfkhAMEl41xUDZx7w==", + "created_at": 1689637676, + "id": "663d4a5985e3cad8f67eb65ad0e00d173caf765175cb55603694bb423a4cdfe2", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "fbc5da1ce36dcd903874429230297028ae7864dadf0c709a1308a837e14e8a79663646189f9a6448d7d27af318fcc2b7fdd51d252240935cdcdff301a179570d", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "o2Mg9Aje0jyl7nuUseRxsg==?iv=to7+pWNFngqM+mwIPy5AdQ==", + "created_at": 1689637614, + "id": "db71590c17eecf3119d78cbb5859dc3406be14854f65cdbb8bdccaa3d2a5df0f", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b19739ccf233092f79aa739deb09cbce4c3657f709692c9c8ab95f354dcfbace3f6e3e6aec440902b6bb1aec0f77dbb9b39733159ebc9feee3999b588488e91c", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "3uVnxDkGt4xxFs7zdSPpdxDR33Q2FJWqsnPPu4IDoP0thesChUq4DsYBidQ9iIh7?iv=KnNpoML3YnkaGI/DnD6CxQ==", + "created_at": 1689637180, + "id": "28f3ddb0d16d4a752c73dc7531c2d221d3689035e8695fdde111cd122edf5831", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "c1b26f20a66f2cf91caa9159fabf9ee17f1b98d98a250e6c67971763e38690ef013b1a81eceee504e85e99b8ea9747e89a35fa3a7207daef51a0d768df8f9458", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "Cwn68+FXCmX4W6qSl1eNiTbmn1vTeAHF25GpMBMid5Mg9x1BazZvpbT/k3/TgHS8W502zcYqCeyVhVPfPU87fQ==?iv=5/HJRrixXfJtO7/n8bDYZw==", + "created_at": 1689637166, + "id": "fa1b6d36c3194a2d7779f9d52c00f534fb0f389955da59edd1f5f9bac99c97d0", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "b48844f9754c36ab4e9469fcd70350a165ab631c740a2f285f62790a1cda8804f2b87da09c3ff9df755234a8bf3e12aae60c0b0461057680a3620531e499171e", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + }, + { + "content": "+q5Stn5eAtTfm/ULw2SkApBV1gqztggFhMwa0OjCBx3syNt/asVFpoPqxguGd9QQErTTDIFDoHAiCX5vEIh6YQ==?iv=Cj00Q8hQLpbHfVh1hQb1oA==", + "created_at": 1689637117, + "id": "30d057504b23277b8b9d8654e46f2a66a3adcbd194706c9c37ce4864763b3d74", + "kind": 4, + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "sig": "dcf3aec8217171253ab614be7cffc239f5767a0d9cd6bd396af6665016c7ed77263f446e1c8f5e10ed60cc81bc78c8ff4b908a2908df5a90ed210bd69784469a", + "tags": [ + [ + "p", + "8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c" + ] + ] + } +] \ No newline at end of file diff --git a/quartz/src/commonTest/resources/nostr_vitor_startup_data.json b/quartz/src/commonTest/resources/nostr_vitor_startup_data.json new file mode 100644 index 0000000000..4d1fc04e5b Binary files /dev/null and b/quartz/src/commonTest/resources/nostr_vitor_startup_data.json differ diff --git a/quartz/src/iosTest/resources/ovxxk2vz.jpg b/quartz/src/commonTest/resources/ovxxk2vz.jpg similarity index 100% rename from quartz/src/iosTest/resources/ovxxk2vz.jpg rename to quartz/src/commonTest/resources/ovxxk2vz.jpg diff --git a/quartz/src/iosTest/resources/relayDB.txt b/quartz/src/commonTest/resources/relayDB.txt similarity index 100% rename from quartz/src/iosTest/resources/relayDB.txt rename to quartz/src/commonTest/resources/relayDB.txt diff --git a/quartz/src/iosTest/resources/trouble_video b/quartz/src/commonTest/resources/trouble_video similarity index 100% rename from quartz/src/iosTest/resources/trouble_video rename to quartz/src/commonTest/resources/trouble_video diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt index 05e0bbbc0b..22ab71c948 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt @@ -65,6 +65,13 @@ actual object OptimizedJsonMapper { throw IllegalArgumentException(e.message, e) } + actual fun fromJsonToEventList(json: String): List = + try { + KotlinSerializationMapper.fromJsonToEventList(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } + actual fun fromJsonToRumor(json: String): Rumor = try { KotlinSerializationMapper.fromJsonToRumor(json) diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt index 24676f8126..708f6f8153 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt @@ -73,9 +73,14 @@ actual object GZip { val written = input.usePinned { pinIn -> output.usePinned { pinOut -> - stream.next_in = pinIn.addressOf(0).reinterpret() + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } stream.avail_in = input.size.toUInt() - stream.next_out = pinOut.addressOf(0).reinterpret() + + if (output.isNotEmpty()) { + stream.next_out = pinOut.addressOf(0).reinterpret() + } stream.avail_out = maxSize.toUInt() deflate(stream.ptr, Z_FINISH) @@ -97,6 +102,8 @@ actual object GZip { * Output is collected in fixed-size chunks to handle arbitrary output size. */ actual fun decompress(content: ByteArray): String { + if (content.isEmpty()) return "" + val chunks = ArrayList() val chunkSize = maxOf(content.size * 4, 4096) @@ -108,7 +115,9 @@ actual object GZip { .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } content.usePinned { pinIn -> - stream.next_in = pinIn.addressOf(0).reinterpret() + if (content.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } stream.avail_in = content.size.toUInt() var status: Int = Z_OK diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt deleted file mode 100644 index c782de2f9c..0000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt +++ /dev/null @@ -1,55 +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 kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSURLComponents -import swiftbridge.Rfc3986UriBridge - -@OptIn(ExperimentalForeignApi::class) -actual object Rfc3986 { - private val rfc3986UriBridge = Rfc3986UriBridge() - - actual fun normalize(uri: String): String = - rfc3986UriBridge - .normalizeUrlWithUrl(uri, null) - ?.let { if (it.last() == '/') it else "$it/" } ?: throw Exception("Could not normalize URI: $uri") - - actual fun isValidUrl(url: String): Boolean = rfc3986UriBridge.isUrlValidWithUrl(url) - - actual fun normalizeAndRemoveFragment(url: String): String = - NSURLComponents(url) - .toStringNoFragment() - .internIfPossible() - - actual fun host(url: String): String = rfc3986UriBridge.hostFromUriWithUrl(url, null) ?: throw Exception("Could not retrieve host from URL.") -} - -fun NSURLComponents.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (host != null) sb.append("//").append(host.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt index 05c06c5478..ba0890c38f 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt @@ -26,32 +26,32 @@ import platform.Foundation.NSURLQueryItem actual class UriParser actual constructor( uri: String, ) { - private val nsUrlComponents: NSURLComponents? = NSURLComponents(string = uri) + private val nsUrlComponents: NSURLComponents = NSURLComponents(string = uri) - actual fun scheme(): String? = nsUrlComponents?.scheme + actual fun scheme(): String? = nsUrlComponents.scheme - actual fun host(): String? = nsUrlComponents?.host + actual fun host(): String? = nsUrlComponents.host actual fun port(): Int? { // The NSNumber?.intValue is a way to handle a nullable port and convert it // from a platform-specific number type to a Kotlin Int. - return nsUrlComponents?.port?.intValue + return nsUrlComponents.port?.intValue } - actual fun path(): String? = nsUrlComponents?.path + actual fun path(): String? = nsUrlComponents.path actual fun queryParameterNames(): Set { - val queryItems = nsUrlComponents?.queryItems ?: return emptySet() + val queryItems = nsUrlComponents.queryItems ?: return emptySet() return queryItems.mapNotNull { (it as? NSURLQueryItem)?.name }.toSet() } actual fun getQueryParameter(param: String): String? { - val queryItems = nsUrlComponents?.queryItems ?: return null + val queryItems = nsUrlComponents.queryItems ?: return null return (queryItems.firstOrNull { (it as? NSURLQueryItem)?.name == param } as? NSURLQueryItem)?.value } val fragments: Map by lazy { - nsUrlComponents?.fragment()?.ifBlank { null }?.let { keyValuePair -> + nsUrlComponents.fragment()?.ifBlank { null }?.let { keyValuePair -> keyValuePair.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt index 00c7bda278..3b8338cd3c 100644 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz +import com.vitorpamplona.quartz.utils.GZip import dev.whyoleg.cryptography.CryptographyProviderApi import dev.whyoleg.cryptography.providers.base.toByteArray import kotlinx.cinterop.ExperimentalForeignApi @@ -33,6 +34,11 @@ import platform.Foundation.stringWithContentsOfFile import platform.posix.getenv actual class TestResourceLoader { + actual fun loadDecompressString(file: String): String { + val data = loadFileData(file) + return GZip.decompress(data) + } + @OptIn(ExperimentalForeignApi::class) actual fun loadString(file: String): String { val resourceDir = getenv("TEST_RESOURCES_ROOT")?.toKString() diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt new file mode 100644 index 0000000000..f5042e2d1b --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip19Bech32 + +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NIP19EmbedTests { + @Test + fun testEmbedKind1Event() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val textNote = + signer.sign( + TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), + ) + + assertNotNull(textNote) + + val bech32 = NEmbed.create(textNote) + + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(textNote.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundleEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundle2EmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testTimsNembed() { + val uri = "nembed1r79ssq9446hkwqhl642ukmku8qg0c92pu7w3j0jyfte8tc7tvg85vmrys8x3sqgle5vjy7jpjswqhphl0kd6yf4sz0n3peyjq5rp3zkat4w6c6j3f7um0724jmfu5456xxgg2yxkn8dp23j64xsn9npcggzafyh2effyntqrqxzja8dp52kpcvc9zqxlj86e8mx05vevzxkeprjkfs4wmppxm3p96vj6yvu2mqgf5l4v99492r2qsggquxuv93uzx244652h2kkj8xseg9xkq0afpygknjtty9j4ju5v0nm9mezux9wyl6s5wr7lzce7cj397mnu0u04ha7aq3w7exelrhe3zs3l3urwa9sp36u80npllrs0hmsxqdn0fsuyav3nv0azjs5suzuurg2uymncjxez8p9xksc2j6gw992enjflgrdd7n5uq2xrpvfrd3rckw624ey0elvm6grr27tyzlf4vaswgm5vc3hdyczsl983g2j8e67r6z5zt30lat84ma4wclkwwxxrcflvdsuwd7346h7zqav4vdwe3gkt9lr87sfk4aqd2aey03tt4eyspldrqcmkx9pqe2pn63rv7grwwalr86akuldnvjm6m87wrw9sdwns8wq0rnsmj57vqwtc3g7hkwum3vl2dda78dwkycgfzw6qna3ufhpatcvq5a4hm4ehl45an8umwt0clf7rn77ctke475qglwu86hhfwhn7dkca4pkfpyc4y75rll6nvr5qc8nlhf8mk22celn5mecvyuzxd830drhdck9tcdpcafymk8wajwu2w8ha8gatggjfvq0a4jlf2sdamzj0ysqks9dk8me3q7a0qpmf6vykurkrcls4pug3u4pn4u26ezx3h8e482n07x2nsmu80dpufxqc0ttcyzhnppguxma4d8aumdawnlsyy7yzcuxl7lw5y9p4nv5h8fn6u8anpm2tsze3p6mgxy9j9uuqfxg2jvlmtjpakna5m4hln0msmw804hnun96h66fh62270yhhljnmmdl7jln07ll5vft7e870hemcld34a09n943ed6629fgtctsftma9q6tf4jfm2p0ukd2j2n2dpz53fqrkk4ctdcy2j5jar095g5jntf6u807ggkzauzt6uqkwk4tg5w7w55kskspc9663zx5dzzzfwpg3q546g2ve4kukr70n0a46eyce2crsqqq247ql5" + + val decodedNote = (Nip19Parser.uriToRoute(uri)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(timsPrescription, decodedNote.toJson()) + } + + val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}" + val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + + val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + val timsPrescription = "{\"id\":\"18d8b22e6455dfc9f4c6d6be8c2cf015e961b8d160dfe5e4b7fc1578f2c4e0be\",\"pubkey\":\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\",\"created_at\":1739566773,\"kind\":82,\"tags\":[[\"p\",\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\"]],\"content\":\"{\\\"resourceType\\\": \\\"VisionPrescription\\\", \\\"id\\\": \\\"eyeglass-prescription-001\\\", \\\"status\\\": \\\"active\\\", \\\"created\\\": \\\"2025-02-14T10:00:00Z\\\", \\\"patient\\\": {\\\"reference\\\": \\\"Patient/12345\\\", \\\"display\\\": \\\"John Doe\\\"}, \\\"encounter\\\": {\\\"reference\\\": \\\"Encounter/67890\\\"}, \\\"dateWritten\\\": \\\"2025-02-10T15:00:00Z\\\", \\\"prescriber\\\": {\\\"reference\\\": \\\"Practitioner/56789\\\", \\\"display\\\": \\\"Dr. Emily Smith\\\"}, \\\"lensSpecification\\\": [{\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"right\\\", \\\"sphere\\\": -2.5, \\\"cylinder\\\": -1.0, \\\"axis\\\": 180, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"up\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Right eye prescription for near-sightedness with astigmatism.\\\"}]}, {\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"left\\\", \\\"sphere\\\": -3.0, \\\"cylinder\\\": -0.75, \\\"axis\\\": 160, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"down\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Left eye prescription for near-sightedness with astigmatism.\\\"}]}]}\",\"sig\":\"d22d3b86aea397094de8b6cdf69decdfd886c90008aeebf95fd43a2770b37d486b313bff5bdb44b78e33bbaa3f336d74ee8b36bc5b16050374054246c72d93c2\"}" +} diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 0000000000..90117ac43a --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt index c011e785ee..c0cee1c9d4 100644 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt @@ -26,15 +26,15 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.fail -public class NIP49Test { +class NIP49Test { companion object { - val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + const val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" - val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" - val TEST_CASE_PASSWORD = "nostr" + const val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + const val TEST_CASE_PASSWORD = "nostr" val MAIN_TEST_CASES = - listOf( + listOf( Nip49TestCase(".ksjabdk.aselqwe", "14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a", 1, 0x00), Nip49TestCase("skjdaklrnçurbç l", "f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab", 2, 0x01), Nip49TestCase("777z7z7z7z7z7z7z", "11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944", 3, 0x02), @@ -74,7 +74,7 @@ public class NIP49Test { @Test fun encryptDecryptTestCase() { val encrypted = nip49.encrypt(TEST_CASE_EXPECTED, TEST_CASE_PASSWORD, 16, 0) - val decrypted = nip49.decrypt(encrypted!!, TEST_CASE_PASSWORD) + val decrypted = nip49.decrypt(encrypted, TEST_CASE_PASSWORD) assertEquals(TEST_CASE_EXPECTED, decrypted) } @@ -86,7 +86,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, it.password) + val decrypted = nip49.decrypt(encrypted, it.password) assertEquals(it.secretKey, decrypted) } @@ -105,7 +105,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, samePassword2) + val decrypted = nip49.decrypt(encrypted, samePassword2) assertEquals(TEST_CASE_EXPECTED, decrypted) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt index 5a501c070b..89a3287d63 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt @@ -72,6 +72,13 @@ actual object OptimizedJsonMapper { throw IllegalArgumentException(e.message, e) } + actual fun fromJsonToEventList(json: String): List = + try { + JacksonMapper.fromJsonToEventList(json) + } catch (e: com.fasterxml.jackson.core.JsonParseException) { + throw IllegalArgumentException(e.message, e) + } + actual fun toJson(tags: Array>): String = JacksonMapper.toJson(tags) actual inline fun fromJsonTo(json: String): T = diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index 7729b4dd86..53c9810918 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -52,12 +52,15 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer -import com.vitorpamplona.quartz.nip47WalletConnect.Notification -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationSerializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer @@ -93,8 +96,11 @@ class JacksonMapper { .addSerializer(Rumor::class.java, RumorSerializer()) .addDeserializer(Rumor::class.java, RumorDeserializer()) // nip 47 + .addSerializer(Response::class.java, ResponseSerializer()) .addDeserializer(Response::class.java, ResponseDeserializer()) + .addSerializer(Request::class.java, RequestSerializer()) .addDeserializer(Request::class.java, RequestDeserializer()) + .addSerializer(Notification::class.java, NotificationSerializer()) .addDeserializer(Notification::class.java, NotificationDeserializer()) // nip 46 .addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer()) @@ -111,6 +117,7 @@ class JacksonMapper { val tagArrayTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) val rumorTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) val eventTemplateTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef>()) + val eventListTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef>()) val messageTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) val commandTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRef()) @@ -126,6 +133,8 @@ class JacksonMapper { fun fromJsonToEventTemplate(json: String): EventTemplate = mapper.readValue(json, eventTemplateTypeInstance) + fun fromJsonToEventList(json: String): List = mapper.readValue(json, eventListTypeInstance) + inline fun fromJsonTo(json: String): T = mapper.readValue(json) inline fun fromJsonTo(json: InputStream): T = mapper.readValue(json) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt index 6f3d2ccabb..70535bb019 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt @@ -24,11 +24,11 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification -import com.vitorpamplona.quartz.nip47WalletConnect.Notification -import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType -import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification -import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification import com.vitorpamplona.quartz.utils.asTextOrNull class NotificationDeserializer : StdDeserializer(Notification::class.java) { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt new file mode 100644 index 0000000000..6d7376fbfe --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification + +class NotificationSerializer : StdSerializer(Notification::class.java) { + override fun serialize( + value: Notification, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + gen.writeStringField("notification_type", value.notification_type) + when (value) { + is PaymentReceivedNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + + is PaymentSentNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + + is HoldInvoiceAcceptedNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt index 07e6eac965..2fe07f584e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt @@ -24,21 +24,21 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod -import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod -import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod -import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod -import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod -import com.vitorpamplona.quartz.nip47WalletConnect.Request -import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod import com.vitorpamplona.quartz.utils.asTextOrNull class RequestDeserializer : StdDeserializer(Request::class.java) { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt new file mode 100644 index 0000000000..098740be17 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod + +class RequestSerializer : StdSerializer(Request::class.java) { + override fun serialize( + value: Request, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + if (value.method != null) { + gen.writeStringField("method", value.method) + } + when (value) { + is PayInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is PayKeysendMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is MakeInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is LookupInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is ListTransactionsMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is MakeHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is CancelHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is SettleHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is SignMessageMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is CreateConnectionMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index ccb98c8b74..275daa5c1a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -24,24 +24,24 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.NwcError -import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse import com.vitorpamplona.quartz.utils.asTextOrNull class ResponseDeserializer : StdDeserializer(Response::class.java) { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt new file mode 100644 index 0000000000..94fa792972 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse + +class ResponseSerializer : StdSerializer(Response::class.java) { + override fun serialize( + value: Response, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + if (value.resultType.isNotEmpty()) { + gen.writeStringField("result_type", value.resultType) + } + when (value) { + is NwcErrorResponse -> { + if (value.error != null) { + gen.writeObjectField("error", value.error) + } + } + + is PayInvoiceErrorResponse -> { + if (value.error != null) { + gen.writeObjectField("error", value.error) + } + } + + is PayInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is PayKeysendSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is MakeInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is LookupInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is ListTransactionsSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetBalanceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetInfoSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is MakeHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is CancelHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is SettleHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetBudgetSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is SignMessageSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is CreateConnectionSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt index d9b192d9db..71d8fd5a2c 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -50,12 +50,13 @@ actual class ChessEngine { actual fun makeMove(san: String): MoveResult = try { - if (board.doMove(san)) { - // Input is already SAN from Nostr events, store directly - sanHistory.add(san) + val normalized = normalizeSan(san) + if (board.doMove(normalized)) { + // Store the normalized SAN, not the original + sanHistory.add(normalized) MoveResult( success = true, - san = san, + san = normalized, position = boardToPosition(), ) } else { @@ -71,6 +72,31 @@ actual class ChessEngine { ) } + /** + * Normalize SAN notation for interoperability with different chess clients. + * + * Handles: + * - Castling with zeros: 0-0 → O-O, 0-0-0 → O-O-O (preserving +/# suffix) + * - Annotation symbols: strips trailing !, ?, !!, ??, !?, ?! + */ + private fun normalizeSan(san: String): String { + var s = san.trim() + + // Strip trailing annotation characters (!, ?, and combinations) + while (s.isNotEmpty() && (s.last() == '!' || s.last() == '?')) { + s = s.dropLast(1) + } + + // Normalize castling with zeros to letters + // Handle 0-0-0 before 0-0 to avoid partial match + s = + s + .replace("0-0-0", "O-O-O") + .replace("0-0", "O-O") + + return s + } + actual fun makeMove( from: String, to: String, @@ -212,7 +238,8 @@ actual class ChessEngine { board.getPiece(toSquare) != Piece.NONE || ( pt == com.github.bhlangonijr.chesslib.PieceType.PAWN && - epTarget != Square.NONE && toSquare == epTarget + epTarget != Square.NONE && + toSquare == epTarget ) if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt deleted file mode 100644 index 4595678925..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt +++ /dev/null @@ -1,65 +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 org.czeal.rfc3986.URIReference - -actual object Rfc3986 { - actual fun normalize(uri: String) = - URIReference - .parse(uri) - .normalize() - .toString() - - actual fun isValidUrl(url: String): Boolean = - runCatching { - URIReference.parse(url) - }.isSuccess - - actual fun normalizeAndRemoveFragment(url: String): String = - URIReference - .parse(url) - .normalize() - .toStringNoFragment() - .intern() - - actual fun host(url: String): String = URIReference.parse(url).host.value -} - -fun URIReference.toStringSchemeHost(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - - return sb.toString() -} - -fun URIReference.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt similarity index 86% rename from quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt index ebdf489700..3e75d0dc87 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt @@ -26,28 +26,29 @@ import java.net.URLDecoder actual class UriParser actual constructor( uri: String, ) { - val myUri = URI.create(uri) - val queryParameters: Map by lazy { + private val myUri = URI.create(uri) + + private val queryParameters: Map by lazy { myUri.query?.ifBlank { null }?.let { query -> query.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { - parts[0] to parts[1] + parts[0] to URLDecoder.decode(parts[1], "UTF-8") } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" + parts[0] to "" // Handle parameters without a value } } } ?: emptyMap() } - val fragments: Map by lazy { + private val fragments: Map by lazy { myUri.rawFragment?.ifBlank { null }?.let { keyValuePair -> keyValuePair.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { parts[0] to URLDecoder.decode(parts[1], "UTF-8") } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" + parts[0] to "" // Handle parameters without a value } } } ?: emptyMap() @@ -58,7 +59,6 @@ actual class UriParser actual constructor( actual fun host(): String? = myUri.host actual fun port(): Int? { - // java.net.URI.getPort() returns -1 if the port is not set, so we handle that case. val port = myUri.port return if (port == -1) null else port } @@ -69,5 +69,5 @@ actual class UriParser actual constructor( actual fun getQueryParameter(param: String): String? = queryParameters[param] - actual fun fragments() = fragments + actual fun fragments(): Map = fragments } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt index 44528e4df7..1431b7af5c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -21,11 +21,34 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +class DefaultContentTypeInterceptor( + private val userAgentHeader: String, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = + originalRequest + .newBuilder() + .header("User-Agent", userAgentHeader) + .build() + return chain.proceed(requestWithUserAgent) + } +} open class BaseNostrClientTest { companion object { - val rootClient = OkHttpClient.Builder().build() + val rootClient = + OkHttpClient + .Builder() + .followRedirects(true) + .followSslRedirects(true) + .addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05")) + .build() val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index 338a832e8b..e974d964da 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -70,7 +70,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index e583343c50..7334cb1583 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -83,7 +83,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -94,7 +94,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldIgnore = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), @@ -105,7 +105,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldSendAfterEOSE = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt new file mode 100644 index 0000000000..352ecada21 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { + @Test + fun testDownloadFromRelayReturnsMetadataEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val events = mutableListOf() + + // nos.lol returns only 500 events per req + val totalFound = + client.reqBypassingRelayLimits( + relay = "wss://nos.lol", + filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 1000, + ), + ), + ) { event -> + events.add(event) + } + + client.disconnect() + delay(500) + appScope.cancel() + + assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol") + assertEquals(1000, events.size, "Events list should be 1000 events") + events.forEach { event -> + assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") + } + } + + @Test + fun testDownloadFromRelayReturnsMetadataAndContactListEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val metadataEvents = mutableListOf() + val contactListEvents = mutableListOf() + + // nos.lol returns only 500 events per req + val totalFound = + client.reqBypassingRelayLimits( + relay = "wss://nos.lol", + filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 1000, + ), + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 1500, + ), + ), + ) { event -> + if (event.kind == MetadataEvent.KIND) { + metadataEvents.add(event) + } + if (event.kind == ContactListEvent.KIND) { + contactListEvents.add(event) + } + } + + client.disconnect() + delay(500) + appScope.cancel() + + assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol") + assertEquals(1000, metadataEvents.size, "Events list should be 1000 events") + assertEquals(1500, contactListEvents.size, "Events list should be 1000 events") + metadataEvents.forEach { event -> + assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") + } + contactListEvents.forEach { event -> + assertEquals(ContactListEvent.KIND, event.kind, "All events should be kind ${ContactListEvent.KIND}") + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index e28b887f2b..7afd9caad4 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -93,7 +93,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { val flow = client.reqAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 511a0d2a5b..00c37f00ea 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -48,7 +48,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { val sub = client.req( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index 913bf5eff9..de36239681 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -93,7 +93,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { val flow = client.reqUntilEoseAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessInteropBugTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessInteropBugTest.kt new file mode 100644 index 0000000000..1c2c70d896 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessInteropBugTest.kt @@ -0,0 +1,645 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip64Chess + +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Tests for interoperability bugs when playing chess against other clients. + * + * Category 1: SAN Format Mismatch — different engines produce different SAN notation + * Category 2: Reconstruction with variant SAN + * Category 3: Open Challenge Spectator Detection + * Category 4: Missing Start Event + */ +class ChessInteropBugTest { + private val whitePubkey = "white_pubkey_abc123" + private val blackPubkey = "black_pubkey_def456" + private val spectatorPubkey = "spectator_pubkey_xyz789" + private val startEventId = "start-event-001" + + // Italian Game opening to reach castling position: + // 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 + private val italianGameMoves = listOf("e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5") + + // Queenside castling setup: 1. d4 d5 2. Nc3 Nf6 3. Bf4 e6 4. Qd2 Be7 + private val queensideCastlingSetup = listOf("d4", "d5", "Nc3", "Nf6", "Bf4", "e6", "Qd2", "Be7") + + // ========================================================================== + // CATEGORY 1: SAN NORMALIZATION (ChessEngine) + // ========================================================================== + + @Test + fun `engine handles 0-0 kingside castling notation`() { + val engine = ChessEngine() + for (move in italianGameMoves) { + val result = engine.makeMove(move) + assertTrue(result.success, "Setup move $move should succeed") + } + val result = engine.makeMove("0-0") + assertTrue( + result.success, + "0-0 (zeros) should be accepted for kingside castling", + ) + } + + @Test + fun `engine handles 0-0-0 queenside castling notation`() { + val engine = ChessEngine() + for (move in queensideCastlingSetup) { + val result = engine.makeMove(move) + assertTrue(result.success, "Setup move $move should succeed") + } + val result = engine.makeMove("0-0-0") + assertTrue( + result.success, + "0-0-0 (zeros) should be accepted for queenside castling", + ) + } + + @Test + fun `engine handles 0-0 with check suffix`() { + // Setup a position where kingside castling gives check + // Use FEN to create a contrived position where O-O gives check + // Easier: just verify 0-0+ is normalized to O-O (check suffix preserved) + val engine = ChessEngine() + for (move in italianGameMoves) { + engine.makeMove(move) + } + // 0-0+ — the + may or may not be relevant depending on position, + // but normalization should convert 0-0+ → O-O and strip + + // In Italian Game position, O-O doesn't give check, so we just verify + // the move is accepted (the engine ignores spurious +) + val result = engine.makeMove("0-0+") + assertTrue( + result.success, + "0-0+ should be normalized and accepted", + ) + } + + @Test + fun `engine handles 0-0-0 with check suffix`() { + val engine = ChessEngine() + for (move in queensideCastlingSetup) { + engine.makeMove(move) + } + val result = engine.makeMove("0-0-0+") + assertTrue( + result.success, + "0-0-0+ should be normalized and accepted", + ) + } + + @Test + fun `engine handles move with annotation !`() { + val engine = ChessEngine() + val result = engine.makeMove("e4!") + assertTrue(result.success, "e4! should be accepted after stripping annotation") + } + + @Test + fun `engine handles move with annotation !!`() { + val engine = ChessEngine() + val result = engine.makeMove("e4") + assertTrue(result.success) + val result2 = engine.makeMove("e5") + assertTrue(result2.success) + val result3 = engine.makeMove("Nf3!!") + assertTrue(result3.success, "Nf3!! should be accepted after stripping annotation") + } + + @Test + fun `engine handles move with annotation questionmark-bang`() { + val engine = ChessEngine() + val result = engine.makeMove("e4?!") + assertTrue(result.success, "e4?! should be accepted after stripping annotation") + } + + @Test + fun `engine handles standard O-O still works`() { + val engine = ChessEngine() + for (move in italianGameMoves) { + engine.makeMove(move) + } + val result = engine.makeMove("O-O") + assertTrue(result.success, "Standard O-O should still work (regression check)") + } + + @Test + fun `engine handles standard O-O-O still works`() { + val engine = ChessEngine() + for (move in queensideCastlingSetup) { + engine.makeMove(move) + } + val result = engine.makeMove("O-O-O") + assertTrue(result.success, "Standard O-O-O should still work (regression check)") + } + + @Test + fun `engine handles Qxf7# checkmate notation`() { + val engine = ChessEngine() + val setup = listOf("e4", "e5", "Qh5", "Nc6", "Bc4", "Nf6") + for (move in setup) { + engine.makeMove(move) + } + val result = engine.makeMove("Qxf7#") + assertTrue(result.success, "Qxf7# should be accepted") + assertTrue(engine.isCheckmate(), "Position should be checkmate") + } + + @Test + fun `engine handles Qh5 check notation`() { + val engine = ChessEngine() + // 1. e4 e5 2. Qh5 — Qh5 doesn't give check here but let's verify it works + engine.makeMove("e4") + engine.makeMove("e5") + val result = engine.makeMove("Qh5") + assertTrue(result.success, "Qh5 should be accepted") + } + + @Test + fun `engine stores normalized SAN in history`() { + val engine = ChessEngine() + for (move in italianGameMoves) { + engine.makeMove(move) + } + // Castle with zero notation + engine.makeMove("0-0") + val history = engine.getMoveHistory() + assertEquals( + "O-O", + history.last(), + "History should store normalized O-O, not 0-0", + ) + } + + @Test + fun `engine stores normalized SAN without annotations in history`() { + val engine = ChessEngine() + engine.makeMove("e4!") + val history = engine.getMoveHistory() + assertEquals( + "e4", + history.last(), + "History should store e4, not e4!", + ) + } + + // ========================================================================== + // CATEGORY 2: RECONSTRUCTION WITH VARIANT SAN + // ========================================================================== + + @Test + fun `reconstructor handles castling with 0-0 in history`() { + val historyWithZeros = italianGameMoves + "0-0" + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "0-0", + fen = "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4", + history = historyWithZeros, + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue( + result.isSuccess(), + "Reconstruction with 0-0 in history should succeed", + ) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + assertFalse(state.isDesynced, "Should not be desynced") + } + + @Test + fun `reconstructor handles castling with 0-0-0 in history`() { + val historyWithZeros = queensideCastlingSetup + "0-0-0" + val finalMove = + createMoveEvent( + id = "move-009", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-008", + move = "0-0-0", + fen = "rnbqk2r/ppp1bppp/4pn2/3p4/3P1B2/2N5/PPPQPPPP/2KR1BNR b kq - 6 5", + history = historyWithZeros, + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue( + result.isSuccess(), + "Reconstruction with 0-0-0 in history should succeed", + ) + + val state = result.getOrNull()!! + assertEquals(9, state.moveHistory.size) + assertFalse(state.isDesynced, "Should not be desynced") + } + + @Test + fun `reconstructor handles mixed SAN formats in history`() { + val mixedHistory = listOf("e4", "e5", "Nf3!", "Nc6", "Bc4", "Bc5", "0-0") + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "0-0", + fen = "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4", + history = mixedHistory, + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue( + result.isSuccess(), + "Reconstruction with mixed SAN formats should succeed", + ) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + assertFalse(state.isDesynced, "Should not be desynced") + } + + @Test + fun `reconstructor handles jester-style content without playerColor`() { + val events = + JesterGameEvents( + startEvent = createOpenChallengeStartEventNoColor(startEventId, whitePubkey), + moves = emptyList(), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals( + whitePubkey, + state.whitePubkey, + "Challenger should default to WHITE when playerColor is absent", + ) + } + + // ========================================================================== + // CATEGORY 3: OPEN CHALLENGE SPECTATOR DETECTION + // ========================================================================== + + @Test + fun `open challenge with no p-tag no playerColor - only challenger moves - viewer is spectator`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createOpenChallengeStartEvent(startEventId, whitePubkey), + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, spectatorPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.SPECTATOR, state.viewerRole, "Third party should be SPECTATOR") + assertFalse(state.isPlayerTurn(), "Spectators never have a turn") + } + + @Test + fun `open challenge with no p-tag - acceptor made moves - viewer is player`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createOpenChallengeStartEvent(startEventId, whitePubkey), + moves = listOf(move1, move2), + ) + + val result = ChessStateReconstructor.reconstruct(events, blackPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals( + ViewerRole.BLACK_PLAYER, + state.viewerRole, + "Acceptor who made a move should be BLACK_PLAYER", + ) + assertEquals(Color.BLACK, state.playerColor) + assertEquals(whitePubkey, state.opponentPubkey) + } + + @Test + fun `open challenge - challenger chose black via content - roles swap correctly`() { + // Challenger chose black, so the acceptor will be white + val move1 = + createMoveEvent( + id = "move-001", + pubKey = blackPubkey, // Acceptor plays as white (first move) + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + // Challenger (whitePubkey) chose BLACK + startEvent = createStartEvent(startEventId, whitePubkey, Color.BLACK, null), + moves = listOf(move1), + ) + + val challengerResult = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(challengerResult.isSuccess()) + val challengerState = challengerResult.getOrNull()!! + assertEquals(ViewerRole.BLACK_PLAYER, challengerState.viewerRole, "Challenger chose black") + assertEquals(Color.BLACK, challengerState.playerColor) + + val acceptorResult = ChessStateReconstructor.reconstruct(events, blackPubkey) + assertTrue(acceptorResult.isSuccess()) + val acceptorState = acceptorResult.getOrNull()!! + assertEquals(ViewerRole.WHITE_PLAYER, acceptorState.viewerRole, "Acceptor should be white") + assertEquals(Color.WHITE, acceptorState.playerColor) + } + + @Test + fun `jester-style content with version as integer`() { + val content = """{"version":0,"kind":0,"fen":"${JesterProtocol.FEN_START}","history":[]}""" + val event = + JesterEvent( + id = startEventId, + pubKey = whitePubkey, + createdAt = 1000, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "sig-start", + ) + + assertTrue(event.isStartEvent(), "Event with version as integer should parse as start event") + assertEquals(JesterProtocol.FEN_START, event.fen()) + assertTrue(event.history().isEmpty()) + } + + // ========================================================================== + // CATEGORY 4: MISSING START EVENT + // ========================================================================== + + @Test + fun `reconstruction fails clearly with no start event`() { + val events = JesterGameEvents(startEvent = null, moves = emptyList()) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error) + assertEquals("No start event found", result.message) + } + + @Test + fun `reconstruction with moves but no start event`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error, "Should return Error, not crash") + assertEquals("No start event found", result.message) + } + + // ========================================================================== + // HELPER FUNCTIONS + // ========================================================================== + + private fun createStartEvent( + id: String, + challengerPubkey: String, + challengerColor: Color, + opponentPubkey: String?, + createdAt: Long = 1000, + ): JesterEvent { + val tags = + mutableListOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ) + opponentPubkey?.let { tags.add(arrayOf("p", it)) } + + val colorString = if (challengerColor == Color.WHITE) "white" else "black" + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123","playerColor":"$colorString"}""" + + return JesterEvent( + id = id, + pubKey = challengerPubkey, + createdAt = createdAt, + tags = tags.toTypedArray(), + content = content, + sig = "sig-start", + ) + } + + private fun createOpenChallengeStartEvent( + id: String, + challengerPubkey: String, + createdAt: Long = 1000, + ): JesterEvent { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123","playerColor":"white"}""" + + return JesterEvent( + id = id, + pubKey = challengerPubkey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "sig-start", + ) + } + + private fun createOpenChallengeStartEventNoColor( + id: String, + challengerPubkey: String, + createdAt: Long = 1000, + ): JesterEvent { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123"}""" + + return JesterEvent( + id = id, + pubKey = challengerPubkey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "sig-start", + ) + } + + // ========================================================================== + // CATEGORY 5: JESTER CONTENT SERIALIZATION + // ========================================================================== + + @Test + fun `serialized start content includes version fen and history fields`() { + // Jester's isStartGameEvent checks arrayEquals(json.history, []) + // which fails if history field is absent from JSON + val content = + com.vitorpamplona.quartz.nip64Chess.jester.JesterContent( + kind = 0, + nonce = "test1234", + playerColor = "white", + ) + val json = + com.vitorpamplona.quartz.nip01Core.core.JsonMapper + .toJson(content) + + assertTrue(json.contains("\"version\""), "JSON must include version field: $json") + assertTrue(json.contains("\"fen\""), "JSON must include fen field: $json") + assertTrue(json.contains("\"history\""), "JSON must include history field: $json") + assertTrue(json.contains("\"history\":[]"), "history must be empty array: $json") + } + + @Test + fun `serialized move content includes version fen and history fields`() { + val content = + com.vitorpamplona.quartz.nip64Chess.jester.JesterContent( + kind = 1, + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + move = "e4", + history = listOf("e4"), + ) + val json = + com.vitorpamplona.quartz.nip01Core.core.JsonMapper + .toJson(content) + + assertTrue(json.contains("\"version\""), "JSON must include version field: $json") + assertTrue(json.contains("\"fen\""), "JSON must include fen field: $json") + assertTrue(json.contains("\"history\""), "JSON must include history field: $json") + } + + // ========================================================================== + // HELPERS + // ========================================================================== + + private fun createMoveEvent( + id: String, + pubKey: String, + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + createdAt: Long = 2000, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson]}""" + + return JesterEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig-move", + ) + } +} diff --git a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt index bb522a8b7c..314166c723 100644 --- a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -20,7 +20,17 @@ */ package com.vitorpamplona.quartz +import java.util.zip.GZIPInputStream + actual class TestResourceLoader { + actual fun loadDecompressString(file: String): String = + this@TestResourceLoader + .javaClass.classLoader + ?.getResourceAsStream(file) + ?.let { GZIPInputStream(it) } + ?.bufferedReader() + ?.use { it.readText() } ?: throw IllegalArgumentException("Resource not found: $file") + actual fun loadString(file: String): String = this@TestResourceLoader .javaClass.classLoader diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt similarity index 100% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt rename to quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt diff --git a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 0000000000..90117ac43a --- /dev/null +++ b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt index 52822c49cb..0c975e7c33 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -261,7 +261,8 @@ class NamecoinNameResolverTest { pubkey = rootMatch.content } - firstEntry != null && firstEntry.value is kotlinx.serialization.json.JsonPrimitive && + firstEntry != null && + firstEntry.value is kotlinx.serialization.json.JsonPrimitive && (firstEntry.value as kotlinx.serialization.json.JsonPrimitive) .content .matches(Regex("^[0-9a-fA-F]{64}$")) -> { diff --git a/quartz/src/nativeInterop/cinterop/Clibsodium.def b/quartz/src/nativeInterop/cinterop/Clibsodium.def deleted file mode 100644 index a60f3fda69..0000000000 --- a/quartz/src/nativeInterop/cinterop/Clibsodium.def +++ /dev/null @@ -1,3 +0,0 @@ -package = Clibsodium -staticLibraries = libsodium.a libsodium-simulator.a -libraryPaths = src/nativeInterop/libsodium/ios/lib src/nativeInterop/libsodium/ios-simulators/lib diff --git a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift b/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift deleted file mode 100644 index bafd34a5b0..0000000000 --- a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by NullDev on 31/12/2025. -// - -import Foundation -import RFC_3986 - -@objcMembers public class Rfc3986UriBridge: NSObject { - public func normalizeUrl(url: String) throws -> String { - let uri = try RFC_3986.URI(url) - let normalized = uri.normalized() - return normalized.value - } - - public func isUrlValid(url: String) -> Bool { - return RFC_3986.isValidURI(url) - } - - public func hostFromUri(url: String) throws -> String { - let actualUri = try RFC_3986.URI(url) - return actualUri.host! - } -} diff --git a/quartz/src/swift/swiftbridge/UrlDetector.swift b/quartz/src/swift/swiftbridge/UrlDetector.swift deleted file mode 100644 index e4927c516d..0000000000 --- a/quartz/src/swift/swiftbridge/UrlDetector.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by NullDev on 20/01/2026. -// - -import Foundation - -@objcMembers public class UrlDetector: NSObject { - public func findURLs(text: String) -> [String] { - var links = [String]() - let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) - - for match in matches { - guard let range = Range(match.range, in: text) else { continue } - let url = text[range] - links.append(String(url)) - } - - return links - } -} \ No newline at end of file diff --git a/zapstore.yaml b/zapstore.yaml index ffc1ede493..0eafcad529 100644 --- a/zapstore.yaml +++ b/zapstore.yaml @@ -1,14 +1,51 @@ -name: Amethyst -description: The all-in-one Nostr client -license: MIT -builder: npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0 +# ═══════════════════════════════════════════════════════════════════ +# SOURCE CONFIGURATION +# ═══════════════════════════════════════════════════════════════════ + +# Source code repository URL or NIP-34 naddr (for display in app store) repository: https://github.com/vitorpamplona/amethyst -homepage: https://amethyst.social/ -assets: - - amethyst-googleplay.*.apk -remote_metadata: - - github +# ═══════════════════════════════════════════════════════════════════ +# APP METADATA +# ═══════════════════════════════════════════════════════════════════ +# App name (overrides APK label) +name: Amethyst +# Short one-line description +summary: The all-in-one Nostr client +# Full description (supports markdown) +description: | + A privacy-focused Nostr client for Android. Built-in TOR support, the most configurable relay system, + encrypted messaging, zaps, marketplaces, live streams and complete data sovereignty. + +# Category tags +tags: + - social-network + - nostr + +# SPDX license identifier +license: MIT + +# App homepage +website: https://amethyst.social/ + +# ═══════════════════════════════════════════════════════════════════ +# MEDIA +# ═══════════════════════════════════════════════════════════════════ +# Screenshots (local paths or URLs) +images: + - ./docs/screenshots/home.png + - ./docs/screenshots/messages.png + - ./docs/screenshots/notifications.png + - ./docs/screenshots/replies.png + +# ═══════════════════════════════════════════════════════════════════ +# VARIANTS +# ═══════════════════════════════════════════════════════════════════ + +# APK variant patterns (for apps with multiple builds) +variants: + fdroid: ".*-fdroid-.*\\.apk$" + google: ".*-googleplay-.*\\.apk$"