diff --git a/.claude/skills/find-non-lambda-logs/SKILL.md b/.claude/skills/find-non-lambda-logs/SKILL.md new file mode 100644 index 000000000..0b0009255 --- /dev/null +++ b/.claude/skills/find-non-lambda-logs/SKILL.md @@ -0,0 +1,86 @@ +--- +name: find-non-lambda-logs +description: Use when auditing or migrating Log calls to lambda overloads, after adding new logging, or checking for string interpolation in Log.d/i/w/e calls that waste allocations when the log level is filtered out +--- + +# Find Non-Lambda Log Calls + +## Overview + +Locates `Log.d/i/w/e` calls that use string interpolation without the lambda overload, wasting string allocation when the log level is filtered out in release builds. + +## When to Use + +- After merging branches that add new logging +- Periodic audit of logging hygiene +- After migrating `android.util.Log` usages to the shared `Log` wrapper + +## What to Flag + +Calls with **string interpolation** (`$` in message) that do **not** pass a throwable: + +```kotlin +// FLAG - interpolation without lambda, no throwable +Log.d("Tag", "Processing ${event.id}") +Log.w("Tag", "Failed for $url") + +// IGNORE - passes throwable (lambda overload doesn't accept throwable) +Log.w("Tag", "Error: ${e.message}", e) +Log.e("Tag", "Failed for $url", throwable) + +// IGNORE - no interpolation (no allocation benefit from lambda) +Log.d("Tag", "Initialization complete") +``` + +## Search Commands + +**Important:** Tags can be string literals (`"Tag"`) or variables (`tag`, `LOG_TAG`). Run both patterns for each step. + +### Step 1: Find interpolated Log.d/Log.i (highest priority — filtered in release) + +``` +pattern: Log\.(d|i)\("[^"]+",\s*"[^"]*\$ +type: kotlin +``` +``` +pattern: Log\.(d|i)\(\w+,\s*"[^"]*\$ +type: kotlin +``` + +### Step 2: Find interpolated Log.w/Log.e without throwable + +``` +pattern: Log\.(w|e)\("[^"]+",\s*"[^"]*\$ +type: kotlin +``` +``` +pattern: Log\.(w|e)\(\w+,\s*"[^"]*\$ +type: kotlin +``` + +Then **manually exclude** lines where a throwable is passed as third argument (ending with `, e)`, `, throwable)`, etc.). Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call. + +### Step 3: Verify no android.util.Log leakage + +``` +pattern: android\.util\.Log\.(d|i|w|e|v)\( +type: kotlin +``` + +These bypass the `Log.minLevel` filter entirely. Exclude `PlatformLog.android.kt` which is the wrapper implementation. + +## Fix Pattern + +```kotlin +// Before +Log.d("Tag", "Processing event ${event.id} from ${relay.url}") + +// After +Log.d("Tag") { "Processing event ${event.id} from ${relay.url}" } +``` + +## Do NOT Convert + +- Calls passing a `Throwable` parameter - the lambda overload `(tag) { message }` has no throwable parameter +- Static string calls with no `$` interpolation - no allocation benefit +- Commented-out log calls diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 2c0dbdc27..1e9795cfc 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -12,8 +12,11 @@ echo "$JAVA_HOME" echo "$(java -version)" echo "Running test... " -./gradlew test --quiet - +if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + ./gradlew test --quiet -x :desktopApp:test -x :desktopApp:upxDownload -x :desktopApp:vlcDownload +else + ./gradlew test --quiet +fi status=$? if [ "$status" = 0 ] ; then diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0377b3735..93176ac48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -71,6 +74,10 @@ jobs: - name: Test (gradle) run: ./gradlew test --no-daemon + - name: Stop Gradle Daemon + if: always() + run: ./gradlew --stop + - name: Android Test Report uses: asadmansr/android-test-report-action@v1.2.0 if: ${{ always() && matrix.os == 'ubuntu-latest' }} @@ -85,7 +92,7 @@ jobs: build-android: needs: test runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v6 @@ -188,6 +195,10 @@ jobs: - name: Build Desktop Distribution run: ./gradlew :desktopApp:${{ matrix.task }} + - name: Stop Gradle Daemon + if: always() + run: ./gradlew --stop + - name: Upload Desktop Distribution uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index beb08874b..fc619bc3f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -5,6 +5,9 @@ on: tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 +permissions: + contents: write + jobs: create-release: runs-on: ubuntu-latest diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 923e7fa49..e23a28596 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -5,6 +5,10 @@ on: # paths: ["app/src/main/res/**/strings.xml"] // removes filter to allow downloads at any moment. branches: [ main ] +permissions: + contents: write + pull-requests: write + jobs: synchronize-with-crowdin: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index de1c06cf0..bece7072e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ /.idea/deviceManager.xml /.idea/inspectionProfiles/ /.idea/migrations.xml +/desktopApp/vlc-temp/ /commons/.idea/gradle.xml /commons/.idea/misc.xml /commons/.idea/workspace.xml diff --git a/README.md b/README.md index a19f16550..dcf7f0753 100644 --- a/README.md +++ b/README.md @@ -39,107 +39,104 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) -- [x] Events / Relay Subscriptions (NIP-01) +- [x] Basic protocol flow (NIP-01) - [x] Follow List (NIP-02) - [x] OpenTimestamps Attestations (NIP-03) -- [x] Private Messages (NIP-04 -- to be removed) -- [x] DNS Address (NIP-05) -- [x] Mnemonic seed phrase (NIP-06) -- [ ] WebBrowser Signer (NIP-07, Not applicable) -- [x] Old-style mentions (NIP-08) -- [x] Event Deletion (NIP-09) +- [x] Encrypted Direct Message (NIP-04) +- [x] DNS-based Identifiers (NIP-05) +- [x] Key Derivation from Mnemonic (NIP-06) +- [ ] window.nostr for Web Browsers (NIP-07, Not applicable) +- [x] Handling Mentions (NIP-08) +- [x] Event Deletion Request (NIP-09) - [x] Text Notes and Threads (NIP-10) - [x] Relay Information Document (NIP-11) -- [x] Generic Tag Queries (NIP-12) -- [x] Proof of Work Display (NIP-13) -- [ ] Proof of Work Calculations (NIP-13) -- [x] Events with a Subject (NIP-14) -- [ ] Marketplace (NIP-15) -- [x] Event Treatment (NIP-16) +- [x] Proof of Work (NIP-13) +- [x] Subject Tag in Text Events (NIP-14) +- [x] Nostr Marketplace (NIP-15) - [x] Private Direct Messages (NIP-17) -- [x] Image/Video/Url/LnInvoice/Cashu Previews -- [x] Reposts, Quotes, Generic Reposts (NIP-18) -- [x] Bech32 Encoding support (NIP-19) -- [x] Command Results (NIP-20) -- [x] URI Support (NIP-21) -- [x] Long-form Content (NIP-23) (view only) -- [x] User Profile Fields / Relay list (NIP-24) +- [x] Reposts (NIP-18) +- [x] bech32-encoded Entities (NIP-19) +- [x] nostr: URI Scheme (NIP-21) +- [x] Comment (NIP-22) +- [x] Long-form Content (NIP-23) +- [x] Extra Metadata Fields and Tags (NIP-24) - [x] Reactions (NIP-25) -- [ ] Delegated Event Signing (NIP-26, Will not implement) +- [x] Delegated Event Signing (NIP-26) - [x] Text Note References (NIP-27) -- [x] Public Chats (NIP-28) -- [ ] Relay-based Groups (NIP-29) +- [x] Public Chat (NIP-28) +- [x] Relay-based Groups (NIP-29) - [x] Custom Emoji (NIP-30) -- [x] Event alt descriptors (NIP-31) -- [ ] Labeling (NIP-32) -- [x] Git Stuff (NIP-34) +- [x] Dealing with Unknown Events (NIP-31) +- [x] Labeling (NIP-32) +- [x] git stuff (NIP-34) - [x] Torrents (NIP-35) - [x] Sensitive Content (NIP-36) -- [x] Drafts (NIP-37) -- [x] User Status Event (NIP-38) -- [x] External Identities (NIP-39) -- [x] Expiration Support (NIP-40) -- [x] Relay Authentication (NIP-42) -- [ ] Relay Access Metadata and Requests (NIP-43) -- [x] Versioned Encrypted Payloads (NIP-44) -- [x] Event Counts (NIP-45) -- [o] Nostr Connect (NIP-46) -- [o] Wallet Connect API (NIP-47) -- [ ] Proxy Tags (NIP-48, Not applicable) -- [x] Encryption for import/export (NIP-49) -- [x] Relay Search (NIP-50) +- [x] Draft Events (NIP-37) +- [x] User Statuses (NIP-38) +- [x] External Identities in Profiles (NIP-39) +- [x] Expiration Timestamp (NIP-40) +- [x] Authentication of Clients to Relays (NIP-42) +- [x] Relay Access Metadata and Requests (NIP-43) +- [x] Encrypted Payloads / Versioned (NIP-44) +- [x] Counting Results (NIP-45) +- [x] Nostr Remote Signing (NIP-46) +- [x] Nostr Wallet Connect (NIP-47) +- [x] Proxy Tags (NIP-48) +- [x] Private Key Encryption (NIP-49) +- [x] Search Capability (NIP-50) - [x] Lists (NIP-51) -- [o] Calendar Events (NIP-52) -- [x] Live Activities & Live Chats (NIP-53) +- [x] Calendar Events (NIP-52) +- [x] Live Activities (NIP-53) - [x] Wiki (NIP-54) -- [x] Android Signer (NIP-55) +- [x] Android Signer Application (NIP-55) - [x] Reporting (NIP-56) -- [x] Lightning Tips -- [x] Zaps (NIP-57) +- [x] Lightning Zaps (NIP-57) - [x] Zap Splits (NIP-57) -- [x] Private Zaps (NIP-57 / Draft) -- [x] Zapraiser (NIP-57 / Draft) +- [x] Private Zaps (NIP-57) +- [x] Zapraiser (NIP-57) - [x] Badges (NIP-58) -- [x] Gift Wraps & Seals (NIP-59) -- [ ] Cashu Wallets (NIP-60) -- [ ] Nutzaps (NIP-61) +- [x] Gift Wrap (NIP-59) +- [x] Pubkey Static Websites (NIP-5A) +- [x] Cashu Wallet (NIP-60) +- [x] Nutzaps (NIP-61) - [x] Request to Vanish (NIP-62) -- [x] Chess (NIP-64) +- [x] Chess / PGN (NIP-64) - [x] Relay List Metadata (NIP-65) -- [x] Relay Discovery and Monitoring (NIP-66) -- [x] Picture-first feeds (NIP-68) -- [ ] Peer-to-peer Orders (NIP-69) -- [o] Protected Events (NIP-70) +- [x] Relay Discovery and Liveness Monitoring (NIP-66) +- [x] Picture-first Feeds (NIP-68) +- [x] Peer-to-peer Order Events (NIP-69) +- [x] Protected Events (NIP-70) - [x] Video Events (NIP-71) - [x] Moderated Communities (NIP-72) - [x] External Content IDs (NIP-73) -- [ ] Zap Goals (NIP-75) -- [ ] Negentropy Syncing (NIP-77) -- [x] Arbitrary Custom App Data (NIP-78) -- [ ] NIP-29 Threads (NIP-7D) +- [x] Zap Goals (NIP-75) +- [x] Negentropy Syncing (NIP-77) +- [x] Application-specific Data (NIP-78) +- [x] Threads (NIP-7D) - [x] Highlights (NIP-84) - [x] Trusted Assertions (NIP-85) -- [ ] Relay Management API (NIP-86) -- [ ] ECash Mint Discoverability (NIP-87) +- [x] Relay Management API (NIP-86) +- [x] Ecash Mint Discoverability (NIP-87) - [x] Polls (NIP-88) -- [x] Relay Notify Request - [x] Recommended Application Handlers (NIP-89) -- [x] Data Vending Machine (NIP-90) -- [x] Inline Metadata (NIP-92) -- [x] Verifiable file URLs (NIP-94) +- [x] Data Vending Machines (NIP-90) +- [x] Media Attachments (NIP-92) +- [x] File Metadata (NIP-94) - [x] Binary Blobs (NIP-95/Draft) - [x] HTTP File Storage Integration (NIP-96) - [x] HTTP Auth (NIP-98) -- [x] Classifieds (NIP-99) +- [x] Classified Listings (NIP-99) - [x] Voice Messages (NIP-A0) - [x] Public Messages (NIP-A4) -- [ ] Web Bookmarks +- [x] Web Bookmarks (NIP-B0) - [x] Blossom (NIP-B7) -- [ ] Nostr BLE Communications Protocol (NIP-BE) +- [x] Nostr BLE Communications Protocol (NIP-BE) - [x] Code Snippets (NIP-C0) -- [ ] NIP-29 Chats (NIP-C7) +- [x] Chats (NIP-C7) - [ ] MLS Protocol (NIP-EE) - [x] Audio Tracks (zapstr.live) (kind:31337) +- [x] Lightning Tips +- [x] Image/Video/Url/LnInvoice/Cashu Previews - [x] Push Notifications (Google and Unified Push) - [x] In-Device Automatic Translations - [x] Hashtag Following and Custom Hashtags @@ -405,24 +402,24 @@ to `onPause` methods. ### Feature Parity Table -| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | -| :--- | :--- | :---: | :---: | :--- | -| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. | -| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. | -| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. | -| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. | -| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. | -| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. | -| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. | -| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. | -| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. | -| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. | -| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | -| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. | -| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. | -| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. | -| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | -| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | +| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | +|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------| +| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | | +| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | | +| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | | +| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | | +| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | | +| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. | +| | GZip Compression | ✅ Full | ✅ Full | | +| | BitSet | ✅ Full | ✅ Full | | +| | LargeCache | ✅ Full | ✅ Full | | +| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | | +| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | +| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. | +| **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | | +| | Unicode Normalization | ✅ Full | ✅ Full | | +| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | +| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | ## Contributing diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 48ca191f7..a591dd575 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -70,9 +70,9 @@ class ThreadDualAxisChartAssemblerTest { Account( settings = AccountSettings(keyPair = keyPair), signer = NostrSignerInternal(keyPair), - geolocationFlow = MutableStateFlow(LocationState.LocationResult.Loading), - nwcFilterAssembler = NWCPaymentFilterAssembler(client), - otsResolverBuilder = EmptyOtsResolverBuilder, + geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, + nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, + otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = client, scope = scope, diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt index ac14ef95b..5da1c62fb 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt @@ -46,7 +46,7 @@ object PushDistributorHandler : PushDistributorActions { fun setEndpoint(newEndpoint: String) { endpointInternal = newEndpoint - Log.d("PushHandler", "New endpoint saved : $endpointInternal") + Log.d("PushHandler") { "New endpoint saved : $endpointInternal" } } fun removeEndpoint() { diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index fe5cf2cd2..dffba6b57 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -53,7 +53,7 @@ class PushMessageReceiver : MessagingReceiver() { instance: String, ) { val messageStr = message.content.decodeToString() - Log.d(TAG, "New message $messageStr for Instance: $instance") + Log.d(TAG) { "New message $messageStr for Instance: $instance" } scope.launch { try { parseMessage(messageStr)?.let { @@ -61,7 +61,7 @@ class PushMessageReceiver : MessagingReceiver() { } } catch (e: Exception) { if (e is CancellationException) throw e - Log.d(TAG, "Message could not be parsed: ${e.message}") + Log.d(TAG) { "Message could not be parsed: ${e.message}" } } } } @@ -87,7 +87,7 @@ class PushMessageReceiver : MessagingReceiver() { ) { val sanitizedEndpoint = if (endpoint.url.endsWith("?up=1")) endpoint.url.dropLast(5) else endpoint.url if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) { - Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint") + Log.d(TAG) { "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint" } pushHandler.setEndpoint(sanitizedEndpoint) scope.launch(Dispatchers.IO) { PushNotificationUtils.checkAndInit(sanitizedEndpoint, LocalPreferences.allSavedAccounts()) { @@ -97,7 +97,7 @@ class PushMessageReceiver : MessagingReceiver() { NotificationUtils.getOrCreateDMChannel(appContext) } } else { - Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint") + Log.d(TAG) { "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint" } } } @@ -106,7 +106,7 @@ class PushMessageReceiver : MessagingReceiver() { reason: FailedReason, instance: String, ) { - Log.d(TAG, "Registration failed for Instance: $instance") + Log.d(TAG) { "Registration failed for Instance: $instance" } pushHandler.forceRemoveDistributor(context) } @@ -115,7 +115,7 @@ class PushMessageReceiver : MessagingReceiver() { instance: String, ) { val removedEndpoint = pushHandler.getSavedEndpoint() - Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance") + Log.d(TAG) { "Endpoint: $removedEndpoint removed for Instance: $instance" } Log.d(TAG, "App is unregistered. ") pushHandler.forceRemoveDistributor(context) pushHandler.removeEndpoint() diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 30c16940d..3ed4fe322 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -35,7 +35,7 @@ object PushNotificationUtils { accounts: List, okHttpClient: (String) -> OkHttpClient, ) = with(Dispatchers.IO) { - if (!pushHandler.savedDistributorExists()) return + if (!pushHandler.savedDistributorExists()) return@with val currentDistributor = PushDistributorHandler.getSavedDistributor() PushDistributorHandler.saveDistributor(currentDistributor) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 5c90cfd7f..8e488a83a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst import android.app.Application import com.vitorpamplona.amethyst.service.logging.Logging import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.LogLevel class Amethyst : Application() { init { - Log.d("AmethystApp", "Creating App $this") + Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR + Log.d("AmethystApp") { "Creating App $this" } } companion object { @@ -36,7 +38,7 @@ class Amethyst : Application() { override fun onCreate() { super.onCreate() - Log.d("AmethystApp", "onCreate $this") + Log.d("AmethystApp") { "onCreate $this" } instance = AppModules(this) if (isDebug) { @@ -48,7 +50,7 @@ class Amethyst : Application() { override fun onTerminate() { super.onTerminate() - Log.d("AmethystApp", "onTerminate $this") + Log.d("AmethystApp") { "onTerminate $this" } instance.terminate(this) } @@ -59,7 +61,7 @@ class Amethyst : Application() { */ override fun onTrimMemory(level: Int) { super.onTrimMemory(level) - Log.d("AmethystApp", "onTrimMemory $level") + Log.d("AmethystApp") { "onTrimMemory $level" } instance.trim() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cf9256dc4..78a13e3f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -20,14 +20,15 @@ */ package com.vitorpamplona.amethyst -import android.content.ContentResolver import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder @@ -63,9 +64,11 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory +import com.vitorpamplona.amethyst.ui.resourceCacheInit import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager +import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -87,6 +90,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -94,6 +98,7 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.io.File class AppModules( @@ -109,31 +114,54 @@ class AppModules( val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + // Pre-load both preference DataStores in parallel on IO threads. + // Both constructors use runBlocking internally, so starting them concurrently + // reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs). + private val uiPrefsDeferred = + applicationIOScope.async { + val prefs = UiSharedPreferences.uiPreferences(appContext) ?: UiSettings() + UiSharedPreferences(prefs, appContext, applicationIOScope) + } + + private val torPrefsDeferred = + applicationIOScope.async { + val prefs = TorSharedPreferences.torPreferences(appContext) ?: TorSettings() + TorSharedPreferences(prefs, appContext, applicationIOScope) + } + // Blocking load of UI Preferences to avoid theme/language blinking val uiPrefs by lazy { - UiSharedPreferences(appContext, applicationIOScope) + Log.d("AppModules", "UiSharedPreferences Init") + runBlocking { uiPrefsDeferred.await() } } // Blocking load of Tor Settings to avoid connection leaks val torPrefs by lazy { - TorSharedPreferences(appContext, applicationIOScope) + Log.d("AppModules", "TorSharedPreferences Init") + runBlocking { torPrefsDeferred.await() } } // Namecoin ElectrumX server preferences (global, like Tor settings) val namecoinPrefs by lazy { + Log.d("AppModules", "NamecoinSharedPreferences Init") NamecoinSharedPreferences(appContext, applicationIOScope) } // OTS blockchain explorer preferences (global, like Tor settings) val otsPrefs by lazy { + Log.d("AppModules", "OtsSharedPreferences Init") OtsSharedPreferences(appContext, applicationIOScope) } // App services that should be run as soon as there are subscribers to their flows - val locationManager = LocationState(appContext, applicationIOScope) + val locationManager by lazy { + Log.d("AppModules", "LocationManager Init") + LocationState(appContext, applicationIOScope) + } val connManager = ConnectivityManager(appContext, applicationIOScope) val uiState by lazy { + Log.d("AppModules", "UiSettingsState Init") UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope) } @@ -158,40 +186,67 @@ class AppModules( // Offers easy methods to know when connections are happening through Tor or not val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) - // Custom fetcher that considers tor settings and avoids forwarding. - val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) + val electrumXClient by lazy { + Log.d("AppModules", "ElectrumXClient Init") + val client = + ElectrumXClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) + applicationIOScope.launch { + try { + val pinnedCerts = namecoinPrefs.loadPinnedCerts() + if (pinnedCerts.isNotEmpty()) { + client.setDynamicCerts(pinnedCerts) + } + } catch (_: Exception) { + // Non-fatal — defaults will still work + } + } + client + } - val namecoinResolver = - NamecoinNameResolver( - electrumxClient = - ElectrumXClient( - socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, - ), - serverListProvider = { - // User-configured custom servers take priority - namecoinPrefs.customServersOrNull - ?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { - TOR_ELECTRUMX_SERVERS - } else { - DEFAULT_ELECTRUMX_SERVERS - } - }, - ) - val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) + val namecoinResolver by + lazy { + Log.d("AppModules", "Namecoin Resolver Init") + NamecoinNameResolver( + electrumxClient = electrumXClient, + serverListProvider = { + // User-configured custom servers take priority + namecoinPrefs.customServersOrNull + ?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + TOR_ELECTRUMX_SERVERS + } else { + DEFAULT_ELECTRUMX_SERVERS + } + }, + ) + } - // Application-wide block height request cache - val otsBlockHeightCache by lazy { OtsBlockHeightCache() } + val nip05Client by + lazy { + Log.d("AppModules", "NIP05Client Init") + Nip05Client( + fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05), + namecoinResolverBuilder = { namecoinResolver }, + ) + } - val otsResolverBuilder: TorAwareOkHttpOtsResolverBuilder = - TorAwareOkHttpOtsResolverBuilder( - roleBasedHttpClientBuilder::okHttpClientForMoney, - roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations, - otsBlockHeightCache, - customExplorerUrl = { otsPrefs.current.normalizedUrl() }, - ) + val otsResolverBuilder by + lazy { + Log.d("AppModules", "OtsResolverBuilder Init") + TorAwareOkHttpOtsResolverBuilder( + roleBasedHttpClientBuilder::okHttpClientForMoney, + roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations, + OtsBlockHeightCache(), + customExplorerUrl = { otsPrefs.current.normalizedUrl() }, + ) + } // Application-wide ots verification cache - val otsVerifCache by lazy { VerificationStateCache(otsResolverBuilder) } + val otsVerifCache by lazy { + Log.d("AppModules", "OtsCache Init") + VerificationStateCache(otsResolverBuilder) + } val torEvaluatorFlow = TorRelayState( @@ -209,7 +264,7 @@ class AppModules( scope = applicationIOScope, ) - // Connects the NostrClient class with okHttp + // Connects the INostrClient class with okHttp val websocketBuilder = OkHttpWebSocket.Builder { url -> val useTor = torEvaluatorFlow.flow.value.useTor(url) @@ -243,7 +298,12 @@ class AppModules( val authCoordinator = AuthCoordinator(client, applicationIOScope) // Tries to verify new OTS events when they arrive. - val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationIOScope) + val otsEventVerifier = + IncomingOtsEventVerifier( + otsVerifCache = { otsVerifCache }, + cache = cache, + scope = applicationIOScope, + ) // Tracks if it is possible to connect to relays. val failureTracker = RelayOfflineTracker(client) @@ -269,10 +329,10 @@ class AppModules( // keeps all accounts live val accountsCache = AccountCacheState( - geolocationFlow = locationManager.geohashStateFlow, - nwcFilterAssembler = sources.nwc, - contentResolverFn = ::contentResolverFn, - otsResolverBuilder = otsResolverBuilder, + geolocationFlow = { locationManager.geohashStateFlow }, + nwcFilterAssembler = { sources.nwc }, + contentResolverFn = { appContext.contentResolver }, + otsResolverBuilder = { otsResolverBuilder.build() }, cache = cache, client = client, ) @@ -280,8 +340,8 @@ class AppModules( val sessionManager = AccountSessionManager( accountsCache = accountsCache, - nip05Client = nip05Client, - client = client, + nip05ClientBuilder = { nip05Client }, + clientBuilder = { client }, localPreferences = LocalPreferences, scope = applicationIOScope, ) @@ -307,7 +367,8 @@ class AppModules( } } - val blossomResolver = + val blossomResolver by lazy { + Log.d("AppModules", "BlossomServerResolver Init") BlossomServerResolver( loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) }, blossomServers = { addressesToSubscribe -> @@ -323,45 +384,60 @@ class AppModules( }, httpClientBuilder = roleBasedHttpClientBuilder, ) + } // Organizes cache clearing - val trimmingService = MemoryTrimmingService(cache) + val trimmingService by + lazy { + MemoryTrimmingService(cache) + } // as new accounts are loaded, updates the state of the TorRelaySettings, which produces new TorRelayEvaluator // and reconnects relays if the configuration has been changed. val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationIOScope) // saves the .content of NIP-95 blobs in disk to save memory - val nip95cache: File by lazy { Nip95CacheFactory.new(appContext) } + val nip95cache: File by lazy { + Log.d("AppModules", "NIP95 Cache Init") + Nip95CacheFactory.new(appContext) + } // local video cache with disk + memory - val videoCache: VideoCache by lazy { VideoCacheFactory.new(appContext) } + val videoCache: VideoCache by lazy { + Log.d("AppModules", "VideoCache Init") + VideoCacheFactory.new(appContext) + } // image cache in disk for coil - val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(appContext) } + val diskCache: DiskCache by lazy { + Log.d("AppModules", "ImageCacheFactory Init") + ImageCacheFactory.newDisk(appContext) + } // image cache in memory for coil - val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(appContext) } + val memoryCache: MemoryCache by lazy { + Log.d("AppModules", "MemoryCache Init") + ImageCacheFactory.newMemory(appContext) + } // crash report storage - val crashReportCache: CrashReportCache by lazy { CrashReportCache(appContext) } + val crashReportCache = CrashReportCache(appContext) // cache for NIP-11 documents val nip11Cache: Nip11CachedRetriever by lazy { + Log.d("AppModules", "Nip11CachedRetriever Init") Nip11CachedRetriever(torEvaluatorFlow::okHttpClientForRelay) } - fun contentResolverFn(): ContentResolver = appContext.contentResolver - fun setImageLoader() { + Log.d("AppModules", "ImageLoaderSetup Init") ImageLoaderSetup.setup( app = appContext, diskCache = { diskCache }, memoryCache = { memoryCache }, - blossomServerResolver = blossomResolver, - ) { url -> - okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url)) - } + blossomServerResolver = { blossomResolver }, + callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, + ) } fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub) @@ -380,14 +456,20 @@ class AppModules( // initializes diskcache on an IO thread. applicationIOScope.launch { - // preloads tor preferences - torPrefs + // Sets Coil - Tor - OkHttp link + setImageLoader() } // initializes diskcache on an IO thread. applicationIOScope.launch { // Sets Coil - Tor - OkHttp link - setImageLoader() + uiState + } + + // LRUCache should not be instanciated in the Main thread due to blocking + applicationIOScope.launch { + CachedRobohash + resourceCacheInit() } // registers to receive events @@ -395,15 +477,10 @@ class AppModules( // initializes diskcache on an IO thread. applicationIOScope.launch { - // Sets Coil - Tor - OkHttp link - delay(3000) + // Prepares video cache later + delay(10_000) videoCache } - - applicationIOScope.launch { - // Eagerly initialize OtsSharedPreferences off the main thread - otsPrefs - } } fun terminate(appContext: Context) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index 7e2b8dc3f..9a3efa00c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -46,19 +46,19 @@ fun debugState(context: Context) { val jvmHeapAllocatedMb = totalMemoryMb - freeMemoryMb - Log.d(STATE_DUMP_TAG, "Total Heap Allocated: $jvmHeapAllocatedMb/$maxMemoryMb MB") + Log.d(STATE_DUMP_TAG) { "Total Heap Allocated: $jvmHeapAllocatedMb/$maxMemoryMb MB" } val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024) val maxNative = Debug.getNativeHeapSize() / (1024 * 1024) - Log.d(STATE_DUMP_TAG, "Total Native Heap Allocated: $nativeHeap/$maxNative MB") + Log.d(STATE_DUMP_TAG) { "Total Native Heap Allocated: $nativeHeap/$maxNative MB" } val activityManager: ActivityManager? = context.getSystemService() if (activityManager != null) { val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0 val memClass = if (isLargeHeap) activityManager.largeMemoryClass else activityManager.memoryClass - Log.d(STATE_DUMP_TAG, "Memory Class $memClass MB (largeHeap $isLargeHeap)") + Log.d(STATE_DUMP_TAG) { "Memory Class $memClass MB (largeHeap $isLargeHeap)" } } Log.d( @@ -68,14 +68,8 @@ fun debugState(context: Context) { .size() + "/" + normalizedUrls.size(), ) - Log.d( - STATE_DUMP_TAG, - "Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB", - ) - Log.d( - STATE_DUMP_TAG, - "Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.maxSize) / (1024 * 1024)} MB", - ) + Log.d(STATE_DUMP_TAG) { "Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB" } + Log.d(STATE_DUMP_TAG) { "Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.maxSize) / (1024 * 1024)} MB" } Log.d( STATE_DUMP_TAG, @@ -130,13 +124,12 @@ fun debugState(context: Context) { LocalCache.ephemeralChannels.values().sumOf { it.notes.size() }, ) LocalCache.chatroomList.forEach { key, room -> - Log.d( - STATE_DUMP_TAG, + Log.d(STATE_DUMP_TAG) { "Private Chats $key: " + room.rooms.size() + " / " + - room.rooms.sumOf { key, value -> value.messages.size }, - ) + room.rooms.sumOf { key, value -> value.messages.size } + } } Log.d( STATE_DUMP_TAG, @@ -173,10 +166,10 @@ fun debugState(context: Context) { .sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory()?.toLong() ?: 0L }) qttNotes.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) -> - Log.d(STATE_DUMP_TAG, "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB ") + Log.d(STATE_DUMP_TAG) { "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB " } } qttAddressables.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) -> - Log.d(STATE_DUMP_TAG, "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB ") + Log.d(STATE_DUMP_TAG) { "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB " } } } @@ -188,7 +181,7 @@ inline fun logTime( if (isDebug) { val (result, elapsed) = measureTimedValue(block) if (elapsed.inWholeMilliseconds > minToReportMs) { - Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage") + Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage" } } result } else { @@ -203,7 +196,7 @@ inline fun logTime( if (isDebug) { val (result, elapsed) = measureTimedValue(block) if (elapsed.inWholeMilliseconds > minToReportMs) { - Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}") + Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}" } } result } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 968a6e75c..9165e674e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper @@ -59,9 +58,11 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -253,7 +254,7 @@ object LocalPreferences { val prefsDir = File(prefsDirPath) prefsDir.list()?.forEach { if (it.contains(npub) && !File(prefsDir, it).delete()) { - Log.w("LocalPreferences", "Failed to delete preference file: $it") + Log.w("LocalPreferences") { "Failed to delete preference file: $it" } } } } @@ -281,7 +282,7 @@ object LocalPreferences { */ @SuppressLint("ApplySharedPref") suspend fun deleteAccount(accountInfo: AccountInfo) { - Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}") + Log.d("LocalPreferences") { "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}" } withContext(Dispatchers.IO) { encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } removeAccount(accountInfo) @@ -445,108 +446,139 @@ object LocalPreferences { } private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { - Log.d("LocalPreferences", "Load account from file $npub") + Log.d("LocalPreferences") { "Load account from file $npub" } val result = withContext(Dispatchers.IO) { - checkNotInMainThread() - return@withContext with(encryptedPreferences(npub)) { + Log.d("LocalPreferences") { "Load account from file $npub - opened file" } val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null - val externalSignerPackageName = - getString(PrefKeys.SIGNER_PACKAGE_NAME, null) - ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null + val externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null - val defaultHomeFollowList = parseOrNull(PrefKeys.DEFAULT_HOME_FOLLOW_LIST) ?: TopFilter.AllFollows - val defaultStoriesFollowList = parseOrNull(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST) ?: TopFilter.Global - val defaultNotificationFollowList = parseOrNull(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST) ?: TopFilter.Global - val defaultDiscoveryFollowList = parseOrNull(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST) ?: TopFilter.Global + val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + + Log.d("LocalPreferences") { "Load account from file $npub - keys ready" } - val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) - val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) - - val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() - val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() - - val latestUserMetadata = parseEventOrNull(PrefKeys.LATEST_USER_METADATA) - val latestContactList = parseEventOrNull(PrefKeys.LATEST_CONTACT_LIST) - val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) - val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) - val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) - val latestIndexRelayList = parseEventOrNull(PrefKeys.LATEST_INDEX_RELAY_LIST) - val latestRelayFeedsList = parseEventOrNull(PrefKeys.LATEST_RELAY_FEEDS_LIST) - val latestBlockedRelayList = parseEventOrNull(PrefKeys.LATEST_BLOCKED_RELAY_LIST) - val latestTrustedRelayList = parseEventOrNull(PrefKeys.LATEST_TRUSTED_RELAY_LIST) - val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) - val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) - val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) - val latestChannelList = parseEventOrNull(PrefKeys.LATEST_CHANNEL_LIST) - val latestCommunityList = parseEventOrNull(PrefKeys.LATEST_COMMUNITY_LIST) - val latestHashtagList = parseEventOrNull(PrefKeys.LATEST_HASHTAG_LIST) - val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) - val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) - val latestTrustProviderList = parseEventOrNull(PrefKeys.LATEST_TRUST_PROVIDER_LIST) - val latestPaymentTargets = parseEventOrNull(PrefKeys.LATEST_PAYMENT_TARGETS) - val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) + val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() + val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() + + val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) + val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) + val defaultNotificationFollowListStr = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) + val defaultDiscoveryFollowListStr = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) + val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) + val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null) + + val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) + val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null) + val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null) + val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null) + val latestNip65RelayListStr = getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null) + val latestSearchRelayListStr = getString(PrefKeys.LATEST_SEARCH_RELAY_LIST, null) + val latestIndexRelayListStr = getString(PrefKeys.LATEST_INDEX_RELAY_LIST, null) + val latestRelayFeedsListStr = getString(PrefKeys.LATEST_RELAY_FEEDS_LIST, null) + val latestBlockedRelayListStr = getString(PrefKeys.LATEST_BLOCKED_RELAY_LIST, null) + val latestTrustedRelayListStr = getString(PrefKeys.LATEST_TRUSTED_RELAY_LIST, null) + val latestMuteListStr = getString(PrefKeys.LATEST_MUTE_LIST, null) + val latestPrivateHomeRelayListStr = getString(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, null) + val latestAppSpecificDataStr = getString(PrefKeys.LATEST_APP_SPECIFIC_DATA, null) + val latestChannelListStr = getString(PrefKeys.LATEST_CHANNEL_LIST, null) + val latestCommunityListStr = getString(PrefKeys.LATEST_COMMUNITY_LIST, null) + val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null) + val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null) + val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null) + val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null) + val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null) + val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null) + + Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" } + + val defaultHomeFollowList = async { parseOrNull(defaultHomeFollowListStr) ?: TopFilter.AllFollows } + val defaultStoriesFollowList = async { parseOrNull(defaultStoriesFollowListStr) ?: TopFilter.Global } + val defaultNotificationFollowList = async { parseOrNull(defaultNotificationFollowListStr) ?: TopFilter.Global } + val defaultDiscoveryFollowList = async { parseOrNull(defaultDiscoveryFollowListStr) ?: TopFilter.Global } + val zapPaymentRequestServer = async { parseOrNull(zapPaymentRequestServerStr) } + val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } + + val pendingAttestations = async { parseOrNull>(pendingAttestationsStr) ?: mapOf() } + val latestUserMetadata = async { parseEventOrNull(latestUserMetadataStr) } + val latestContactList = async { parseEventOrNull(latestContactListStr) } + val latestDmRelayList = async { parseEventOrNull(latestDmRelayListStr) } + val latestNip65RelayList = async { parseEventOrNull(latestNip65RelayListStr) } + val latestSearchRelayList = async { parseEventOrNull(latestSearchRelayListStr) } + val latestIndexRelayList = async { parseEventOrNull(latestIndexRelayListStr) } + val latestRelayFeedsList = async { parseEventOrNull(latestRelayFeedsListStr) } + val latestBlockedRelayList = async { parseEventOrNull(latestBlockedRelayListStr) } + val latestTrustedRelayList = async { parseEventOrNull(latestTrustedRelayListStr) } + val latestMuteList = async { parseEventOrNull(latestMuteListStr) } + val latestPrivateHomeRelayList = async { parseEventOrNull(latestPrivateHomeRelayListStr) } + val latestAppSpecificData = async { parseEventOrNull(latestAppSpecificDataStr) } + val latestChannelList = async { parseEventOrNull(latestChannelListStr) } + val latestCommunityList = async { parseEventOrNull(latestCommunityListStr) } + val latestHashtagList = async { parseEventOrNull(latestHashtagListStr) } + val latestGeohashList = async { parseEventOrNull(latestGeohashListStr) } + val latestEphemeralList = async { parseEventOrNull(latestEphemeralListStr) } + val latestTrustProviderList = async { parseEventOrNull(latestTrustProviderListStr) } + val latestPaymentTargets = async { parseEventOrNull(latestPaymentTargetsStr) } val lastReadPerRoute = - parseOrNull>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { - MutableStateFlow(it.value) - } ?: mapOf() + async { + parseOrNull>(lastReadPerRouteStr)?.mapValues { + MutableStateFlow(it.value) + } ?: mapOf() + } - val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) - val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() + Log.d("LocalPreferences") { "Load account from file $npub - asyncs created" } return@with AccountSettings( keyPair = keyPair, transientAccount = false, externalSignerPackageName = externalSignerPackageName, localRelayServers = MutableStateFlow(localRelayServers), - defaultFileServer = defaultFileServer, + defaultFileServer = defaultFileServer.await(), stripLocationOnUpload = stripLocationOnUpload, - defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), - defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), - defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), - defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer?.normalize()), + defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList.await()), + defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList.await()), + defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList.await()), + defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList.await()), + zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer.await()?.normalize()), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, - backupUserMetadata = latestUserMetadata, - backupContactList = latestContactList, - backupNIP65RelayList = latestNip65RelayList, - backupDMRelayList = latestDmRelayList, - backupSearchRelayList = latestSearchRelayList, - backupIndexRelayList = latestIndexRelayList, - backupRelayFeedsList = latestRelayFeedsList, - backupBlockedRelayList = latestBlockedRelayList, - backupTrustedRelayList = latestTrustedRelayList, - backupPrivateHomeRelayList = latestPrivateHomeRelayList, - backupMuteList = latestMuteList, - backupAppSpecificData = latestAppSpecificData, - backupChannelList = latestChannelList, - backupCommunityList = latestCommunityList, - backupHashtagList = latestHashtagList, - backupGeohashList = latestGeohashList, - backupEphemeralChatList = latestEphemeralList, - backupTrustProviderList = latestTrustProviderList, - lastReadPerRoute = MutableStateFlow(lastReadPerRoute), + backupUserMetadata = latestUserMetadata.await(), + backupContactList = latestContactList.await(), + backupNIP65RelayList = latestNip65RelayList.await(), + backupDMRelayList = latestDmRelayList.await(), + backupSearchRelayList = latestSearchRelayList.await(), + backupIndexRelayList = latestIndexRelayList.await(), + backupRelayFeedsList = latestRelayFeedsList.await(), + backupBlockedRelayList = latestBlockedRelayList.await(), + backupTrustedRelayList = latestTrustedRelayList.await(), + backupPrivateHomeRelayList = latestPrivateHomeRelayList.await(), + backupMuteList = latestMuteList.await(), + backupAppSpecificData = latestAppSpecificData.await(), + backupChannelList = latestChannelList.await(), + backupCommunityList = latestCommunityList.await(), + backupHashtagList = latestHashtagList.await(), + backupGeohashList = latestGeohashList.await(), + backupEphemeralChatList = latestEphemeralList.await(), + backupTrustProviderList = latestTrustProviderList.await(), + lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), - pendingAttestations = MutableStateFlow(pendingAttestations), - backupNipA3PaymentTargets = latestPaymentTargets, + pendingAttestations = MutableStateFlow(pendingAttestations.await()), + backupNipA3PaymentTargets = latestPaymentTargets.await(), ) } } - Log.d("LocalPreferences", "Loaded account from file $npub") + Log.d("LocalPreferences") { "Loaded account from file $npub" } return result } - private inline fun SharedPreferences.parseOrNull(key: String): T? { - val value = getString(key, null) + private inline fun parseOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null } @@ -558,13 +590,12 @@ object LocalPreferences { } } catch (e: Throwable) { if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e) + Log.w("LocalPreferences", "Error Decoding ${T::class.java} from Preferences with value $value", e) null } } - private inline fun SharedPreferences.parseEventOrNull(key: String): T? { - val value = getString(key, null) + private inline fun parseEventOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null } @@ -572,7 +603,7 @@ object LocalPreferences { Event.fromJson(value) as T? } catch (e: Throwable) { if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e) + Log.w("LocalPreferences", "Error Decoding ${T::class.java} from Preferences with value $value", e) null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 32bb83c9b..7fa36f404 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip51Lists.PinListState import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState @@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListD import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState +import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState @@ -134,7 +136,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -143,7 +145,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -185,6 +187,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -197,7 +200,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @@ -213,6 +216,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.containsAny @@ -234,9 +238,9 @@ import kotlin.coroutines.cancellation.CancellationException class Account( val settings: AccountSettings = AccountSettings(KeyPair()), override val signer: NostrSigner, - val geolocationFlow: StateFlow, - val nwcFilterAssembler: NWCPaymentFilterAssembler, - val otsResolverBuilder: OtsResolverBuilder, + val geolocationFlow: () -> StateFlow, + val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, + val otsResolverBuilder: () -> OtsResolver, val cache: LocalCache, val client: INostrClient, val scope: CoroutineScope, @@ -317,8 +321,11 @@ class Account( val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) + val pinState = PinListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) + val vanish = VanishRequestsState(signer, cache, client, scope) + val appSpecific = AppSpecificState(signer, cache, scope, settings) val blossomServers = BlossomServerListState(signer, cache, scope, settings) @@ -597,7 +604,7 @@ class Account( onResponse: (Response?) -> Unit, ) { val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse) - client.send(event, setOf(relay)) + client.publish(event, setOf(relay)) } suspend fun sendZapPaymentRequestFor( @@ -606,7 +613,7 @@ class Account( onResponse: (Response?) -> Unit, ) { val (event, relay) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) - client.send(event, setOf(relay)) + client.publish(event, setOf(relay)) } suspend fun createZapRequestFor( @@ -655,7 +662,7 @@ class Account( myRelayList.addAll(it.relays) } - client.send(deletionEvent, myRelayList) + client.publish(deletionEvent, myRelayList) cache.justConsumeMyOwnEvent(deletionEvent) } } @@ -669,7 +676,7 @@ class Account( if (event.pubKey != signer.pubKey) return val deletionEvent = signer.sign(DeletionEvent.build(listOf(event))) - client.send(deletionEvent, outboxRelays.flow.value + additionalRelays) + client.publish(deletionEvent, outboxRelays.flow.value + additionalRelays) cache.justConsumeMyOwnEvent(deletionEvent) } @@ -692,7 +699,7 @@ class Account( suspend fun boost(note: Note) { RepostAction.repost(note, signer)?.let { event -> - client.send(event, computeMyReactionToNote(note, event)) + client.publish(event, computeMyReactionToNote(note, event)) cache.justConsumeMyOwnEvent(event) } } @@ -714,7 +721,7 @@ class Account( event: Event, relays: Set, ) { - client.send(event, relays) + client.publish(event, relays) cache.justConsumeMyOwnEvent(event) } @@ -930,7 +937,7 @@ class Account( // download the event and send it. noteEvent.host?.let { host -> client - .downloadFirstEvent( + .fetchFirst( filters = note.relays.associateWith { relay -> listOf( @@ -943,11 +950,11 @@ class Account( }, )?.let { downloadedEvent -> val toRelays = computeRelayListToBroadcast(downloadedEvent) - client.send(downloadedEvent, toRelays) + client.publish(downloadedEvent, toRelays) } } } else { - client.send(noteEvent, computeRelayListToBroadcast(note)) + client.publish(noteEvent, computeRelayListToBroadcast(note)) } } } @@ -995,7 +1002,7 @@ class Account( val relays = outboxRelays.flow.value + commEvent.relayUrls() + community.relays + (post.author?.inboxRelays() ?: emptyList()) cache.justConsumeMyOwnEvent(signedEvent) - client.send(signedEvent, relays) + client.publish(signedEvent, relays) } fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } @@ -1003,24 +1010,49 @@ class Account( fun sendAutomatic(event: Event?) { if (event == null) return cache.justConsumeMyOwnEvent(event) - client.send(event, computeRelayListToBroadcast(event)) + client.publish(event, computeRelayListToBroadcast(event)) + } + + suspend fun sendWebBookmark( + url: String, + title: String?, + description: String, + hashtags: List = emptyList(), + ) { + if (!isWriteable()) return + + val template = WebBookmarkEvent.build(url, title, description, tags = hashtags) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) + } + + suspend fun deleteWebBookmark(event: WebBookmarkEvent) { + if (!isWriteable()) return + + val template = DeletionEvent.build(listOf(event)) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) } fun sendMyPublicAndPrivateOutbox(event: Event?) { if (event == null) return cache.justConsumeMyOwnEvent(event) - client.send(event, outboxRelays.flow.value) + client.publish(event, outboxRelays.flow.value) } fun sendMyPublicAndPrivateOutbox(events: List) { events.forEach { - client.send(it, outboxRelays.flow.value) + client.publish(it, outboxRelays.flow.value) cache.justConsumeMyOwnEvent(it) } } fun sendLiterallyEverywhere(event: Event) { - client.send(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value) + client.publish(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value) cache.justConsumeMyOwnEvent(event) } @@ -1037,7 +1069,7 @@ class Account( cache.justConsumeMyOwnEvent(signedEvent) - client.send(signedEvent, computeRelayListToBroadcast(signedEvent)) + client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) } } @@ -1073,10 +1105,10 @@ class Account( val relayList = computeRelayListToBroadcast(signedEvent) - client.send(data, relayList = relayList) + client.publish(data, relayList = relayList) cache.justConsumeMyOwnEvent(data) - client.send(signedEvent, relayList = relayList) + client.publish(signedEvent, relayList = relayList) cache.justConsumeMyOwnEvent(signedEvent) return cache.getNoteIfExists(signedEvent.id) @@ -1097,8 +1129,8 @@ class Account( signedEvent: FileStorageHeaderEvent, relayList: Set, ) { - client.send(data, relayList = relayList) - client.send(signedEvent, relayList = relayList) + client.publish(data, relayList = relayList) + client.publish(signedEvent, relayList = relayList) } fun sendHeader( @@ -1106,7 +1138,7 @@ class Account( relayList: Set, onReady: (Note) -> Unit, ) { - client.send(signedEvent, relayList = relayList) + client.publish(signedEvent, relayList = relayList) cache.justConsumeMyOwnEvent(signedEvent) cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } @@ -1253,7 +1285,7 @@ class Account( ) { val event = signer.sign(template) cache.justConsumeMyOwnEvent(event) - client.send(event, relayList) + client.publish(event, relayList) } suspend fun signAndSendPrivatelyOrBroadcast( @@ -1264,9 +1296,9 @@ class Account( cache.justConsumeMyOwnEvent(event) val relays = relayList(event) if (!relays.isNullOrEmpty()) { - client.send(event, relays.toSet()) + client.publish(event, relays.toSet()) } else { - client.send(event, computeRelayListToBroadcast(event)) + client.publish(event, computeRelayListToBroadcast(event)) } return event } @@ -1286,9 +1318,9 @@ class Account( val relayList = computeRelayListToBroadcast(note) - client.send(event, relayList) + client.publish(event, relayList) - broadcast.forEach { client.send(it, relayList) } + broadcast.forEach { client.publish(it, relayList) } return event } @@ -1310,9 +1342,9 @@ class Account( val relayList = computeRelayListToBroadcast(note) - client.send(event, relayList) + client.publish(event, relayList) - broadcast.forEach { client.send(it, relayList) } + broadcast.forEach { client.publish(it, relayList) } return event } @@ -1344,7 +1376,7 @@ class Account( extraNotesToBroadcast: List, ) { cache.justConsumeMyOwnEvent(event) - extraNotesToBroadcast.forEach { client.send(it, relays) } + extraNotesToBroadcast.forEach { client.publish(it, relays) } } suspend fun createAndSendDraftIgnoreErrors( @@ -1376,9 +1408,9 @@ class Account( val relayList = (privateStorageRelayList.flow.value + localRelayList.flow.value + extraRelays).toSet() if (relayList.isNotEmpty()) { - client.send(draftEvent, relayList) + client.publish(draftEvent, relayList) broadcast.forEach { - client.send(it, relayList.toSet()) + client.publish(it, relayList.toSet()) } } } @@ -1405,8 +1437,8 @@ class Account( cache.justConsumeMyOwnEvent(deletionEvent) if (relayList.isNotEmpty()) { - client.send(deletedDraft, relayList) - client.send(deletionEvent, relayList) + client.publish(deletedDraft, relayList) + client.publish(deletionEvent, relayList) } } @@ -1429,9 +1461,9 @@ class Account( val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value if (relayList.isNotEmpty()) { - client.send(event, relayList + noteRelays) + client.publish(event, relayList + noteRelays) } else { - client.send(event, outboxRelays.flow.value + noteRelays) + client.publish(event, outboxRelays.flow.value + noteRelays) } cache.justConsumeMyOwnEvent(event) } @@ -1455,9 +1487,9 @@ class Account( val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value if (relayList.isNotEmpty()) { - client.send(event, relayList + noteRelays) + client.publish(event, relayList + noteRelays) } else { - client.send(event, outboxRelays.flow.value + noteRelays) + client.publish(event, outboxRelays.flow.value + noteRelays) } cache.justConsumeMyOwnEvent(event) } @@ -1518,9 +1550,9 @@ class Account( } else { val it = signer.sign(template) cache.justConsumeMyOwnEvent(it) - client.send(it, relayList = relayList) + client.publish(it, relayList = relayList) - mapEntitiesToNotes(quotes).forEach { it.event?.let { client.send(it, relayList = relayList) } } + mapEntitiesToNotes(quotes).forEach { it.event?.let { client.publish(it, relayList = relayList) } } } } @@ -1563,9 +1595,9 @@ class Account( } else { val it = signer.sign(template) cache.justConsumeMyOwnEvent(it) - client.send(it, relayList = relayList) + client.publish(it, relayList = relayList) - broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } } + broadcastNotes.forEach { it.event?.let { client.publish(it, relayList = relayList) } } } } @@ -1590,8 +1622,8 @@ class Account( val newEvent = signer.sign(template) cache.justConsumeMyOwnEvent(newEvent) - client.send(newEvent, relayList = relays) - client.send(bountyEvent, relayList = relays) + client.publish(newEvent, relayList = relays) + client.publish(bountyEvent, relayList = relays) } suspend fun sendEdit( @@ -1618,9 +1650,9 @@ class Account( val note = cache.getOrCreateNote(event.id) val relayList = computeRelayListToBroadcast(note) - client.send(event, relayList = relayList) + client.publish(event, relayList = relayList) - broadcast.forEach { client.send(it, relayList) } + broadcast.forEach { client.publish(it, relayList) } } override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { @@ -1631,7 +1663,7 @@ class Account( val destinationRelays = recipient?.let { cache.getOrCreateUser(it).dmInboxRelays() } ?: emptyList() cache.justConsumeMyOwnEvent(newEvent) - client.send(newEvent, outboxRelays.flow.value + destinationRelays) + client.publish(newEvent, outboxRelays.flow.value + destinationRelays) } override suspend fun sendNip17EncryptedFile(template: EventTemplate) { @@ -1649,7 +1681,7 @@ class Account( override suspend fun sendGiftWraps(wraps: List) { wraps.forEach { wrap -> val relayList = computeRelayListToBroadcast(wrap) - client.send(wrap, relayList) + client.publish(wrap, relayList) } } @@ -1670,7 +1702,7 @@ class Account( } val relayList = computeRelayListToBroadcast(wrap) - client.send(wrap, relayList) + client.publish(wrap, relayList) } } @@ -1783,6 +1815,43 @@ class Account( cache.justConsumeMyOwnEvent(event) } + suspend fun addPin(note: Note) { + if (!isWriteable() || note.isDraft()) return + + sendMyPublicAndPrivateOutbox(pinState.addPin(note)) + } + + suspend fun removePin(note: Note) { + if (!isWriteable() || note.isDraft()) return + + val event = pinState.removePin(note) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) + } + } + + suspend fun createAddPinEvent(note: Note): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = pinState.addPin(note) + val relays = outboxRelays.flow.value + + return event to relays + } + + suspend fun createRemovePinEvent(note: Note): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = pinState.removePin(note) ?: return null + val relays = outboxRelays.flow.value + + return event to relays + } + + fun consumePinEvent(event: Event) { + cache.justConsumeMyOwnEvent(event) + } + suspend fun createAuthEvent( relay: NormalizedRelayUrl, challenge: String, @@ -1812,7 +1881,7 @@ class Account( onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, ) { val relays = nip65RelayList.inboxFlow.value.toSet() - val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) + val request = signer.sign(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays)) val relayList = dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null } @@ -1821,7 +1890,7 @@ class Account( cache.justConsumeMyOwnEvent(request) onReady(request) delay(100) - client.send(request, relayList) + client.publish(request, relayList) } fun cachedDecryptContent(note: Note): String? = cachedDecryptContent(note.event) @@ -1979,6 +2048,31 @@ class Account( suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) + suspend fun requestToVanish( + relays: List, + reason: String, + createdAt: Long, + ) { + if (!isWriteable() || relays.isEmpty()) return + + val template = RequestToVanishEvent.build(relays, reason, createdAt) + val signedEvent = signer.sign(template) + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, outboxRelays.flow.value + relays.toSet()) + } + + suspend fun requestToVanishFromEverywhere( + reason: String, + createdAt: Long, + ) { + if (!isWriteable()) return + + val template = RequestToVanishEvent.buildVanishFromEverywhere(reason, createdAt) + val signedEvent = signer.sign(template) + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value) + } + suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 6ed5a2cf1..1bf73e1ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -57,6 +56,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 75c9e3aa7..d5033201b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -87,7 +87,7 @@ class AntiSpamFilter { val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay)) val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay)) - Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2") + Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" } // Log down offenders val spammer = logOffender(hash, event) @@ -114,7 +114,7 @@ class AntiSpamFilter { val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay)) val link2 = njumpLink(NEvent.create(event.id, null, null, relay)) - Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2") + Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" } // Log down offenders val spammer = logOffender(hash, event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt index 717289460..ff65202ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.cache.CacheCollectors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 16d054ea6..c138c431f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.model import android.util.LruCache @@ -25,6 +27,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel @@ -59,9 +62,6 @@ import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -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.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -173,6 +173,9 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent @@ -181,6 +184,8 @@ import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -192,22 +197,27 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -336,10 +346,10 @@ object LocalCache : ILocalCache, ICacheProvider { } }.buffer(kotlinx.coroutines.channels.Channel.CONFLATED) - fun observeEvents(filter: Filter): Flow> = + fun observeEvents(filter: Filter): Flow> = callbackFlow { val cachedFilter = - EventListMatchingFilter(filter, this@LocalCache::filter) { + EventListMatchingFilter(filter, this@LocalCache::filter) { trySend(it) } @@ -352,7 +362,8 @@ object LocalCache : ILocalCache, ICacheProvider { } }.buffer(kotlinx.coroutines.channels.Channel.CONFLATED) - fun observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstNotNullOfOrNull { it as? T } } + @Suppress("UNCHECKED_CAST") + fun observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstOrNull() } fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() } @@ -732,6 +743,12 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: RequestToVanishEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( event: NIP90UserDiscoveryRequestEvent, relay: NormalizedRelayUrl?, @@ -1157,6 +1174,18 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( + event: RootSiteEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: NamedSiteEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: ChannelListEvent, relay: NormalizedRelayUrl?, @@ -1337,6 +1366,18 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + private fun consume( + event: RelayDiscoveryEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: RelayMonitorEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: StatusEvent, relay: NormalizedRelayUrl?, @@ -1433,6 +1474,12 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( + event: WebBookmarkEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( event: CalendarDateSlotEvent, relay: NormalizedRelayUrl?, @@ -1899,7 +1946,7 @@ object LocalCache : ILocalCache, ICacheProvider { if (new) { val channel = checkGetOrCreatePublicChatChannel(channelId) if (channel == null) { - Log.w("LocalCache", "Unable to create public chat channel for event ${event.toJson()}") + Log.w("LocalCache") { "Unable to create public chat channel for event ${event.toJson()}" } return false } @@ -1987,7 +2034,7 @@ object LocalCache : ILocalCache, ICacheProvider { val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { - Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") + Log.e("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" } return false } @@ -2110,6 +2157,12 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: ChatEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( event: PollEvent, relay: NormalizedRelayUrl?, @@ -2484,7 +2537,7 @@ object LocalCache : ILocalCache, ICacheProvider { suspend fun findEarliestOtsForNote( note: Note, - otsVerifCache: VerificationStateCache, + otsVerifCacheBuilder: () -> VerificationStateCache, ): Long? { checkNotInMainThread() @@ -2495,7 +2548,7 @@ object LocalCache : ILocalCache, ICacheProvider { notes.mapNotNull { _, item -> val noteEvent = item.event if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) { - val cachedTime = (otsVerifCache.justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime + val cachedTime = (otsVerifCacheBuilder().justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime if (cachedTime != null) { if (minTime == null || cachedTime < (minTime ?: Long.MAX_VALUE)) { minTime = cachedTime @@ -2511,7 +2564,7 @@ object LocalCache : ILocalCache, ICacheProvider { } candidates.forEach { noteEvent -> - (otsVerifCache.cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime -> + (otsVerifCacheBuilder().cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime -> if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) { minTime = stampedTime } @@ -2550,17 +2603,17 @@ object LocalCache : ILocalCache, ICacheProvider { } fun cleanMemory() { - Log.d("LargeCache", "Notes cleanup started. Current size: ${notes.size()}") + Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() - Log.d("LargeCache", "Notes cleanup completed. Remaining size: ${notes.size()}") + Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${notes.size()}" } - Log.d("LargeCache", "Addressables cleanup started. Current size: ${addressables.size()}") + Log.d("LargeCache") { "Addressables cleanup started. Current size: ${addressables.size()}" } addressables.cleanUp() - Log.d("LargeCache", "Addressables cleanup completed. Remaining size: ${addressables.size()}") + Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${addressables.size()}" } - Log.d("LargeCache", "Users cleanup started. Current size: ${users.size()}") + Log.d("LargeCache") { "Users cleanup started. Current size: ${users.size()}" } users.cleanUp() - Log.d("LargeCache", "Users cleanup completed. Remaining size: ${users.size()}") + Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${users.size()}" } } fun cleanObservers() { @@ -2892,7 +2945,7 @@ object LocalCache : ILocalCache, ICacheProvider { event.checkSignature() } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("Event Verification Failed", "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}") + Log.w("Event Verification Failed") { "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}" } } false } else { @@ -2966,7 +3019,7 @@ object LocalCache : ILocalCache, ICacheProvider { getNoteIfExists(deletionEvent.id)?.let { note -> if (!note.hasRelay(relay.url)) { if (isDebug) { - Log.d("LocalCache", "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}") + Log.d("LocalCache") { "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}" } } relay.sendIfConnected(EventCmd(deletionEvent)) note.addRelay(relay.url) @@ -2983,7 +3036,7 @@ object LocalCache : ILocalCache, ICacheProvider { note.event?.let { existingEvent -> if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.url) && !deletionIndex.hasBeenDeleted(event) && !event.isExpired()) { if (isDebug) { - Log.d("LocalCache", "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}") + Log.d("LocalCache") { "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}" } } relay.sendIfConnected(EventCmd(existingEvent)) @@ -3146,6 +3199,8 @@ object LocalCache : ILocalCache, ICacheProvider { is GitReplyEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) + is RootSiteEvent -> consume(event, relay, wasVerified) + is NamedSiteEvent -> consume(event, relay, wasVerified) is ChessGameEvent -> consume(event, relay, wasVerified) is RelayFeedsListEvent -> consume(event, relay, wasVerified) is JesterEvent -> consume(event, relay, wasVerified) @@ -3185,10 +3240,14 @@ object LocalCache : ILocalCache, ICacheProvider { is PinListEvent -> consume(event, relay, wasVerified) is PublicMessageEvent -> consume(event, relay, wasVerified) is PeopleListEvent -> consume(event, relay, wasVerified) + is RequestToVanishEvent -> consume(event, relay, wasVerified) is CodeSnippetEvent -> consume(event, relay, wasVerified) is ZapPollEvent -> consume(event, relay, wasVerified) + is ChatEvent -> consume(event, relay, wasVerified) is PollEvent -> consume(event, relay, wasVerified) is PollResponseEvent -> consume(event, relay, wasVerified) + is RelayDiscoveryEvent -> consume(event, relay, wasVerified) + is RelayMonitorEvent -> consume(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) is ContactCardEvent -> consume(event, relay, wasVerified) is RelaySetEvent -> consume(event, relay, wasVerified) @@ -3209,9 +3268,10 @@ object LocalCache : ILocalCache, ICacheProvider { is VideoShortEvent -> consume(event, relay, wasVerified) is VoiceEvent -> consume(event, relay, wasVerified) is VoiceReplyEvent -> consume(event, relay, wasVerified) + is WebBookmarkEvent -> consume(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified) is PaymentTargetsEvent -> consume(event, relay, wasVerified) - else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false } + else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index c1981297e..467663f7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -151,19 +151,19 @@ class UiSettingsFlow( } companion object { - fun build(torSettings: UiSettings): UiSettingsFlow = + fun build(uiSettings: UiSettings): UiSettingsFlow = UiSettingsFlow( - MutableStateFlow(torSettings.theme), - MutableStateFlow(torSettings.preferredLanguage), - MutableStateFlow(torSettings.automaticallyShowImages), - MutableStateFlow(torSettings.automaticallyStartPlayback), - MutableStateFlow(torSettings.automaticallyShowUrlPreview), - MutableStateFlow(torSettings.automaticallyHideNavigationBars), - MutableStateFlow(torSettings.automaticallyShowProfilePictures), - MutableStateFlow(torSettings.dontShowPushNotificationSelector), - MutableStateFlow(torSettings.dontAskForNotificationPermissions), - MutableStateFlow(torSettings.featureSet), - MutableStateFlow(torSettings.gallerySet), + MutableStateFlow(uiSettings.theme), + MutableStateFlow(uiSettings.preferredLanguage), + MutableStateFlow(uiSettings.automaticallyShowImages), + MutableStateFlow(uiSettings.automaticallyStartPlayback), + MutableStateFlow(uiSettings.automaticallyShowUrlPreview), + MutableStateFlow(uiSettings.automaticallyHideNavigationBars), + MutableStateFlow(uiSettings.automaticallyShowProfilePictures), + MutableStateFlow(uiSettings.dontShowPushNotificationSelector), + MutableStateFlow(uiSettings.dontAskForNotificationPermissions), + MutableStateFlow(uiSettings.featureSet), + MutableStateFlow(uiSettings.gallerySet), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 754d801de..30379256b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -31,8 +31,9 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -44,10 +45,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update class AccountCacheState( - val geolocationFlow: StateFlow, - val nwcFilterAssembler: NWCPaymentFilterAssembler, + val geolocationFlow: () -> StateFlow, + val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val contentResolverFn: () -> ContentResolver, - val otsResolverBuilder: OtsResolverBuilder, + val otsResolverBuilder: () -> OtsResolver, val cache: LocalCache, val client: INostrClient, ) { @@ -91,9 +92,11 @@ class AccountCacheState( val cached = accounts.value[signer.pubKey] if (cached != null) return cached + val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME) + return Account( settings = accountSettings, - signer = signer, + signer = signerWithClientTag, geolocationFlow = geolocationFlow, nwcFilterAssembler = nwcFilterAssembler, otsResolverBuilder = otsResolverBuilder, @@ -122,4 +125,8 @@ class AccountCacheState( emptyMap() } } + + companion object { + const val CLIENT_TAG_NAME = "Amethyst" + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt index 80c35d16f..255230349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt @@ -91,7 +91,7 @@ class PrivateStorageRelayListState( init { settings.backupPrivateHomeRelayList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved private home relay list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(event) @@ -101,7 +101,7 @@ class PrivateStorageRelayListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") getPrivateOutboxRelayListFlow().collect { noteState -> - Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Private Home Relay List for ${signer.pubKey}" } (noteState.note.event as? PrivateOutboxRelayListEvent)?.let { settings.updatePrivateHomeRelayList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt index 7c7a1c235..d3179dd07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -132,7 +132,7 @@ class UserMetadataState( init { settings.backupUserMetadata?.let { - Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved user metadata ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } @@ -142,7 +142,7 @@ class UserMetadataState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Kind 0 Collector Start") getUserMetadataFlow().collect { - Log.d("AccountRegisterObservers", "Updating Kind 0 ${user.toBestDisplayName()}") + Log.d("AccountRegisterObservers") { "Updating Kind 0 ${user.toBestDisplayName()}" } (it.note.event as? MetadataEvent)?.let { settings.updateUserMetadata(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt index 35fd21cd4..d4b257652 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt @@ -162,7 +162,7 @@ class Kind3FollowListState( init { settings.backupContactList?.let { - Log.d("AccountRegisterObservers", "Loading saved ${it.tags.size} contacts") + Log.d("AccountRegisterObservers") { "Loading saved ${it.tags.size} contacts" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } @@ -172,7 +172,7 @@ class Kind3FollowListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Kind 3 Collector Start") getFollowListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Kind 3 ${signer.pubKey}" } (it.note.event as? ContactListEvent)?.let { settings.updateContactListTo(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt index 3e3222b67..f082fa108 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn class IncomingOtsEventVerifier( - private val otsVerifCache: VerificationStateCache, + private val otsVerifCache: () -> VerificationStateCache, private val cache: LocalCache, private val scope: CoroutineScope, ) { @@ -52,7 +52,7 @@ class IncomingOtsEventVerifier( suspend fun consume(note: Note) { note.event?.let { event -> if (event is OtsEvent) { - otsVerifCache.cacheVerify(event) + otsVerifCache().cacheVerify(event) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt index c8b537f98..3396f6e31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope @@ -37,7 +37,7 @@ import java.util.Base64 class OtsState( val signer: NostrSigner, val cache: LocalCache, - val otsResolver: OtsResolverBuilder, + val otsResolver: () -> OtsResolver, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -55,10 +55,10 @@ class OtsState( } suspend fun updateAttestations(): List { - Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") + Log.d("Pending Attestations") { "Updating ${settings.pendingAttestations.value.size} pending attestations" } return settings.pendingAttestations.value.toList().mapNotNull { (key, value) -> - val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver.build()) + val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver()) if (otsState != null) { val hint = cache.getNoteIfExists(key)?.toEventHint() @@ -96,7 +96,7 @@ class OtsState( Base64.getEncoder().encodeToString( OtsEvent.stamp( id, - otsResolver.build(), + otsResolver(), ), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt index 6ddb03366..308688d6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt @@ -46,7 +46,7 @@ fun loadRelayInfo( value = it }, onError = { url, errorCode, exceptionMessage -> - Log.e("RelayInfo", "Error loading relay info for ${relay.url}: $errorCode - $exceptionMessage") + Log.e("RelayInfo") { "Error loading relay info for ${relay.url}: $errorCode - $exceptionMessage" } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt index 324ddde21..5d1ebccea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt @@ -88,7 +88,7 @@ class DmRelayListState( init { settings.backupDMRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved DM Relay List ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) @@ -98,7 +98,7 @@ class DmRelayListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") getDMRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating DM Relay List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating DM Relay List for ${signer.pubKey}" } (it.note.event as? ChatMessageRelayListEvent)?.let { settings.updateDMRelayList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index bb40be6cf..616ad24e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -64,7 +64,7 @@ import kotlinx.coroutines.launch */ class NwcSignerState( val signer: NostrSigner, - val nwcFilterAssembler: NWCPaymentFilterAssembler, + val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val cache: LocalCache, val scope: CoroutineScope, val nip47Setup: MutableStateFlow, @@ -163,11 +163,13 @@ class NwcSignerState( relay = walletService.relayUri, ) - nwcFilterAssembler.subscribe(filter) + val assembler = nwcFilterAssembler() + + assembler.subscribe(filter) scope.launch(Dispatchers.IO) { delay(60000) - nwcFilterAssembler.unsubscribe(filter) + assembler.unsubscribe(filter) } cache.consume(event, null, true, walletService.relayUri) { @@ -204,11 +206,13 @@ class NwcSignerState( relay = walletService.relayUri, ) - nwcFilterAssembler.subscribe(filter) + val assembler = nwcFilterAssembler() + + assembler.subscribe(filter) scope.launch(Dispatchers.IO) { delay(60000) // waits 1 minute to complete payment. - nwcFilterAssembler.unsubscribe(filter) + assembler.unsubscribe(filter) } cache.consume(event, zappedNote, true, walletService.relayUri) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index 9a438a880..db9004b75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -20,282 +20,4 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists -import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.NoteState -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combineTransform -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn - -@Stable -class BookmarkListState( - val signer: NostrSigner, - val cache: LocalCache, - val scope: CoroutineScope, -) { - class BookmarkList( - val public: List = emptyList(), - val private: List = emptyList(), - ) - - // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress()) - - fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey) - - fun getBookmarkListFlow(): StateFlow = bookmarkList.flow().metadata.stateFlow - - fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent - - fun publicBookmarks(note: Note): List { - val noteEvent = note.event as? BookmarkListEvent - return noteEvent?.publicBookmarks() ?: emptyList() - } - - suspend fun privateBookmarks(note: Note): List { - val noteEvent = note.event as? BookmarkListEvent - return noteEvent?.privateBookmarks(signer) ?: emptyList() - } - - @OptIn(FlowPreview::class) - val publicBookmarks: StateFlow> = - getBookmarkListFlow() - .map { noteState -> - publicBookmarks(noteState.note) - }.onStart { - emit(publicBookmarks(bookmarkList)) - }.debounce(100) - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - @OptIn(FlowPreview::class) - val privateBookmarks: StateFlow> = - getBookmarkListFlow() - .map { noteState -> - privateBookmarks(noteState.note) - }.onStart { - emit(privateBookmarks(bookmarkList)) - }.debounce(100) - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val publicBookmarkEventIdSet = - publicBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is EventBookmark) it.eventId else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val publicBookmarkAddressIdSet = - publicBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is AddressBookmark) it.address else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val privateBookmarkEventIdSet = - privateBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is EventBookmark) it.eventId else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val privateBookmarkAddressIdSet = - privateBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is AddressBookmark) it.address else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - fun bookmarkList( - privateBookmarks: List, - publicBookmarks: List, - ): BookmarkList = - BookmarkList( - public = - publicBookmarks - .mapNotNull { - when (it) { - is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) - is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) - } - }.reversed(), - private = - privateBookmarks - .mapNotNull { - when (it) { - is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) - is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) - } - }.reversed(), - ) - - @OptIn(FlowPreview::class) - val bookmarks: StateFlow = - combineTransform(privateBookmarks, publicBookmarks) { private, public -> - emit(bookmarkList(private, public)) - }.onStart { - emit(bookmarkList(privateBookmarks.value, publicBookmarks.value)) - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - BookmarkList(), - ) - - fun isInPrivateBookmarks(note: Note): Boolean { - if (!signer.isWriteable()) return false - - return if (note is AddressableNote) { - privateBookmarkAddressIdSet.value.contains(note.address) - } else { - privateBookmarkEventIdSet.value.contains(note.idHex) - } - } - - fun isInPublicBookmarks(note: Note): Boolean = - if (note is AddressableNote) { - publicBookmarkAddressIdSet.value.contains(note.address) - } else { - publicBookmarkEventIdSet.value.contains(note.idHex) - } - - suspend fun addBookmark( - note: Note, - isPrivate: Boolean, - ): BookmarkListEvent { - val bookmarkList = getBookmarkList() - - return if (bookmarkList == null) { - if (note is AddressableNote) { - BookmarkListEvent.create( - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } else { - BookmarkListEvent.create( - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } - } else { - if (note is AddressableNote) { - BookmarkListEvent.add( - earlierVersion = bookmarkList, - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } else { - BookmarkListEvent.add( - earlierVersion = bookmarkList, - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } - } - } - - suspend fun removeBookmark( - note: Note, - isPrivate: Boolean, - ): BookmarkListEvent? { - val bookmarkList = getBookmarkList() - - return if (bookmarkList != null) { - if (note is AddressableNote) { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } else { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } - } else { - null - } - } - - suspend fun removeBookmark(note: Note): BookmarkListEvent? { - val bookmarkList = getBookmarkList() - - return if (bookmarkList != null) { - if (note is AddressableNote) { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - signer = signer, - ) - } else { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - signer = signer, - ) - } - } else { - null - } - } -} +typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt new file mode 100644 index 000000000..fbe711408 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.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.model.nip51Lists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +@Stable +class PinListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + val pinList = cache.getOrCreateAddressableNote(PinListEvent.createPinAddress(signer.pubKey)) + + fun getPinListFlow(): StateFlow = pinList.flow().metadata.stateFlow + + fun getPinList(): PinListEvent? = pinList.event as? PinListEvent + + fun pinnedEvents(note: Note): List { + val noteEvent = note.event as? PinListEvent + return noteEvent?.pinnedEvents() ?: emptyList() + } + + @OptIn(FlowPreview::class) + val pinnedNotes: StateFlow> = + getPinListFlow() + .map { noteState -> + pinnedEvents(noteState.note) + }.onStart { + emit(pinnedEvents(pinList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val pinnedEventIdSet: StateFlow> = + pinnedNotes + .map { pins -> + pins.map { it.eventId }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(FlowPreview::class) + val pinnedNotesList: StateFlow> = + pinnedNotes + .map { pins -> + pins.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed() + }.onStart { + emit( + pinnedNotes.value.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed(), + ) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun isPinned(note: Note): Boolean = pinnedEventIdSet.value.contains(note.idHex) + + suspend fun addPin(note: Note): PinListEvent { + val currentList = getPinList() + val pin = EventBookmark(note.idHex, note.relayHintUrl()) + + return if (currentList == null) { + PinListEvent.create( + pin = pin, + signer = signer, + ) + } else { + PinListEvent.add( + earlierVersion = currentList, + pin = pin, + signer = signer, + ) + } + } + + suspend fun removePin(note: Note): PinListEvent? { + val currentList = getPinList() ?: return null + val pin = EventBookmark(note.idHex, note.relayHintUrl()) + + return PinListEvent.remove( + earlierVersion = currentList, + pin = pin, + signer = signer, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt index f126d6461..8bc4cec59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt @@ -93,7 +93,7 @@ class BlockedRelayListState( init { settings.backupBlockedRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved Blocked relay list ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved Blocked relay list ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } } @@ -101,7 +101,7 @@ class BlockedRelayListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Blocked Relay List Collector Start") getBlockedRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Blocked Relay List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Blocked Relay List for ${signer.pubKey}" } (it.note.event as? BlockedRelayListEvent)?.let { settings.updateBlockedRelayList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt index 669eb5898..a17373083 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt @@ -106,7 +106,7 @@ class GeohashListState( init { settings.backupGeohashList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved Geohash list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(event) @@ -116,7 +116,7 @@ class GeohashListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Geohash List Collector Start") getGeohashListFlow().collect { noteState -> - Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Geohash List for ${signer.pubKey}" } (noteState.note.event as? GeohashListEvent)?.let { settings.updateGeohashListTo(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt index 7d5d490ec..b43e1d319 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt @@ -106,7 +106,7 @@ class HashtagListState( init { settings.backupHashtagList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved Hashtag list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(event) @@ -116,7 +116,7 @@ class HashtagListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Hashtag List Collector Start") getHashtagListFlow().collect { - Log.d("AccountRegisterObservers", "Hashtag List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Hashtag List for ${signer.pubKey}" } (it.note.event as? HashtagListEvent)?.let { settings.updateHashtagListTo(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index aef44173c..4f739918e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -103,7 +103,7 @@ class IndexerRelayListState( init { settings.backupIndexRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved index relay list ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved index relay list ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } } @@ -111,7 +111,7 @@ class IndexerRelayListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Index Relay List Collector Start") getIndexerRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Index Relay List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Index Relay List for ${signer.pubKey}" } (it.note.event as? IndexerRelayListEvent)?.let { settings.updateIndexRelayList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt index f71705966..3115c9383 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -141,7 +141,7 @@ class MuteListState( init { settings.backupMuteList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved mute list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(event) @@ -151,7 +151,7 @@ class MuteListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Mute List Collector Start") getMuteListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Mute List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Mute List for ${signer.pubKey}" } (it.note.event as? MuteListEvent)?.let { settings.updateMuteList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt index de9b52c16..7860cfd29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt @@ -115,7 +115,7 @@ class RelayFeedListState( init { settings.backupRelayFeedsList?.let { - Log.d("AccountRegisterObservers", "Loading saved relay feeds list ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved relay feeds list ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } } @@ -123,7 +123,7 @@ class RelayFeedListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Relay feeds list Collector Start") getRelayFeedsListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Relay feeds list for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Relay feeds list for ${signer.pubKey}" } (it.note.event as? RelayFeedsListEvent)?.let { settings.updateRelayFeedList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt index e7301e059..961f8f160 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt @@ -42,6 +42,7 @@ open class GenericRelayListCache( suspend fun relays(event: T) = cachedPrivateLists.mergeTagList(event).relaySet() + @Suppress("UNCHECKED_CAST") fun fastStartValueForRelayList(note: Note): RelayListCard { val noteEvent = note.event as? T return if (noteEvent != null) { @@ -52,6 +53,7 @@ open class GenericRelayListCache( } @OptIn(ExperimentalCoroutinesApi::class) + @Suppress("UNCHECKED_CAST") fun observeDecryptedRelayList(note: Note): Flow = note .flow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 5824016c3..4fbba2ef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -103,7 +103,7 @@ class SearchRelayListState( init { settings.backupSearchRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved search relay list ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } } @@ -111,7 +111,7 @@ class SearchRelayListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Search Relay List Collector Start") getSearchRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Search Relay List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Search Relay List for ${signer.pubKey}" } (it.note.event as? SearchRelayListEvent)?.let { settings.updateSearchRelayList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt index bce1af25d..65a5c80c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -90,7 +90,7 @@ class TrustedRelayListState( init { settings.backupTrustedRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved Trusted relay list ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved Trusted relay list ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } } @@ -98,7 +98,7 @@ class TrustedRelayListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Trusted Relay List Collector Start") getTrustedRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Trusted Relay List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating Trusted Relay List for ${signer.pubKey}" } (it.note.event as? TrustedRelayListEvent)?.let { settings.updateTrustedRelayList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt new file mode 100644 index 000000000..f8123a60f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip62Vanish/VanishRequestsState.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip62Vanish + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +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.nip62RequestToVanish.RequestToVanishEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +@Stable +data class VanishEventItem( + val event: RequestToVanishEvent, + val relays: List, + val isAllRelays: Boolean, + val complianceResults: MutableStateFlow> = + MutableStateFlow( + relays.associateWith { ComplianceStatus.UNTESTED }, + ), +) + +enum class ComplianceStatus { + UNTESTED, + TESTING, + COMPLIANT, + NON_COMPLIANT, + ERROR, +} + +class VanishRequestsState( + val signer: NostrSigner, + val cache: LocalCache, + val client: INostrClient, + val scope: CoroutineScope, +) { + val noteFlow = + cache + .observeNotes( + Filter( + kinds = listOf(RequestToVanishEvent.KIND), + authors = listOf(signer.pubKey), + ), + ).stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + ) + + val testableFlow = + noteFlow + .map { notes -> + notes.mapNotNull { + val noteEvent = it.event + if (noteEvent is RequestToVanishEvent) { + VanishEventItem(noteEvent, noteEvent.vanishFromRelays(), noteEvent.vanishFromAllRelays()) + } else { + null + } + } + }.stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(10000), + initialValue = emptyList(), + ) + + suspend fun testVanishCompliance( + item: VanishEventItem, + relay: NormalizedRelayUrl, + ) { + item.complianceResults.update { + it + (relay to ComplianceStatus.TESTING) + } + + try { + val foundEvent = + withContext(Dispatchers.IO) { + client.fetchFirst( + relay = relay, + filter = + Filter( + authors = listOf(item.event.pubKey), + until = item.event.createdAt - 1, + limit = 1, + ), + ) + } + + item.complianceResults.update { + it + ( + relay to + if (foundEvent != null) { + ComplianceStatus.NON_COMPLIANT + } else { + ComplianceStatus.COMPLIANT + } + ) + } + } catch (_: Exception) { + item.complianceResults.update { + it + (relay to ComplianceStatus.ERROR) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt index c0a913fd6..6add6cd3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -144,7 +144,7 @@ class Nip65RelayListState( init { settings.backupNIP65RelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved nip65 relay list ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } } @@ -152,7 +152,7 @@ class Nip65RelayListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") getNIP65RelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating NIP-65 List for ${signer.pubKey}" } (it.note.event as? AdvertisedRelayListEvent)?.let { settings.updateNIP65RelayList(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt index a4fd1285a..97a5c2dfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt @@ -155,7 +155,7 @@ class CommunityListState( init { settings.backupCommunityList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved Community list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(event) @@ -165,7 +165,7 @@ class CommunityListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Community List Collector Start") getCommunityListFlow().collect { - Log.d("AccountRegisterObservers", "Community List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Community List for ${signer.pubKey}" } (it.note.event as? CommunityListEvent)?.let { settings.updateCommunityListTo(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt index d68d64f9c..8a745aed6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -65,7 +65,7 @@ class AppSpecificState( init { if (settings.isWriteable()) { settings.backupAppSpecificData?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved app specific data ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(event) @@ -84,7 +84,7 @@ class AppSpecificState( Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") getAppSpecificDataFlow().collect { try { - Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating AppSpecificData for ${signer.pubKey}" } (it.note.event as? AppSpecificDataEvent)?.let { val decrypted = signer.decrypt(it.content, it.pubKey) try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt new file mode 100644 index 000000000..306dc530a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.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.model.nip86RelayManagement + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync + +private const val CONTENT_TYPE_STRING = "application/nostr+json+rpc" +private val CONTENT_TYPE = CONTENT_TYPE_STRING.toMediaType() + +class Nip86Retriever( + val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient, +) { + suspend fun execute( + client: Nip86Client, + request: Nip86Request, + ): Nip86Response { + val jsonBody = client.serializeRequest(request) + val bodyBytes = jsonBody.encodeToByteArray() + val authToken = client.buildAuthHeader(bodyBytes) + + val httpRequest = + Request + .Builder() + .url(client.httpUrl) + .header("Content-Type", CONTENT_TYPE_STRING) + .header("Accept", CONTENT_TYPE_STRING) + .header("Authorization", authToken) + .post(bodyBytes.toRequestBody(CONTENT_TYPE)) + .build() + + val httpClient = okHttpClient(client.relayUrl) + + return withContext(Dispatchers.IO) { + try { + httpClient.newCall(httpRequest).executeAsync().use { response -> + val body = response.body.string() + if (response.code == 401) { + Nip86Response(error = "Unauthorized: relay rejected authentication") + } else if (!response.isSuccessful) { + Nip86Response(error = "HTTP ${response.code}: $body") + } else { + try { + client.parseResponse(body) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Nip86Retriever", "Failed to parse response ${client.httpUrl}: $body", e) + Nip86Response(error = "Failed to parse response: ${e.message}") + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Nip86Retriever", "Failed to reach relay ${client.relayUrl.url}", e) + Nip86Response(error = "Failed to reach relay: ${e.message}") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt index 58bb30514..aa7481521 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt @@ -46,7 +46,7 @@ class NipA3PaymentTargetsState( init { settings.backupNipA3PaymentTargets?.let { - Log.d("AccountRegisterObservers", "Loading saved nipA3 Payment targets ${it.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved nipA3 Payment targets ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } } @@ -54,7 +54,7 @@ class NipA3PaymentTargetsState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "nipA3 Payment targets Collector Start") getNipA3PaymentTargetsFlow().collect { - Log.d("AccountRegisterObservers", "Updating nipA3 Payment targets for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Updating nipA3 Payment targets for ${signer.pubKey}" } (it.note.event as? PaymentTargetsEvent)?.let { paymentTargetsEvent -> settings.updateNIPA3PaymentTargets(paymentTargetsEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index b2d127f46..a61a1b020 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -87,6 +88,10 @@ class BlossomServerListState( mergeServerList(blossoms) }.onStart { emit(mergeServerList(flow.value)) + }.onEach { servers -> + if (servers.none { it == settings.defaultFileServer }) { + settings.changeDefaultFileServer(servers.firstOrNull() ?: DEFAULT_MEDIA_SERVERS[0]) + } }.flowOn(Dispatchers.IO) .stateIn( scope, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 600cecaa9..04dd01739 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -32,8 +32,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.encodeToString +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlin.coroutines.cancellation.CancellationException @@ -58,17 +57,21 @@ class NamecoinSharedPreferences( companion object { val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts") } /** * Current settings, loaded synchronously at init to avoid races. */ - private val _settings = - MutableStateFlow( - runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT }, - ) + private val _settings = MutableStateFlow(NamecoinSettings.DEFAULT) val settings: StateFlow = _settings + init { + scope.launch { + _settings.tryEmit(loadFromDisk() ?: NamecoinSettings.DEFAULT) + } + } + /** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */ val current: NamecoinSettings get() = _settings.value @@ -99,8 +102,48 @@ class NamecoinSharedPreferences( suspend fun reset() { persist(NamecoinSettings.DEFAULT) + clearPinnedCerts() } + /** + * Store a PEM-encoded certificate that the user accepted via Test Connection. + * The cert is appended to the existing list and synced to the ElectrumXClient. + */ + suspend fun addPinnedCert(pem: String) { + val existing = loadPinnedCertsFromDisk() + val updated = (existing + pem).distinct() + savePinnedCerts(updated) + } + + /** Load all user-pinned certs from disk (for startup sync). */ + suspend fun loadPinnedCerts(): List = loadPinnedCertsFromDisk() + + private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList()) + + private suspend fun savePinnedCerts(certs: List) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_PINNED_CERTS] = json.encodeToString(certs) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs") { "Error writing pinned certs: ${e.message}" } + } + } + + private suspend fun loadPinnedCertsFromDisk(): List = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val certsJson = prefs[KEY_PINNED_CERTS] + if (certsJson != null) { + json.decodeFromString>(certsJson) + } else { + emptyList() + } + } catch (_: Exception) { + emptyList() + } + // ── Internal ─────────────────────────────────────────────────────── private suspend fun persist(settings: NamecoinSettings) { @@ -115,7 +158,7 @@ class NamecoinSharedPreferences( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs", "Error writing DataStore: ${e.message}") + Log.e("NamecoinPrefs") { "Error writing DataStore: ${e.message}" } } } @@ -137,7 +180,7 @@ class NamecoinSharedPreferences( NamecoinSettings(enabled = enabled, customServers = servers) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs", "Error reading DataStore: ${e.message}") + Log.e("NamecoinPrefs") { "Error reading DataStore: ${e.message}" } null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt index 315278f50..9c769c796 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt @@ -86,7 +86,7 @@ class OtsSharedPreferences( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("OtsPrefs", "Error writing DataStore: ${e.message}") + Log.e("OtsPrefs") { "Error writing DataStore: ${e.message}" } } } @@ -97,7 +97,7 @@ class OtsSharedPreferences( OtsSettings(customExplorerUrl = url) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("OtsPrefs", "Error reading DataStore: ${e.message}") + Log.e("OtsPrefs") { "Error reading DataStore: ${e.message}" } null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index 97422d41b..0bb4d80f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -40,14 +40,31 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.runBlocking import kotlin.coroutines.cancellation.CancellationException @Stable class TorSharedPreferences( + prefs: TorSettings, val context: Context, val scope: CoroutineScope, ) { + // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs + val value = TorSettingsFlow.build(prefs) + + @OptIn(FlowPreview::class) + val saving = + value.propertyWatchFlow + .debounce(1000) + .distinctUntilChanged() + .onEach { + save(it, context) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + value.toSettings(), + ) + companion object { // loads faster when individualized val TOR_TYPE_KEY = stringPreferencesKey("tor.torType") @@ -63,74 +80,58 @@ class TorSharedPreferences( val MONEY_OPERATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.moneyOperationsViaTor") val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor") val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor") - } - // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs - val value = - runBlocking { - TorSettingsFlow.build(torPreferences() ?: TorSettings()) - } - - @OptIn(FlowPreview::class) - val saving = - value.propertyWatchFlow - .debounce(1000) - .distinctUntilChanged() - .onEach(::save) - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - value.toSettings(), - ) - - suspend fun torPreferences(): TorSettings? = - try { - // Get the preference flow and take the first value. - val preferences = context.sharedPreferencesDataStore.data.first() - TorSettings( - torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL, - externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050, - onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true, - dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true, - newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true, - trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false, - urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false, - profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false, - imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false, - videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false, - moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false, - nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false, - mediaUploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}") - null - } - - suspend fun save(torSettings: TorSettings) { - try { - context.sharedPreferencesDataStore.edit { preferences -> - preferences[TOR_TYPE_KEY] = torSettings.torType.name - preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort - preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor - preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor - preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor - preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor - preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor - preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor - preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor - preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor - preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor - preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor - preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.mediaUploadsViaTor + suspend fun torPreferences(context: Context): TorSettings? = + try { + // Get the preference flow and take the first value. + val preferences = context.sharedPreferencesDataStore.data.first() + TorSettings( + torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL, + externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050, + onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true, + dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true, + newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true, + trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false, + urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false, + profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false, + imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false, + videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false, + moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false, + nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false, + mediaUploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + // Log any errors that occur while reading the DataStore. + Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" } + null + } + + suspend fun save( + torSettings: TorSettings, + context: Context, + ) { + try { + context.sharedPreferencesDataStore.edit { preferences -> + preferences[TOR_TYPE_KEY] = torSettings.torType.name + preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort + preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor + preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor + preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor + preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor + preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor + preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor + preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor + preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor + preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor + preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor + preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.mediaUploadsViaTor + } + } catch (e: Exception) { + if (e is CancellationException) throw e + // Log any errors that occur while reading the DataStore. + Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } } - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index bce3c62da..2b673eb36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -49,36 +49,18 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.runBlocking import kotlin.coroutines.cancellation.CancellationException val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore(name = "shared_settings") @Stable class UiSharedPreferences( + prefs: UiSettings, val context: Context, val scope: CoroutineScope, ) { - companion object { - // loads faster when individualized - val UI_THEME = stringPreferencesKey("ui.theme") - val UI_LANGUAGE = stringPreferencesKey("ui.language") - val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") - val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") - val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") - val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") - val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") - val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector") - val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") - val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") - val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") - } - // UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences - val value = - runBlocking { - UiSettingsFlow.build(uiPreferences() ?: UiSettings()) - } + val value = UiSettingsFlow.build(prefs) val languageUpdate = value.preferredLanguage @@ -98,68 +80,87 @@ class UiSharedPreferences( value.propertyWatchFlow .debounce(1000) .distinctUntilChanged() - .onEach(::save) - .flowOn(Dispatchers.IO) + .onEach { + save(it, context) + }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, value.toSettings(), ) - suspend fun uiPreferences(): UiSettings? = - try { - // Get the preference flow and take the first value. - val preferences = context.sharedPreferencesDataStore.data.first() - - UiSettings( - theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM, - preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, - automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false, - dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, - featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED, - gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}") + companion object { + // loads faster when individualized + val UI_THEME = stringPreferencesKey("ui.theme") + val UI_LANGUAGE = stringPreferencesKey("ui.language") + val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") + val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") + val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") + val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") + val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") + val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector") + val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") + val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") + val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") + suspend fun uiPreferences(context: Context): UiSettings? = try { - val oldVersion = LocalPreferences.loadSharedSettings() - if (oldVersion != null) { - save(oldVersion) - } - oldVersion + // Get the preference flow and take the first value. + val preferences = context.sharedPreferencesDataStore.data.first() + + UiSettings( + theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM, + preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, + automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false, + dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, + featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED, + gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, + ) } catch (e: Exception) { if (e is CancellationException) throw e - null - } - } + // Log any errors that occur while reading the DataStore. + Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" } - suspend fun save(sharedSettings: UiSettings) { - try { - context.sharedPreferencesDataStore.edit { preferences -> - preferences[UI_THEME] = sharedSettings.theme.name - preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" - preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name - preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name - preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name - preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name - preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name - preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector - preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions - preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name - preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name + try { + val oldVersion = LocalPreferences.loadSharedSettings() + if (oldVersion != null) { + save(oldVersion, context) + } + oldVersion + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + + suspend fun save( + sharedSettings: UiSettings, + context: Context, + ) { + try { + context.sharedPreferencesDataStore.edit { preferences -> + preferences[UI_THEME] = sharedSettings.theme.name + preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" + preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name + preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name + preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name + preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name + preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name + preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector + preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions + preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name + preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name + } + } catch (e: Exception) { + if (e is CancellationException) throw e + // Log any errors that occur while reading the DataStore. + Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } } - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt index dfb319822..85a875363 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn +@Suppress("UNCHECKED_CAST") class MergedFollowListsState( val kind3List: Kind3FollowListState, val peopleList: PeopleListsState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 44d62f84a..8dae90a9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -51,7 +51,7 @@ class FeedTopNavFilterState( val feedFilterListName: MutableStateFlow, val kind3Follows: StateFlow, val allFollows: StateFlow, - val locationFlow: StateFlow, + val locationFlow: () -> StateFlow, val followsRelays: StateFlow>, val blockedRelays: StateFlow>, val proxyRelays: StateFlow>, @@ -78,7 +78,7 @@ class FeedTopNavFilterState( } TopFilter.AroundMe -> { - AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) + AroundMeFeedFlow(locationFlow(), followsRelays, proxyRelays) } TopFilter.Chess -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index 567cd6f89..48fa7df62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.torState +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType @@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import okhttp3.OkHttpClient +@Stable class TorRelayState( val okHttpClient: DualHttpClientManager, val torSettingsFlow: TorSettingsFlow, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt index f113bc87b..27f21e147 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.model.trustedAssertions -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviderSet -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviderSet +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders class TrustProviderListDecryptionCache( val signer: NostrSigner, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt index be345e595..324ad09ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -124,7 +124,7 @@ class TrustProviderListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "TrustProviderList Collector Start") getTrustProviderListFlow().collect { noteState -> - Log.d("AccountRegisterObservers", "TrustProviderList List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "TrustProviderList List for ${signer.pubKey}" } (noteState.note.event as? TrustProviderListEvent)?.let { settings.updateTrustProviderListTo(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt index 1c3d62232..bfede508e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.broadcast import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -85,12 +85,12 @@ class BroadcastTracker { // Add to active broadcasts and cache event for retries _activeBroadcasts.update { (it + broadcast).toImmutableList() } - Log.d(TAG, "Starting broadcast $trackingId (kind ${event.kind}) to ${relays.size} relays") + Log.d(TAG) { "Starting broadcast $trackingId (kind ${event.kind}) to ${relays.size} relays" } val resultChannel = Channel(UNLIMITED) val subscription = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onCannotConnect( relay: IRelayClient, errorMessage: String, @@ -102,7 +102,7 @@ class BroadcastTracker { result = RelayResult.Error(errorMessage), ), ) - Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage") + Log.d(TAG) { "[$trackingId] Cannot connect to ${relay.url}: $errorMessage" } } } @@ -114,7 +114,7 @@ class BroadcastTracker { result = RelayResult.Error("Relay disconnected before completion"), ), ) - Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}") + Log.d(TAG) { "[$trackingId] Disconnected from ${relay.url}" } } } @@ -135,62 +135,65 @@ class BroadcastTracker { RelayResult.Error(msg.message) } resultChannel.trySend(RelayResponse(relay.url, result)) - Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}") + Log.d(TAG) { "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}" } } } } } } - client.subscribe(subscription) + try { + client.addConnectionListener(subscription) - val finalBroadcast = - coroutineScope { - val resultCollector = - async { - val receivedRelays = mutableSetOf() - var currentBroadcast = broadcast + val finalBroadcast = + coroutineScope { + val resultCollector = + async { + val receivedRelays = mutableSetOf() + var currentBroadcast = broadcast - withTimeoutOrNull(TIMEOUT_SECONDS * 1000) { - while (receivedRelays.size < relays.size) { - val response = resultChannel.receive() + withTimeoutOrNull(TIMEOUT_SECONDS * 1000) { + while (receivedRelays.size < relays.size) { + val response = resultChannel.receive() - // Skip if already received (don't override success) - if (response.relay in receivedRelays) continue + // Skip if already received (don't override success) + if (response.relay in receivedRelays) continue - receivedRelays.add(response.relay) - currentBroadcast = currentBroadcast.withResult(response.relay, response.result) + receivedRelays.add(response.relay) + currentBroadcast = currentBroadcast.withResult(response.relay, response.result) - // Update active broadcasts with new progress - _activeBroadcasts.update { list -> - list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList() + // Update active broadcasts with new progress + _activeBroadcasts.update { list -> + list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList() + } } } + + // Mark remaining relays as timeout + relays.filter { it !in receivedRelays }.forEach { relay -> + currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout) + } + + currentBroadcast } - // Mark remaining relays as timeout - relays.filter { it !in receivedRelays }.forEach { relay -> - currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout) - } + // Send after setting up listener + client.publish(event, relays) - currentBroadcast - } + resultCollector.await() + } - // Send after setting up listener - client.send(event, relays) + resultChannel.close() - resultCollector.await() + // Remove from active, emit to completed + _activeBroadcasts.update { list -> + list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList() } - client.unsubscribe(subscription) - resultChannel.close() - - // Remove from active, emit to completed - _activeBroadcasts.update { list -> - list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList() + Log.d(TAG) { "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success" } + } finally { + client.removeConnectionListener(subscription) } - - Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") } /** @@ -263,7 +266,7 @@ class BroadcastTracker { val resultChannel = Channel(UNLIMITED) val subscription = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onCannotConnect( relay: IRelayClient, errorMessage: String, @@ -275,7 +278,7 @@ class BroadcastTracker { result = RelayResult.Error(errorMessage), ), ) - Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage") + Log.d(TAG) { "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage" } } } @@ -287,7 +290,7 @@ class BroadcastTracker { result = RelayResult.Error("Relay disconnected before completion"), ), ) - Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}") + Log.d(TAG) { "[${broadcast.id}] Retry disconnected from ${relay.url}" } } } @@ -308,14 +311,14 @@ class BroadcastTracker { RelayResult.Error(msg.message) } resultChannel.trySend(RelayResponse(relay.url, result)) - Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}") + Log.d(TAG) { "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}" } } } } } } - client.subscribe(subscription) + client.addConnectionListener(subscription) val finalBroadcast = coroutineScope { @@ -367,12 +370,12 @@ class BroadcastTracker { currentBroadcast.copy(status = newStatus) } - client.send(event, relaysToRetry) + client.publish(event, relaysToRetry) resultCollector.await() } - client.unsubscribe(subscription) + client.removeConnectionListener(subscription) resultChannel.close() // Update in active broadcasts @@ -380,7 +383,7 @@ class BroadcastTracker { list.map { if (it.id == broadcast.id) finalBroadcast else it }.toImmutableList() } - Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") + Log.d(TAG) { "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success" } return finalBroadcast } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt index 2c72217f1..a3d140e5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt @@ -48,7 +48,7 @@ class ConnectivityFlow( object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) - Log.d("ConnectivityFlow", "onAvailable ${network.networkHandle}") + Log.d("ConnectivityFlow") { "onAvailable ${network.networkHandle}" } connectivityManager.getNetworkCapabilities(network)?.let { trySend(ConnectivityStatus.Active(network.networkHandle, it.isMeteredOrMobileData())) } @@ -60,13 +60,13 @@ class ConnectivityFlow( ) { super.onCapabilitiesChanged(network, networkCapabilities) val isMobile = networkCapabilities.isMeteredOrMobileData() - Log.d("ConnectivityFlow", "onCapabilitiesChanged ${network.networkHandle} $isMobile") + Log.d("ConnectivityFlow") { "onCapabilitiesChanged ${network.networkHandle} $isMobile" } trySend(ConnectivityStatus.Active(network.networkHandle, isMobile)) } override fun onLost(network: Network) { super.onLost(network) - Log.d("ConnectivityFlow", "onLost ${network.networkHandle} ") + Log.d("ConnectivityFlow") { "onLost ${network.networkHandle} " } trySend(ConnectivityStatus.Off) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt index 2b0b80447..7406c8e13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt @@ -41,12 +41,12 @@ import kotlin.coroutines.cancellation.CancellationException class BlossomFetcher( private val options: Options, private val data: Uri, - private val blossomServerResolver: BlossomServerResolver, + private val blossomServerResolver: () -> BlossomServerResolver, private val networkFetcher: (url: String) -> Fetcher, ) : Fetcher { override suspend fun fetch(): FetchResult? = try { - val urlResult = blossomServerResolver.findServers(data.toString()) + val urlResult = blossomServerResolver().findServers(data.toString()) networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch() } catch (e: Exception) { if (e is CancellationException) throw e @@ -55,7 +55,7 @@ class BlossomFetcher( @OptIn(ExperimentalCoilApi::class) class Factory( - val blossomServerResolver: BlossomServerResolver, + val blossomServerResolver: () -> BlossomServerResolver, val networkClient: (url: String) -> Call.Factory, ) : Fetcher.Factory { private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 22a71cd21..9e8b40a03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -64,7 +64,7 @@ class ImageLoaderSetup { app: Context, diskCache: () -> DiskCache, memoryCache: () -> MemoryCache, - blossomServerResolver: BlossomServerResolver, + blossomServerResolver: () -> BlossomServerResolver, callFactory: (url: String) -> Call.Factory, ) { SingletonImageLoader.setUnsafe( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index da9cc9ae5..c37e4b4de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -188,7 +188,7 @@ class LightningAddressResolver { } if (errorMessage == null) { - Log.d("LightningAddressResolver", "Error parsing LNResponse: $body") + Log.d("LightningAddressResolver") { "Error parsing LNResponse: $body" } } return errorMessage diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index 480efa4ce..87ab766ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -48,13 +48,13 @@ class LocationFlow( val locationCallback = LocationListener { location -> - Log.d("LocationFlow", "onLocationChanged $location") + Log.d("LocationFlow") { "onLocationChanged $location" } launch { send(location) } } locationManager.allProviders.forEach { val location = locationManager.getLastKnownLocation(it) - Log.d("LocationFlow", "Last Known location is $location") + Log.d("LocationFlow") { "Last Known location is $location" } if (location != null) { send(location) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index c72cd8fe9..d3a8d15d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -65,7 +65,7 @@ class LocationState( } @OptIn(ExperimentalCoroutinesApi::class) - val geohashStateFlow = + val geohashStateFlow by lazy { hasLocationPermission .transformLatest { if (it) { @@ -92,4 +92,5 @@ class LocationState( SharingStarted.WhileSubscribed(5000), latestLocation, ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt index 92e6f5127..3caed0056 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt @@ -29,6 +29,7 @@ import androidx.annotation.RequiresApi import com.vitorpamplona.quartz.utils.Log import java.io.IOException +@Suppress("DEPRECATION") class ReverseGeolocation { companion object { fun execute( @@ -52,18 +53,18 @@ class ReverseGeolocation { val locationCallback = object : Geocoder.GeocodeListener { override fun onGeocode(addresses: List
) { - Log.d("ReverseGeoLocation", "Found ${addresses.size} new addresses") + Log.d("ReverseGeoLocation") { "Found ${addresses.size} new addresses" } onReady(addresses) } override fun onError(errorMessage: String?) { super.onError(errorMessage) - Log.w("ReverseGeoLocation", "Failure $errorMessage") + Log.w("ReverseGeoLocation") { "Failure $errorMessage" } onReady(null) } } - Log.d("ReverseGeoLocation", "Execute Async $location") + Log.d("ReverseGeoLocation") { "Execute Async $location" } Geocoder(context).getFromLocation( location.latitude, location.longitude, @@ -76,7 +77,7 @@ class ReverseGeolocation { location: Location, context: Context, ): List
? { - Log.d("ReverseGeoLocation", "Execute Sync $location") + Log.d("ReverseGeoLocation") { "Execute Sync $location" } return try { Geocoder(context).getFromLocation( location.latitude, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt index c4d7569d0..591079375 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt @@ -41,7 +41,7 @@ object ChoreographerHelper { if (diff > 35) { // Follow the frame number val droppedCount = (diff / 16.6).toInt() - Log.w("block-canary", "Dropped $droppedCount frames. Skipped $diff ms") + Log.w("block-canary") { "Dropped $droppedCount frames. Skipped $diff ms" } } lastFrameTimeNanos = frameTimeNanos Choreographer.getInstance().postFrameCallback(this) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt index 8d0b882a0..21fddb7c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt @@ -60,7 +60,7 @@ class LogMonitor : Printer { val endTime = System.currentTimeMillis() if (x.indexOf("com.vitorpamplona.amethyst") > 0) { - Log.d("block-canary", "Looper ${endTime - mStartTimestamp}ms for $x") + Log.d("block-canary") { "Looper ${endTime - mStartTimestamp}ms for $x" } } mPrintingStarted = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 4855b493f..1a859848c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -66,7 +66,7 @@ data class NamecoinSettings( * TLS is the default protocol. Append `:tcp` for plaintext * (useful for `.onion` addresses and local servers). * - * `.onion` addresses automatically get `trustAllCerts = true` + * `.onion` addresses automatically get `usePinnedTrustStore = true` * since certificate verification is meaningless over Tor. */ fun parseServerString(s: String): ElectrumxServer? { @@ -77,11 +77,15 @@ data class NamecoinSettings( if (host.isEmpty() || port <= 0 || port > 65535) return null val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" val isOnion = host.endsWith(".onion") + // All custom servers use the pinned trust store. ElectrumX + // servers almost universally use self-signed certs, so we + // route them through our pinned SSLSocketFactory (hardcoded + // defaults + TOFU-pinned certs + system CAs). return ElectrumxServer( host = host, port = port, useSsl = useSsl, - trustAllCerts = isOnion || !useSsl, + usePinnedTrustStore = true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index e1252c1c7..33237a36f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -71,14 +71,14 @@ class EventNotificationConsumer( LocalPreferences.allSavedAccounts().forEach { if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) { LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc -> - Log.d(TAG, "New Notification Testing if for ${it.npub}") + Log.d(TAG) { "New Notification Testing if for ${it.npub}" } try { val account = Amethyst.instance.accountsCache.loadAccount(acc) consumeIfMatchesAccount(event, account) matchAccount = true } catch (e: Exception) { if (e is CancellationException) throw e - Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}") + Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } } } } @@ -98,14 +98,14 @@ class EventNotificationConsumer( account: Account, ) { val consumed = LocalCache.hasConsumed(notificationEvent) - Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.signer.pubKey} consumed= $consumed") + Log.d(TAG) { "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.signer.pubKey} consumed= $consumed" } if (!consumed) { Log.d(TAG, "New Notification was verified") if (!notificationManager().areNotificationsEnabled()) return Log.d(TAG, "Notifications are enabled") unwrapAndConsume(notificationEvent, account.signer)?.let { innerEvent -> - Log.d(TAG, "Unwrapped consume ${innerEvent.javaClass.simpleName}") + Log.d(TAG) { "Unwrapped consume ${innerEvent.javaClass.simpleName}" } when (innerEvent) { is PrivateDmEvent -> { @@ -147,14 +147,14 @@ class EventNotificationConsumer( LocalPreferences.allSavedAccounts().forEach { if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) { LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings -> - Log.d(TAG, "New Notification Testing if for ${it.npub}") + Log.d(TAG) { "New Notification Testing if for ${it.npub}" } try { val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) consumeNotificationEvent(event, account) matchAccount = true } catch (e: Exception) { if (e is CancellationException) throw e - Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}") + Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } } } } @@ -388,7 +388,7 @@ class EventNotificationConsumer( account: Account, ) { Log.d(TAG, "New Zap to Notify") - Log.d(TAG, "Notify Start ${event.toNostrUri()}") + Log.d(TAG) { "Notify Start ${event.toNostrUri()}" } LocalCache.getNoteIfExists(event.id) ?: return Log.d(TAG, "Notify Not Notified Yet") @@ -401,7 +401,7 @@ class EventNotificationConsumer( val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped") + Log.d(TAG) { "Notify ZapRequest $noteZapRequest zapped $noteZapped" } if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return @@ -410,11 +410,11 @@ class EventNotificationConsumer( if (event.isTaggedUser(account.signer.pubKey)) { val amount = showAmount(event.amount) - Log.d(TAG, "Notify Amount $amount") + Log.d(TAG) { "Notify Amount $amount" } (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent -> - Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}") + Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" } val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey) val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null }) @@ -451,7 +451,7 @@ class EventNotificationConsumer( .hexToByteArray() .toNpub() - Log.d(TAG, "Notify ${event.id} $content $title $noteUri") + Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" } notificationManager() .sendZapNotification( @@ -486,7 +486,7 @@ class EventNotificationConsumer( .hexToByteArray() .toNpub() - Log.d(TAG, "Notify ${event.id} $title $noteUri") + Log.d(TAG) { "Notify ${event.id} $title $noteUri" } notificationManager() .sendZapNotification( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index f5009118e..0289c3406 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -85,7 +85,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { notificationManager.cancel(notificationId) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NotificationReply", "Failed to send reply: ${e.message}") + Log.e("NotificationReply") { "Failed to send reply: ${e.message}" } } finally { pendingResult.finish() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt index 08b78c4df..d2f23c8e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt @@ -69,7 +69,7 @@ class PokeyReceiver : BroadcastReceiver() { ) { if (intent.action == POKEY_ACTION) { // it's best practice to verify intent action before performing any operation val eventStr = intent.getStringExtra("EVENT") - Log.d(TAG, "New Pokey Notification Arrived $eventStr") + Log.d(TAG) { "New Pokey Notification Arrived $eventStr" } if (eventStr == null) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 042f24aef..7c7d818f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -78,7 +78,7 @@ class RegisterAccounts( accounts .mapNotNull { account -> if (account.hasPrivKey || account.loggedInWithExternalSigner) { - Log.d(tag, "Register Account ${account.npub}") + Log.d(tag) { "Register Account ${account.npub}" } val acc = LocalPreferences.loadAccountConfigFromEncryptedStorage(account.npub) if (acc != null && acc.isWriteable()) { @@ -87,10 +87,10 @@ class RegisterAccounts( if (isDebug) { val readRelays = nip65Read.joinToString(", ") { it.url } - Log.d(tag, "Register Account ${account.npub} NIP65 Reads $readRelays") + Log.d(tag) { "Register Account ${account.npub} NIP65 Reads $readRelays" } val dmRelays = nip17Read.joinToString(", ") { it.url } - Log.d(tag, "Register Account ${account.npub} NIP17 Reads $dmRelays") + Log.d(tag) { "Register Account ${account.npub} NIP17 Reads $dmRelays" } } val relays = (nip65Read + nip17Read) @@ -132,7 +132,7 @@ class RegisterAccounts( val client = client(url) client.newCall(request).executeAsync().use { response -> - Log.i(tag, "Server registration ${response.isSuccessful}") + Log.i(tag) { "Server registration ${response.isSuccessful}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt index 7fd03c646..3ddd56c09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt @@ -33,9 +33,9 @@ class EncryptedBlobInterceptor( val body = peekBody(Long.MAX_VALUE) // Only tries to decrypt if the content-type is a byte array - if (body.contentType().toString() != "application/octet-stream") { - return null - } + // if (body.contentType().toString() != "application/octet-stream") { + // return null + // } val bytes = body.bytes() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt index 114ee76b4..1982c7399 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt @@ -41,7 +41,7 @@ class LoggingInterceptor : Interceptor { val response: Response = chain.proceed(request) val t2 = System.nanoTime() - Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms") + Log.d("OkHttpLog") { "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms" } return response } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 3375b7919..b561a4538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -47,7 +47,7 @@ fun GetVideoController( keepPlaying = mediaItem.src.keepPlaying, context = context, ).onEach { state -> - Log.d("PlaybackService", "Controller instance: ${state.controller}") + Log.d("PlaybackService") { "Controller instance: ${state.controller}" } if (BackgroundMedia.isPlaying()) { // There is a video playing, start this one on mute. @@ -57,7 +57,7 @@ fun GetVideoController( // There is no other video playing. Use the default mute state to // decide if sound is on or not. state.controller.volume = if (muted) 0f else 1f - Log.d("PlaybackService", "OnEach $muted") + Log.d("PlaybackService") { "OnEach $muted" } } if (play) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 6b4cb92d8..b51e5727e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -24,7 +24,6 @@ import androidx.annotation.OptIn import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index f69ce32c4..0252d4835 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -60,14 +60,22 @@ class PipVideoActivity : ComponentActivity() { } } - override fun onStop() { - super.onStop() - finishAndRemoveTask() + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + if (!isInPictureInPictureMode) { + // User dismissed PiP (swiped away or expanded). + finishAndRemoveTask() + } } - override fun finish() { - finishAndRemoveTask() - super.finish() + override fun onStop() { + super.onStop() + if (!isInPictureInPictureMode) { + // Only finish if we're not in PiP mode. + // When the screen locks while in PiP, we stay alive + // so the PlaybackService can continue audio playback. + finishAndRemoveTask() + } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 4a01cb6ec..59a425981 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.core.content.ContextCompat +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.state.rememberMuteButtonState @@ -51,6 +53,8 @@ fun RenderPipVideo( controller: MediaControllerState, waveformData: WaveformData?, ) { + KeepScreenOnWhilePlaying(controller) + val modifier = remember { val ratio = @@ -78,6 +82,31 @@ fun RenderPipVideo( } } +@Composable +fun KeepScreenOnWhilePlaying(controller: MediaControllerState) { + val view = LocalView.current + + DisposableEffect(controller.controller, view) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (view.keepScreenOn != isPlaying) { + view.keepScreenOn = isPlaying + } + } + } + + // Set initial state + view.keepScreenOn = controller.controller.isPlaying + + controller.controller.addListener(listener) + onDispose { + controller.controller.removeListener(listener) + view.keepScreenOn = false + } + } +} + @Composable fun RegisterControllerReceiver(controllerState: MediaControllerState) { val context = LocalContext.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 1b3bd3934..534e2d025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -67,14 +67,14 @@ object PlaybackServiceClient { .setConnectionHints(bundle) .buildAsync() - Log.d("PlaybackService", "Preparing Controller $id $videoUri") + Log.d("PlaybackService") { "Preparing Controller $id $videoUri" } controllerFuture.addListener( { try { val controller = controllerFuture.get(5, TimeUnit.SECONDS) - Log.d("PlaybackService", "Controller Ready $id $videoUri") + Log.d("PlaybackService") { "Controller Ready $id $videoUri" } // checks if the player is still active before engaging further trySend( @@ -92,11 +92,11 @@ object PlaybackServiceClient { ) awaitClose { - Log.d("PlaybackService", "Releasing Controller $id $videoUri") + Log.d("PlaybackService") { "Releasing Controller $id $videoUri" } try { MediaController.releaseFuture(controllerFuture) } catch (e: Exception) { - Log.e("Playback Client", "Failed to release Playback Client for $id $videoUri ${e.message}") + Log.e("Playback Client") { "Failed to release Playback Client for $id $videoUri ${e.message}" } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index 512f85a3b..ae6fc1831 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -74,7 +74,7 @@ class RelayProxyClientConnector( if (it.connectivity is ConnectivityStatus.StartingService) { // ignore } else if (it.connectivity is ConnectivityStatus.Off) { - Log.d("ManageRelayServices", "Connectivity Off: Pausing Relay Services ${it.connectivity}") + Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${it.connectivity}" } if (client.isActive()) { client.disconnect() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 2437c21a2..80c11d79d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -80,7 +80,7 @@ class AuthCoordinator( if (account == null) return if (isDebug) { - Log.d("AuthCoordinator", "Watch $account") + Log.d("AuthCoordinator") { "Watch $account" } } authWithAccounts.add(account) @@ -91,7 +91,7 @@ class AuthCoordinator( if (account == null) return if (isDebug) { - Log.d("AuthCoordinator", "Unwatch $account") + Log.d("AuthCoordinator") { "Unwatch $account" } } authWithAccounts.remove(account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index dad3d827b..2817e2a39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -68,7 +68,7 @@ abstract class PerUniqueIdEoseManager( open fun newSub(key: T): Subscription = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index da4d9b2b4..0635acab8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -69,7 +69,7 @@ abstract class PerUserAndFollowListEoseManager( open fun newSub(key: T): Subscription = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index 357a2ab10..ad899cd2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -67,7 +67,7 @@ abstract class PerUserEoseManager( open fun newSub(key: T): Subscription = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index aaefb517a..28c3648bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -41,7 +41,7 @@ abstract class SingleSubNoEoseCacheEoseManager( ) : BaseEoseManager(client, allKeys) { val sub = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 6798df8ac..3c1363a1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -61,6 +63,7 @@ class RelaySubscriptionsCoordinator( val chatroomList = ChatroomListFilterAssembler(client) val video = VideoFilterAssembler(client) val discovery = DiscoveryFilterAssembler(client) + val polls = PollsFilterAssembler(client) // loaders of content that is not yet in the device. // they are active when looking at events, users, channels. @@ -80,6 +83,7 @@ class RelaySubscriptionsCoordinator( val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) val relayFeed = RelayFeedFilterAssembler(client) + val relayInfoNip66 = RelayInfoNip66FilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) val chess = ChessFilterAssembler(client) @@ -93,6 +97,7 @@ class RelaySubscriptionsCoordinator( chatroomList, video, discovery, + polls, channelFinder, eventFinder, userFinder, @@ -105,6 +110,7 @@ class RelaySubscriptionsCoordinator( hashtags, geohashes, relayFeed, + relayInfoNip66, chess, nwc, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 5e70dfedd..5a88cff00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -35,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -107,7 +106,7 @@ class AccountFollowsLoaderSubAssembler( val sub = orchestrator.requestNewSubscription( if (isDebug) logTag + newSubId() else newSubId(), - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 4e58e9531..1bf39cadf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metada import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -43,6 +42,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 854efc620..2573f911e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -38,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index cc8ebc0b8..eedf9a4af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -24,13 +24,17 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent val ReportsAndBookmarksFromKeyKinds = listOf( ReportEvent.KIND, BookmarkListEvent.KIND, + PinListEvent.KIND, + RequestToVanishEvent.KIND, ) fun filterBookmarksAndReportsFromKey( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index b00996b98..60f8865f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent @@ -25,7 +27,6 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -52,6 +53,7 @@ import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent val SummaryKinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 55220b18f..0160962bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.Note @@ -39,8 +41,8 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 34381f2de..d656913e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import kotlinx.collections.immutable.ImmutableList @@ -276,6 +277,30 @@ fun observeUserBookmarkCount( return flow.collectAsStateWithLifecycle(0) } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserPinnedNotesCount( + user: User, + accountViewModel: AccountViewModel, +): State { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + val flow = + remember(user) { + accountViewModel + .pinnedNotes(user) + .flow() + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? PinListEvent)?.countPins() ?: 0 + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle(0) +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserIsFollowing( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 44316d6b1..d4d9504db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -68,7 +68,7 @@ class UserOutboxFinderSubAssembler( val sub = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt index 59312532d..8013013f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers -import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent val ContactCardKindList = listOf(ContactCardEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 4975a63a9..698c93b76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.TimeUtils @@ -61,7 +61,7 @@ class UserWatcherSubAssembler( val sub = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 74ad10761..e1971c810 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory 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.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -52,6 +51,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent val SearchPostsByTextKinds1 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt index cc5f24019..79cdf51a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt @@ -57,10 +57,10 @@ class FrameStat { } fun log() { - Log.d(TAG, "Events Per Second: ${eventCount.get()}") + Log.d(TAG) { "Events Per Second: ${eventCount.get()}" } kinds.forEach { key, value -> if (value.count.get() > 0) { - Log.d(TAG, "-- Kind $key $value") + Log.d(TAG) { "-- Kind $key $value" } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt index 8bceccb65..66718a9ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.speedLogger import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.bytesUsedInMemory /** - * Listens to NostrClient's onNotify messages from the relay + * Listens to INostrClient's onNotify messages from the relay */ class RelaySpeedLogger( val client: INostrClient, @@ -41,7 +41,7 @@ class RelaySpeedLogger( var current = FrameStat() private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -55,7 +55,7 @@ class RelaySpeedLogger( init { Log.d(TAG, "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) // OkHttpDebugLogging.enableHttp2() // OkHttpDebugLogging.enableTaskRunner() } @@ -63,6 +63,6 @@ class RelaySpeedLogger( fun destroy() { // makes sure to run Log.d(TAG, "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 49abc9616..79427a04b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -58,7 +58,7 @@ class FileHeader( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + Log.e("ImageDownload") { "Couldn't download image from server: ${e.message}" } Result.failure(e) } @@ -76,7 +76,7 @@ class FileHeader( Result.success(FileHeader(mimeType, hash, size, dim, blurHash)) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") + Log.e("ImageDownload") { "Couldn't convert image in to File Header: ${e.message}" } Result.failure(e) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index f984e7288..5cc488797 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log import id.zelory.compressor.Compressor import id.zelory.compressor.constraint.default import kotlinx.coroutines.CancellationException +import java.io.File class MediaCompressorResult( val uri: Uri, @@ -89,19 +90,25 @@ class MediaCompressor { else -> 60 } + var tempFile: File? = null return try { - Log.d("MediaCompressor", "Using image compression $mediaQuality") - val tempFile = MediaCompressorFileUtils.from(uri, context) + Log.d("MediaCompressor") { "Using image compression $mediaQuality" } + tempFile = MediaCompressorFileUtils.from(uri, context) val compressedImageFile = Compressor.compress(context, tempFile) { default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality) } - Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]") + if (tempFile != compressedImageFile && !tempFile.delete()) { + Log.w("MediaCompressor") { "Failed to delete temp file: ${tempFile.absolutePath}" } + } + Log.d("MediaCompressor") { "Image compression success. New size [${compressedImageFile.length()}]" } MediaCompressorResult(compressedImageFile.toUri(), MimeTypes.IMAGE_JPEG, compressedImageFile.length()) } catch (e: Exception) { - Log.d("MediaCompressor", "Image compression failed: ${e.message}") if (e is CancellationException) throw e - e.printStackTrace() + Log.d("MediaCompressor") { "Image compression failed: ${e.message}" } + if (tempFile?.delete() == false) { + Log.w("MediaCompressor") { "Failed to delete temp file: ${tempFile.absolutePath}" } + } MediaCompressorResult(uri, contentType, null) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt index 9e603f514..9a0363394 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt @@ -172,7 +172,7 @@ object MetadataStripper { muxer?.release() extractor.release() if (!succeeded && !outputFile.delete()) { - Log.w("MetadataStripper", "Failed to delete temp file: ${outputFile.absolutePath}") + Log.w("MetadataStripper") { "Failed to delete temp file: ${outputFile.absolutePath}" } } } return succeeded @@ -202,7 +202,7 @@ object MetadataStripper { context.contentResolver.openInputStream(uri) ?: run { if (!tempFile.delete()) { - Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}") + Log.w("MetadataStripper") { "Failed to delete temp file: ${tempFile.absolutePath}" } } return StrippingResult(uri, false) } @@ -223,9 +223,9 @@ object MetadataStripper { } catch (e: Exception) { if (e is CancellationException) throw e if (tempFile?.delete() == false) { - Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}") + Log.w("MetadataStripper") { "Failed to delete temp file: ${tempFile.absolutePath}" } } - Log.d("MetadataStripper", "Failed to strip image metadata: ${e.message}") + Log.d("MetadataStripper") { "Failed to strip image metadata: ${e.message}" } StrippingResult(uri, false) } } @@ -260,7 +260,7 @@ object MetadataStripper { StrippingResult(tempOutputFile.toUri(), true) } catch (e: Exception) { if (e is CancellationException) throw e - Log.d("MetadataStripper", "Failed to strip video metadata: ${e.message}") + Log.d("MetadataStripper") { "Failed to strip video metadata: ${e.message}" } StrippingResult(uri, false) } } @@ -293,7 +293,7 @@ object MetadataStripper { StrippingResult(tempOutputFile.toUri(), true) } catch (e: Exception) { if (e is CancellationException) throw e - Log.d("MetadataStripper", "Failed to strip audio metadata: ${e.message}") + Log.d("MetadataStripper") { "Failed to strip audio metadata: ${e.message}" } StrippingResult(uri, false) } } @@ -311,7 +311,7 @@ object MetadataStripper { } } ?: run { if (!tempInputFile.delete()) { - Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + Log.w("MetadataStripper") { "Failed to delete temp file: ${tempInputFile.absolutePath}" } } return StrippingResult(uri, false) } @@ -354,7 +354,7 @@ object MetadataStripper { if (startOffset == 0L && endOffset == fileSize) { if (!tempInputFile.delete()) { - Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + Log.w("MetadataStripper") { "Failed to delete temp file: ${tempInputFile.absolutePath}" } } tempInputFile = null return StrippingResult(uri, true) // no tags found, already clean @@ -376,7 +376,7 @@ object MetadataStripper { } } if (!tempInputFile.delete()) { - Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + Log.w("MetadataStripper") { "Failed to delete temp file: ${tempInputFile.absolutePath}" } } tempInputFile = null @@ -385,9 +385,9 @@ object MetadataStripper { } catch (e: Exception) { if (e is CancellationException) throw e if (tempInputFile?.delete() == false) { - Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}") + Log.w("MetadataStripper") { "Failed to delete temp file: ${tempInputFile.absolutePath}" } } - Log.d("MetadataStripper", "Failed to strip MP3 metadata: ${e.message}") + Log.d("MetadataStripper") { "Failed to strip MP3 metadata: ${e.message}" } StrippingResult(uri, false) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index ee5ef4e72..e0e5ad0f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -31,10 +31,12 @@ import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import okhttp3.OkHttpClient +import java.io.File import kotlin.coroutines.cancellation.CancellationException sealed class UploadingState { @@ -324,6 +326,26 @@ class UploadOrchestrator { return strippingResult.uri } + /** + * Deletes a temporary file created during the upload pipeline if its URI + * differs from the original (meaning it's an intermediate temp file, not the user's content). + */ + private fun deleteTempUri( + tempUri: Uri, + originalUri: Uri, + ) { + if (tempUri == originalUri) return + try { + val path = tempUri.path ?: return + val file = File(path) + if (file.delete()) { + Log.d("UploadOrchestrator") { "Deleted temp file: $path" } + } + } catch (e: Exception) { + Log.w("UploadOrchestrator", "Failed to delete temp file: ${tempUri.path}", e) + } + } + suspend fun upload( uri: Uri, mimeType: String?, @@ -341,12 +363,20 @@ class UploadOrchestrator { val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) - ?: return error(R.string.upload_cancelled) + ?: return error(R.string.upload_cancelled).also { + deleteTempUri(compressed.uri, uri) + } - return when (server.type) { - ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) - ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri) + + try { + return when (server.type) { + ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) + ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + } + } finally { + deleteTempUri(finalUri, uri) } } @@ -368,14 +398,23 @@ class UploadOrchestrator { val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) - ?: return error(R.string.upload_cancelled) + ?: return error(R.string.upload_cancelled).also { + deleteTempUri(compressed.uri, uri) + } + + if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri) val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt) + deleteTempUri(finalUri, uri) - return when (server.type) { - ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) - ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + try { + return when (server.type) { + ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) + ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + } + } finally { + deleteTempUri(encrypted.uri, uri) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 8f98f6fb9..5fd52757e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -26,7 +26,6 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.text.format.Formatter.formatFileSize -import android.util.Log import android.widget.Toast import com.abedelazizshe.lightcompressorlibrary.CompressionListener import com.abedelazizshe.lightcompressorlibrary.VideoCodec @@ -34,6 +33,8 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration import com.abedelazizshe.lightcompressorlibrary.config.Configuration import com.abedelazizshe.lightcompressorlibrary.config.VideoResizer +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.LogLevel import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import java.io.File @@ -50,19 +51,19 @@ data class VideoResolution( val width: Int, val height: Int, ) { - val pixels: Int get() = width * height - - fun getStandard(): VideoStandard = - when { - pixels >= 3840 * 2160 -> VideoStandard.UHD_4K - pixels >= 2560 * 1440 -> VideoStandard.QHD_1440P - pixels >= 1920 * 1080 -> VideoStandard.FHD_1080P - pixels >= 1280 * 720 -> VideoStandard.HD_720P - pixels >= 854 * 480 -> VideoStandard.SD_480P - pixels >= 640 * 360 -> VideoStandard.NHD_360P - pixels >= 426 * 240 -> VideoStandard.QVGA_240P + fun getStandard(): VideoStandard { + val shortSide = minOf(width, height) + return when { + shortSide >= 2160 -> VideoStandard.UHD_4K + shortSide >= 1440 -> VideoStandard.QHD_1440P + shortSide >= 1080 -> VideoStandard.FHD_1080P + shortSide >= 720 -> VideoStandard.HD_720P + shortSide >= 480 -> VideoStandard.SD_480P + shortSide >= 360 -> VideoStandard.NHD_360P + shortSide >= 240 -> VideoStandard.QVGA_240P else -> VideoStandard.UNKNOWN } + } } enum class VideoStandard( @@ -84,8 +85,7 @@ enum class VideoStandard( private const val MBPS_TO_BPS_MULTIPLIER = 1_000_000 data class CompressionRule( - val width: Int, - val height: Int, + val shortSide: Int, val bitrateMbps: Float, val description: String, ) { @@ -95,10 +95,10 @@ data class CompressionRule( ): Int { // Apply 1.3x multiplier for 60fps+ videos, 0.7x multiplier for H265 val framerateMultiplier = if (framerate >= 60f) 1.3f else 1.0f - val codecMultiplier = if (useH265) 0.7f else 1.0f + val codecMultiplier = if (useH265) 0.75f else 1.0f val finalMultiplier = framerateMultiplier * codecMultiplier - Log.d("VideoCompressionHelper", "framerate: $framerate, useH265: $useH265, Bitrate multiplier: $finalMultiplier") + Log.d("VideoCompressionHelper") { "framerate: $framerate, useH265: $useH265, Bitrate multiplier: $finalMultiplier" } return (bitrateMbps * finalMultiplier * MBPS_TO_BPS_MULTIPLIER).toInt() } @@ -111,36 +111,36 @@ object VideoCompressionHelper { mapOf( CompressorQuality.LOW to mapOf( - VideoStandard.UHD_4K to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), - VideoStandard.QHD_1440P to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), - VideoStandard.FHD_1080P to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), - VideoStandard.HD_720P to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), - VideoStandard.SD_480P to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), - VideoStandard.NHD_360P to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), - VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), - VideoStandard.UNKNOWN to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), + VideoStandard.UHD_4K to CompressionRule(720, 1f, "4K→720p, 1Mbps"), + VideoStandard.QHD_1440P to CompressionRule(720, 1f, "1440p→720p, 1Mbps"), + VideoStandard.FHD_1080P to CompressionRule(480, 0.75f, "1080p→480p, 0.75Mbps"), + VideoStandard.HD_720P to CompressionRule(360, 0.5f, "720p→360p, 0.5Mbps"), + VideoStandard.SD_480P to CompressionRule(240, 0.5f, "480p→240p, 0.5Mbps"), + VideoStandard.NHD_360P to CompressionRule(240, 0.3f, "360p→240p, 0.3Mbps"), + VideoStandard.QVGA_240P to CompressionRule(180, 0.2f, "240p→180p, 0.2Mbps"), + VideoStandard.UNKNOWN to CompressionRule(480, 0.75f, "Low quality fallback, 0.75Mbps"), ), CompressorQuality.MEDIUM to mapOf( - VideoStandard.UHD_4K to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), - VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), - VideoStandard.FHD_1080P to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), - VideoStandard.HD_720P to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), - VideoStandard.SD_480P to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), - VideoStandard.NHD_360P to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), - VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), - VideoStandard.UNKNOWN to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), + VideoStandard.UHD_4K to CompressionRule(1080, 4f, "4K→1080p, 4Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1080, 4f, "1440p→1080p, 4Mbps"), + VideoStandard.FHD_1080P to CompressionRule(720, 2f, "1080p→720p, 2Mbps"), + VideoStandard.HD_720P to CompressionRule(480, 1.5f, "720p→480p, 1.5Mbps"), + VideoStandard.SD_480P to CompressionRule(360, 1f, "480p→360p, 1Mbps"), + VideoStandard.NHD_360P to CompressionRule(240, 0.5f, "360p→240p, 0.5Mbps"), + VideoStandard.QVGA_240P to CompressionRule(180, 0.3f, "240p→180p, 0.3Mbps"), + VideoStandard.UNKNOWN to CompressionRule(720, 2f, "Medium quality fallback, 2Mbps"), ), CompressorQuality.HIGH to mapOf( - VideoStandard.UHD_4K to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), - VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), - VideoStandard.FHD_1080P to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), - VideoStandard.HD_720P to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), - VideoStandard.SD_480P to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), - VideoStandard.NHD_360P to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), - VideoStandard.QVGA_240P to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), - VideoStandard.UNKNOWN to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), + VideoStandard.UHD_4K to CompressionRule(2160, 8f, "4K→4K, 8Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1080, 8f, "1440p→1080p, 8Mbps"), + VideoStandard.FHD_1080P to CompressionRule(1080, 4f, "1080p→1080p, 4Mbps"), + VideoStandard.HD_720P to CompressionRule(720, 2f, "720p→720p, 2Mbps"), + VideoStandard.SD_480P to CompressionRule(480, 1.5f, "480p→480p, 1.5Mbps"), + VideoStandard.NHD_360P to CompressionRule(360, 1f, "360p→360p, 1Mbps"), + VideoStandard.QVGA_240P to CompressionRule(240, 0.5f, "240p→240p, 0.5Mbps"), + VideoStandard.UNKNOWN to CompressionRule(1080, 4f, "High quality fallback, 4Mbps"), ), ) @@ -162,14 +162,13 @@ object VideoCompressionHelper { .getValue(info.resolution.getStandard()) val bitrateBps = rule.getBitrateBps(info.framerate, useH265) - Log.d(LOG_TAG, "Bitrate: ${bitrateBps}bps for ${info.resolution.getStandard()} quality=$mediaQuality framerate=${info.framerate}fps useH265=$useH265.") + Log.d(LOG_TAG) { "Bitrate: ${bitrateBps}bps for ${info.resolution.getStandard()} quality=$mediaQuality framerate=${info.framerate}fps useH265=$useH265." } - Log.d( - LOG_TAG, + Log.d(LOG_TAG) { "Resizer: ${info.resolution.width}x${info.resolution.height} -> " + - "${rule.width}x${rule.height} (${rule.description})", - ) - val resizer = VideoResizer.limitSize(rule.width.toDouble(), rule.height.toDouble()) + "shortSide=${rule.shortSide} (${rule.description})" + } + val resizer = VideoResizer.limitShortSide(rule.shortSide.toDouble()) Pair(bitrateBps, resizer) } ?: run { @@ -218,7 +217,7 @@ object VideoCompressionHelper { if (path == null) { applicationContext.notifyUser( "Video compression succeeded, but path was null", - Log.WARN, + LogLevel.WARN, ) if (continuation.isActive) continuation.resume(null) return @@ -232,10 +231,13 @@ object VideoCompressionHelper { } // Sanity check: compression not smaller than original - if (originalSize > 0 && size >= originalSize) { + if (originalSize in 1..size) { + if (!File(path).delete()) { + Log.w("VideoCompressionHelper") { "Failed to delete compressed file: $path" } + } applicationContext.notifyUser( "Compressed file larger than original. Using original.", - Log.WARN, + LogLevel.WARN, ) if (continuation.isActive) { continuation.resume( @@ -255,11 +257,10 @@ object VideoCompressionHelper { ) } - Log.d( - LOG_TAG, + Log.d(LOG_TAG) { "Compression success: Original [$originalSize] -> " + - "Compressed [$size] ($reductionPercent% reduction)", - ) + "Compressed [$size] ($reductionPercent% reduction)" + } if (continuation.isActive) { continuation.resume( @@ -274,7 +275,7 @@ object VideoCompressionHelper { ) { applicationContext.notifyUser( "Video compression failed: $failureMessage", - Log.ERROR, + LogLevel.ERROR, ) if (continuation.isActive) continuation.resume(null) } @@ -298,22 +299,23 @@ object VideoCompressionHelper { if (cursor.moveToFirst()) cursor.getLong(sizeIndex) else 0L } ?: 0L } catch (e: Exception) { - Log.w(LOG_TAG, "Failed to get file size: ${e.message}") + Log.w(LOG_TAG) { "Failed to get file size: ${e.message}" } 0L } private fun Context.notifyUser( message: String, - logLevel: Int = Log.DEBUG, + logLevel: LogLevel = LogLevel.DEBUG, duration: Int = Toast.LENGTH_LONG, ) { Handler(Looper.getMainLooper()).post { Toast.makeText(this, message, duration).show() } when (logLevel) { - Log.ERROR -> Log.e(LOG_TAG, message) - Log.WARN -> Log.w(LOG_TAG, message) - else -> Log.d(LOG_TAG, message) + LogLevel.ERROR -> Log.e(LOG_TAG, message) + LogLevel.WARN -> Log.w(LOG_TAG, message) + LogLevel.INFO -> Log.i(LOG_TAG, message) + LogLevel.DEBUG -> Log.d(LOG_TAG, message) } } @@ -346,13 +348,13 @@ object VideoCompressionHelper { null } } catch (e: Exception) { - Log.w(LOG_TAG, "Failed to get video resolution: ${e.message}") + Log.w(LOG_TAG) { "Failed to get video resolution: ${e.message}" } null } finally { try { retriever?.release() } catch (e: Exception) { - Log.w(LOG_TAG, "Failed to release MediaMetadataRetriever: ${e.message}") + Log.w(LOG_TAG) { "Failed to release MediaMetadataRetriever: ${e.message}" } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt index 8bf65ed8e..26c38d5df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt @@ -36,8 +36,6 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.withTimeoutOrNull import okhttp3.OkHttpClient -import kotlin.collections.toTypedArray -import kotlin.let class BlossomServerResolver( val loggedInUsers: () -> List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 26327122b..933dda809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -60,7 +60,7 @@ class MainActivity : AppCompatActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) - Log.d("ActivityLifecycle", "MainActivity.onCreate $this") + Log.d("ActivityLifecycle") { "MainActivity.onCreate $this" } setContent { StringResSetup() @@ -74,14 +74,14 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() - Log.d("ActivityLifecycle", "MainActivity.onResume $this") + Log.d("ActivityLifecycle") { "MainActivity.onResume $this" } // starts muted every time DEFAULT_MUTED_SETTING.value = true } override fun onPause() { - Log.d("ActivityLifecycle", "MainActivity.onPause $this") + Log.d("ActivityLifecycle") { "MainActivity.onPause $this" } @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { @@ -106,11 +106,11 @@ class MainActivity : AppCompatActivity() { // serviceManager.trimMemory() // } - Log.d("ActivityLifecycle", "MainActivity.onStop $this") + Log.d("ActivityLifecycle") { "MainActivity.onStop $this" } } override fun onDestroy() { - Log.d("ActivityLifecycle", "MainActivity.onDestroy $this") + Log.d("ActivityLifecycle") { "MainActivity.onDestroy $this" } BackgroundMedia.removeBackgroundControllerAndReleaseIt() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt index 3e801e894..66330ef81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt @@ -39,6 +39,12 @@ private var resourceCacheLanguage: String? = null // Caches most common icons in the app to avoid using disk private val iconCache = LruCache>(30) +fun resourceCacheInit() { + resourceCache + resourceCacheLanguage + iconCache +} + fun checkLanguage(currentLanguage: String) { if (resourceCacheLanguage == null) { resourceCacheLanguage = currentLanguage diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 53cd5a131..4219ce80d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -119,7 +119,7 @@ open class EditPostViewModel : ViewModel() { this.editedFromNote = edit this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05Client) + this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) } fun sendPost() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt index 411996a8f..2b4a9f413 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt @@ -38,16 +38,19 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg +import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg2 +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import kotlinx.coroutines.launch import java.io.PrintWriter import java.io.StringWriter @@ -72,6 +75,27 @@ fun InformationDialog( InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss) } +@Composable +fun InformationDialog( + obj: ThrowableToastMsg2, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onDismiss: () -> Unit, +) { + val str = stringRes(obj.description) + + val stack = + remember(obj) { + val writer = StringWriter() + writer.append("\n") + + obj.throwable.printStackTrace(PrintWriter(writer)) + + writer.toString() + } + + InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss) +} + @Composable fun InformationDialog( title: String, @@ -97,9 +121,12 @@ fun InformationDialog( horizontalArrangement = Arrangement.SpaceBetween, ) { moreInfo?.let { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() TextButton(onClick = { - clipboardManager.setText(AnnotatedString(it)) + scope.launch { + clipboardManager.setText(it) + } }) { Text(stringRes(R.string.copy_stack_to_clipboard)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 23a5e561d..aef785e87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -109,7 +109,12 @@ class BlossomServersViewModel : ViewModel() { serverUrl: String, ) { viewModelScope.launch { - val serverName = name.ifBlank { Rfc3986.host(serverUrl) } + val serverName = + name.ifBlank { + runCatching { + Rfc3986.host(serverUrl) + }.getOrNull() + } ?: serverUrl _fileServers.update { it.minus( ServerName(serverName, serverUrl, ServerType.Blossom), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt index 5f7188d03..9883511d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt @@ -61,7 +61,7 @@ class VoiceAnonymizationController( preset: VoicePreset, originalFile: File?, ) { - Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}") + Log.d(logTag) { "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}" } if (processingPreset != null || preset == selectedPreset) return if (preset == VoicePreset.NONE) { @@ -110,9 +110,9 @@ class VoiceAnonymizationController( try { if (result.file.exists()) { if (result.file.delete()) { - Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") + Log.d(logTag) { "Deleted distorted file: ${result.file.absolutePath}" } } else { - Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}") + Log.w(logTag) { "Failed to delete distorted file: ${result.file.absolutePath}" } } } } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt index a8e6ff782..f9fd2fc65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt @@ -25,7 +25,6 @@ import android.media.MediaCodecInfo import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMuxer -import android.util.Log import be.tarsos.dsp.AudioDispatcher import be.tarsos.dsp.AudioEvent import be.tarsos.dsp.AudioProcessor @@ -33,6 +32,7 @@ import be.tarsos.dsp.WaveformSimilarityBasedOverlapAdd import be.tarsos.dsp.io.TarsosDSPAudioFloatConverter import be.tarsos.dsp.io.TarsosDSPAudioFormat import be.tarsos.dsp.resample.RateTransposer +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index c87d34b75..43bb07f54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.media.MediaPlayer -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -60,6 +59,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import java.io.File diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index e87e175f9..5dff75f14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -44,6 +44,7 @@ class RecordingResult( val duration: Int, ) +@Suppress("DEPRECATION") class VoiceMessageRecorder { @Volatile private var recorder: MediaRecorder? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index ed5ac081a..deb0ffcbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -40,18 +40,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState 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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat.startActivity import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu @@ -59,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon import com.vitorpamplona.amethyst.ui.note.ZapIcon @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable @@ -142,7 +143,6 @@ fun CashuPreviewNew( toast: (String, String) -> Unit, ) { val context = LocalContext.current - val clipboardManager = LocalClipboardManager.current Card( modifier = CashuCardBorders, @@ -217,7 +217,7 @@ fun CashuPreviewNew( val intent = Intent(Intent.ACTION_VIEW, "cashu://${token.token}".toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(context, intent, null) + context.startActivity(intent) } catch (e: Exception) { if (e is CancellationException) throw e toast(stringRes(context, R.string.cashu), stringRes(context, R.string.cashu_no_wallet_found)) @@ -231,10 +231,15 @@ fun CashuPreviewNew( Spacer(modifier = StdHorzSpacer) + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + FilledTonalButton( onClick = { - // Copying the token to clipboard - clipboardManager.setText(AnnotatedString(token.token)) + scope.launch { + // Copying the token to clipboard + clipboardManager.setText(token.token) + } }, shape = SmallishBorder, contentPadding = PaddingValues(0.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt index 00964101d..1d55e843a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt @@ -25,24 +25,31 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.launch @Composable fun ClickableRelayUrl( relayUrl: String, nav: INav, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() val clickableModifier = remember(relayUrl) { Modifier .combinedClickable( - onLongClick = { clipboardManager.setText(AnnotatedString(relayUrl)) }, + onLongClick = { + scope.launch { + clipboardManager.setText(relayUrl) + } + }, onClick = { nav.nav(Route.RelayInfo(relayUrl)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt index 81e1a8c22..1bfc73fca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt @@ -44,7 +44,6 @@ fun SetDialogToEdgeToEdge() { attributes.copyFrom(activityWindow.attributes) attributes.type = dialogWindow.attributes.type dialogWindow.attributes = attributes - dialogWindow.statusBarColor parentView.layoutParams = FrameLayout.LayoutParams( activityWindow.decorView.width, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 90bad3c7f..2b91c47c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -250,7 +250,7 @@ object ShareHelper { if (!renamed) { tempFile.copyTo(sharableFile, overwrite = true) if (!tempFile.delete()) { - Log.w(TAG, "Failed to delete temp file ${tempFile.path} after copy") + Log.w(TAG) { "Failed to delete temp file ${tempFile.path} after copy" } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index bbd56206c..78027c4d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -31,20 +31,22 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -59,7 +61,8 @@ fun UrlPreviewCard( } if (popupExpanded.value) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() M3ActionDialog( title = stringRes(R.string.link_actions_dialog_title), onDismiss = { popupExpanded.value = false }, @@ -69,8 +72,10 @@ fun UrlPreviewCard( icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_url_to_clipboard), ) { - clipboardManager.setText(AnnotatedString(url)) - popupExpanded.value = false + scope.launch { + clipboardManager.setText(url) + popupExpanded.value = false + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 2fce2a808..a8c0c1c66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.components import android.Manifest import android.content.Context import android.os.Build +import android.os.Handler +import android.os.Looper import android.view.WindowManager import android.widget.Toast import androidx.compose.animation.AnimatedVisibility @@ -301,6 +303,15 @@ private fun DialogContent( } } +private fun showToastOnMain( + context: Context, + resId: Int, +) { + Handler(Looper.getMainLooper()).post { + Toast.makeText(context.applicationContext, resId, Toast.LENGTH_SHORT).show() + } +} + private suspend fun saveMediaToGallery( content: BaseMediaContent, localContext: Context, @@ -324,7 +335,7 @@ private suspend fun saveMediaToGallery( }, localContext, onSuccess = { - accountViewModel.toastManager.toast(success, success) + showToastOnMain(localContext, success) }, onError = { accountViewModel.toastManager.toast(failure, null, it) @@ -337,7 +348,7 @@ private suspend fun saveMediaToGallery( content.mimeType, localContext, onSuccess = { - accountViewModel.toastManager.toast(success, success) + showToastOnMain(localContext, success) }, onError = { innerIt -> accountViewModel.toastManager.toast(failure, null, innerIt) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 722bd77c5..357f85ec4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -54,15 +54,15 @@ 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow @@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.painterRes @@ -771,20 +772,25 @@ fun ShareMediaAction( title = stringRes(R.string.media_actions_dialog_title), onDismiss = { if (!isDownloadingVideo.value) onDismiss() }, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() // Copy & Gallery section if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) { M3ActionSection { if (videoUri != null && !videoUri.startsWith("file")) { M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) { - clipboardManager.setText(AnnotatedString(videoUri)) + scope.launch { + clipboardManager.setText(videoUri) + } onDismiss() } } postNostrUri?.let { M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) { - clipboardManager.setText(AnnotatedString(it)) + scope.launch { + clipboardManager.setText(it) + } onDismiss() } M3ActionRow(icon = Icons.Outlined.Collections, text = stringRes(R.string.add_media_to_gallery)) { @@ -954,7 +960,7 @@ private suspend fun shareVideoFile( delay(SHARED_VIDEO_CLEANUP_DELAY_MS) sharedFile?.let { file -> if (!file.delete()) { - Log.w("ZoomableContentView", "Failed to delete shared file: ${file.path}") + Log.w("ZoomableContentView") { "Failed to delete shared file: ${file.path}" } } } } @@ -968,11 +974,11 @@ private suspend fun shareVideoFile( // Clean up temp file on error if (!tempFile.delete()) { - Log.w("ZoomableContentView", "Failed to delete temp file: ${tempFile.path}") + Log.w("ZoomableContentView") { "Failed to delete temp file: ${tempFile.path}" } } sharedFile?.let { file -> if (!file.delete()) { - Log.w("ZoomableContentView", "Failed to delete shared file: ${file.path}") + Log.w("ZoomableContentView") { "Failed to delete shared file: ${file.path}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt index 89c945c91..d7289698d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt @@ -82,6 +82,12 @@ fun DisplayErrorMessages( } } + is ThrowableToastMsg2 -> { + InformationDialog(obj) { + toastManager.clearToasts() + } + } + is MultiErrorToastMsg -> { MultiUserErrorMessageDialog(obj, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt index 99d24adaa..4148e4a81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt @@ -28,3 +28,10 @@ class ThrowableToastMsg( val msg: String? = null, val throwable: Throwable, ) : ToastMsg() + +@Immutable +class ThrowableToastMsg2( + val titleResId: Int, + val description: Int, + val throwable: Throwable, +) : ToastMsg() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt index a19b889a0..c08b11373 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.components.toasts +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiErrorToastMsg import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage import kotlinx.coroutines.flow.MutableStateFlow +@Stable class ToastManager { val toasts = MutableStateFlow(null) @@ -62,6 +64,14 @@ class ToastManager { toasts.tryEmit(ThrowableToastMsg(titleResId, message, throwable)) } + fun toast( + titleResId: Int, + description: Int, + throwable: Throwable, + ) { + toasts.tryEmit(ThrowableToastMsg2(titleResId, description, throwable)) + } + fun toast( titleResId: Int, resourceId: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/ClipboardExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/ClipboardExt.kt new file mode 100644 index 000000000..8b3b7a0c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/ClipboardExt.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components.util + +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard + +suspend fun Clipboard.setText(text: String) { + setClipEntry(ClipEntry(ClipData.newPlainText("", text))) +} + +suspend fun Clipboard.getText(): String? = + getClipEntry() + ?.clipData + ?.getItemAt(0) + ?.text + ?.toString() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt deleted file mode 100644 index f87fffad3..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.components.util - -import android.app.Activity -import android.content.Context -import android.content.pm.ActivityInfo -import android.content.pm.PackageManager -import android.provider.Settings -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.window.core.layout.WindowHeightSizeClass -import androidx.window.core.layout.WindowSizeClass -import androidx.window.core.layout.WindowWidthSizeClass -import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils.screenOrientationIsLocked - -object DeviceUtils { - /** - * Tries to determine if the device is - * in landscape mode, by using the [android.util.DisplayMetrics] API. - * - * Credits: NewPipe devs - */ - fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels - - /** - * Checks if the device's orientation is set to locked. - * - * Credits: NewPipe devs - */ - fun screenOrientationIsLocked(context: Context): Boolean { - // 1: Screen orientation changes using accelerometer - // 0: Screen orientation is locked - // if the accelerometer sensor is missing completely, assume locked orientation - return ( - Settings.System.getInt( - context.contentResolver, - Settings.System.ACCELEROMETER_ROTATION, - 0, - ) == 0 || - !context.packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER) - ) - } - - /** - * Changes the device's orientation. This works even if the device's orientation - * is set to locked. - * Thus, to prevent unwanted behaviour, - * it's use can be guarded by conditions such as [screenOrientationIsLocked]. - */ - fun changeDeviceOrientation( - isInLandscape: Boolean, - currentActivity: Activity, - ) { - val newOrientation = - if (isInLandscape) { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } else { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } - currentActivity.requestedOrientation = newOrientation - } - - /** - * This method looks at the window in which the app resides, - * and determines if it is large, while making sure not to be affected - * by configuration changes(such as screen rotation), - * as the device display metrics can be affected as well. - * - * It could be used as an approximation of the type of device(as is the case here), - * though one ought to be careful about multi-window situations. - */ - - @Composable - fun windowIsLarge( - isInLandscapeMode: Boolean, - windowSize: WindowSizeClass, - ): Boolean = - remember(windowSize) { - if (isInLandscapeMode) { - when (windowSize.windowHeightSizeClass) { - WindowHeightSizeClass.COMPACT -> false - WindowHeightSizeClass.MEDIUM -> true - WindowHeightSizeClass.EXPANDED -> true - else -> true - } - } else { - when (windowSize.windowWidthSizeClass) { - WindowWidthSizeClass.EXPANDED -> true - WindowWidthSizeClass.MEDIUM -> true - WindowWidthSizeClass.COMPACT -> false - else -> true - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt index 7b53a2fdd..106cd2ba8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -176,7 +176,7 @@ class ChannelFeedContentState( } fun destroy() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } bundlerInsert.cancel() bundler.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 2cc7b942c..4e5dabc4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -55,7 +55,11 @@ object ScrollStateKeys { const val DISCOVER_COMMUNITY = "DiscoverCommunitiesFeed" const val DISCOVER_CHATS = "DiscoverChatsFeed" + const val POLLS_SCREEN = "PollsFeed" + const val SEARCH_SCREEN = "SearchFeed" + + const val WEB_BOOKMARKS = "WebBookmarksFeed" } object PagerStateKeys { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt index 931570bd8..a70737255 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.TopAppBarState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -217,6 +218,7 @@ fun enterAlwaysScrollBehavior( * Custom copy of EnterAlwaysScrollBehavior that correctly handles reversed layouts */ @OptIn(ExperimentalMaterial3Api::class) +@Stable class CustomEnterAlwaysScrollBehavior( override val state: TopAppBarState, override val snapAnimationSpec: AnimationSpec?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 889b5058d..8df8f6c71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.navigation import android.content.Intent import android.net.Uri -import android.os.Parcelable import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -35,13 +34,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri +import androidx.core.content.IntentCompat import androidx.core.util.Consumer -import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages @@ -93,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals.NewGoalScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen @@ -104,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPic import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen @@ -112,6 +110,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen @@ -128,6 +130,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.nip01Core.core.Address @@ -143,218 +146,8 @@ fun AppNavigation( ) { val nav = rememberNav() - val navBackStackEntry by nav.controller.currentBackStackEntryAsState() - val isTabPagerRoute = - navBackStackEntry?.destination?.let { dest -> - dest.hasRoute() || dest.hasRoute() - } ?: false - val drawerGesturesEnabled = - !isTabPagerRoute || - nav.drawerState.isOpen || - nav.drawerState.targetValue != nav.drawerState.currentValue - - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) { - NavHost( - navController = nav.controller, - startDestination = Route.Home, - enterTransition = { fadeIn(animationSpec = tween(200)) }, - exitTransition = { fadeOut(animationSpec = tween(200)) }, - ) { - composable { HomeScreen(accountViewModel, nav) } - composable { MessagesScreen(accountViewModel, nav) } - composable { VideoScreen(accountViewModel, nav) } - composable { DiscoverScreen(accountViewModel, nav) } - composable { NotificationScreen(accountViewModel, nav) } - composable { ChessLobbyScreen(accountViewModel, nav) } - - composableFromEnd { WalletScreen(accountViewModel, nav) } - composableFromEnd { WalletSendScreen(accountViewModel, nav) } - composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } - composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } - - composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } - composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } - composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } - - composableFromBottomArgs { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } - - composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } - composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } - composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } - composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - - composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } - - composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } - - composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } - composable { SearchScreen(accountViewModel, nav) } - - composableFromEnd { AllSettingsScreen(accountViewModel, nav) } - composableFromEnd { AccountBackupScreen(accountViewModel, nav) } - composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } - composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } - composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } - composableFromEnd { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } - composableFromEnd { BookmarkListScreen(accountViewModel, nav) } - composableFromEnd { DraftListScreen(accountViewModel, nav) } - composableFromEnd { SettingsScreen(accountViewModel, nav) } - composableFromEnd { UserSettingsScreen(accountViewModel, nav) } - composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } - composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } - composableFromEndArgs { - ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) - } - - composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } - composableFromEnd { EventSyncScreen(accountViewModel, nav) } - composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } - composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } - - composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } - composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } - composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } - composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } - composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } - composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - - composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } - composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } - - composableFromEndArgs { - PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) - } - - composableFromEndArgs { - LiveActivityChannelScreen( - Address(it.kind, it.pubKeyHex, it.dTag), - draftId = it.draftId, - replyToId = it.replyTo, - accountViewModel, - nav, - ) - } - - composableFromEndArgs { - EphemeralChatScreen( - id = it.id, - relayUrl = it.relayUrl, - draftId = it.draftId, - replyToId = it.replyTo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } - composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } - composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } - - composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } - - composableFromBottomArgs { - GeoHashPostScreen( - geohash = it.geohash, - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - replyId = it.replyTo, - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - NewPublicMessageScreen( - to = it.toKey(), - replyId = it.replyId, - draftId = it.draftId, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { - HashtagPostScreen( - hashtag = it.hashtag, - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - replyId = it.replyTo, - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - ReplyCommentPostScreen( - replyId = it.replyTo, - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - NewProductScreen( - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - LongFormPostScreen( - draftId = it.draft, - versionId = it.version, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { - ShortNotePostScreen( - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - baseReplyToId = it.baseReplyTo, - quoteId = it.quote, - forkId = it.fork, - versionId = it.version, - draftId = it.draft, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { - VoiceReplyScreen( - replyToNoteId = it.replyToNoteId, - recordingFilePath = it.recordingFilePath, - mimeType = it.mimeType, - duration = it.duration, - amplitudesJson = it.amplitudes, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { + BuildNavigation(accountViewModel, nav) } NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) @@ -365,6 +158,227 @@ fun AppNavigation( DisplayBroadcastProgress(accountViewModel) } +@Composable +fun BuildNavigation( + accountViewModel: AccountViewModel, + nav: Nav, +) { + NavHost( + navController = nav.controller, + startDestination = Route.Home, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) }, + ) { + composable { HomeScreen(accountViewModel, nav) } + composable { MessagesScreen(accountViewModel, nav) } + composable { VideoScreen(accountViewModel, nav) } + composable { DiscoverScreen(accountViewModel, nav) } + composable { NotificationScreen(accountViewModel, nav) } + composableFromEnd { PollsScreen(accountViewModel, nav) } + composable { ChessLobbyScreen(accountViewModel, nav) } + + composableFromEnd { WalletScreen(accountViewModel, nav) } + composableFromEnd { WalletSendScreen(accountViewModel, nav) } + composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } + composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + + composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } + composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } + composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } + + composableFromBottomArgs { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } + + composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } + composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } + composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } + composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + + composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } + + composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } + + composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } + composable { SearchScreen(accountViewModel, nav) } + + composableFromEnd { AllSettingsScreen(accountViewModel, nav) } + composableFromEnd { AccountBackupScreen(accountViewModel, nav) } + composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } + composableFromEnd { PrivacyOptionsScreen(nav) } + composableFromEnd { NamecoinSettingsScreen(nav) } + composableFromEnd { OtsSettingsScreen(nav) } + composableFromEnd { BookmarkListScreen(accountViewModel, nav) } + composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } + composableFromEnd { DraftListScreen(accountViewModel, nav) } + composableFromEnd { SettingsScreen(accountViewModel, nav) } + composableFromEnd { UserSettingsScreen(accountViewModel, nav) } + composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } + composableFromEndArgs { + ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) + } + + composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + composableFromEnd { EventSyncScreen(accountViewModel, nav) } + composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } + composableFromEnd { VanishEventsScreen(accountViewModel, nav) } + composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } + + composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } + composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } + composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } + composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } + composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + + composableFromEndArgs { + PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) + } + + composableFromEndArgs { + LiveActivityChannelScreen( + Address(it.kind, it.pubKeyHex, it.dTag), + draftId = it.draftId, + replyToId = it.replyTo, + accountViewModel, + nav, + ) + } + + composableFromEndArgs { + EphemeralChatScreen( + id = it.id, + relayUrl = it.relayUrl, + draftId = it.draftId, + replyToId = it.replyTo, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } + composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } + + composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } + + composableFromBottomArgs { + GeoHashPostScreen( + geohash = it.geohash, + message = it.message, + attachment = it.attachment, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + NewPublicMessageScreen( + to = it.toKey(), + replyId = it.replyId, + draftId = it.draftId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottom { + NewGoalScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { + HashtagPostScreen( + hashtag = it.hashtag, + message = it.message, + attachment = it.attachment, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + ReplyCommentPostScreen( + replyId = it.replyTo, + message = it.message, + attachment = it.attachment, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + NewProductScreen( + message = it.message, + attachment = it.attachment, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + LongFormPostScreen( + draftId = it.draft, + versionId = it.version, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { + ShortNotePostScreen( + message = it.message, + attachment = it.attachment, + baseReplyToId = it.baseReplyTo, + quoteId = it.quote, + forkId = it.fork, + versionId = it.version, + draftId = it.draft, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { + VoiceReplyScreen( + replyToNoteId = it.replyToNoteId, + recordingFilePath = it.recordingFilePath, + mimeType = it.mimeType, + duration = it.duration, + amplitudesJson = it.amplitudes, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + @Composable private fun NavigateIfIntentRequested( nav: Nav, @@ -397,7 +411,7 @@ private fun NavigateIfIntentRequested( var media by remember { mutableStateOf( - (activity.intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri), + IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java), ) } @@ -467,7 +481,7 @@ private fun NavigateIfIntentRequested( nav.newStack(Route.NewShortNote(message = it)) } - (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { nav.newStack(Route.NewShortNote(attachment = it.toString())) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index dd1eb0605..8dcb5f6c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -20,25 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.navigation.drawer -import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -51,6 +57,7 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Sync import androidx.compose.material3.HorizontalDivider @@ -66,16 +73,18 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource 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.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -111,8 +120,11 @@ import com.vitorpamplona.amethyst.ui.theme.IconRowModifier import com.vitorpamplona.amethyst.ui.theme.IconRowTextModifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier +import com.vitorpamplona.amethyst.ui.theme.Size22ModifierWith4Padding +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.TextStyleBottomNavBar import com.vitorpamplona.amethyst.ui.theme.Width16Space import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing @@ -223,9 +235,9 @@ fun ProfileContentTemplate( modifier = bannerModifier, ) } else { - Image( - painter = painterRes(R.drawable.profile_banner, 3), - contentDescription = stringRes(R.string.profile_banner), + AsyncImage( + model = R.drawable.profile_banner, + contentDescription = stringResource(R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = bannerModifier, ) @@ -274,30 +286,108 @@ private fun EditStatusBoxes( val statuses by observeUserStatuses(baseAccountUser, accountViewModel) if (statuses.isEmpty()) { - StatusEditBar(accountViewModel = accountViewModel, nav = nav) + PreviewStatusEditBar(accountViewModel = accountViewModel, nav = nav) } else { statuses.forEach { val noteStatus by observeNote(it, accountViewModel) - StatusEditBar(noteStatus.note.event?.content, it.address, accountViewModel, nav) + PreviewStatusEditBar(noteStatus.note.event?.content, it.address, accountViewModel, nav) } } } +@Composable +fun PreviewStatusEditBar( + savedStatus: String? = null, + address: Address? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + var isEditing by remember { mutableStateOf(false) } + + if (isEditing) { + StatusEditBar(savedStatus, address, onDone = { isEditing = false }, accountViewModel, nav) + } else { + FakeEditBar(savedStatus) { isEditing = true } + } +} + +@Composable +fun FakeEditBar( + savedStatus: String? = null, + onEdit: () -> Unit, +) { + // ── Static text styled to look like OutlinedTextField ─── + Box( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onEdit, + ).padding(top = 8.dp), + ) { + // Outer border — matches OutlinedTextField's unfocused border + Box( + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) // same as OutlinedTextField + .border( + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = savedStatus?.ifEmpty { null } ?: stringRes(R.string.status_update), + style = MaterialTheme.typography.bodyLarge, + color = + if (savedStatus?.ifEmpty { null } == null) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + // Floating label — sits on top of the border like Material does + Text( + text = stringRes(R.string.status_update), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .padding(start = 12.dp) + .align(Alignment.TopStart) + .offset(y = (-8).dp) // float above the border + .background(MaterialTheme.colorScheme.surface) // punch through border line + .padding(horizontal = 4.dp), + ) + } +} + @Composable fun StatusEditBar( savedStatus: String? = null, address: Address? = null, + onDone: () -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } val currentStatus = remember { mutableStateOf(savedStatus ?: "") } - val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } } + LaunchedEffect(nav.drawerState.isClosed) { if (nav.drawerState.isClosed) { focusManager.clearFocus(true) + onDone() + } else { + focusRequester.requestFocus() } } @@ -305,7 +395,7 @@ fun StatusEditBar( value = currentStatus.value, onValueChange = { currentStatus.value = it }, label = { Text(text = stringRes(R.string.status_update)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), placeholder = { Text( text = stringRes(R.string.status_update), @@ -331,8 +421,11 @@ fun StatusEditBar( ), singleLine = true, trailingIcon = { + val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } } if (hasChanged.value) { - SendButton { + SendButton( + tint = MaterialTheme.colorScheme.primary, + ) { if (address == null) { accountViewModel.createStatus(currentStatus.value) } else { @@ -353,7 +446,10 @@ fun StatusEditBar( } @Composable -fun SendButton(onClick: () -> Unit) { +fun SendButton( + tint: Color = MaterialTheme.colorScheme.placeholderText, + onClick: () -> Unit, +) { IconButton( modifier = Size26Modifier, onClick = onClick, @@ -362,7 +458,7 @@ fun SendButton(onClick: () -> Unit) { imageVector = Icons.AutoMirrored.Filled.Send, null, modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, + tint = tint, ) } } @@ -439,7 +535,9 @@ fun ListContent( icon = Icons.Default.AccountCircle, tint = MaterialTheme.colorScheme.primary, nav = nav, - route = remember { Route.Profile(accountViewModel.userProfile().pubkeyHex) }, + computeRoute = { + Route.Profile(accountViewModel.userProfile().pubkeyHex) + }, ) NavigationRow( @@ -458,6 +556,14 @@ fun ListContent( route = Route.BookmarkGroups, ) + NavigationRow( + title = R.string.web_bookmarks, + icon = Icons.Outlined.Language, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.WebBookmarks, + ) + NavigationRow( title = R.string.drafts, icon = Icons.Outlined.Drafts, @@ -466,6 +572,15 @@ fun ListContent( route = Route.Drafts, ) + NavigationRow( + title = R.string.polls, + icon = R.drawable.ic_poll, + iconReference = 1, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Polls, + ) + NavigationRow( title = R.string.wallet, icon = Icons.Outlined.AccountBalanceWallet, @@ -568,6 +683,25 @@ fun NavigationRow( ) } +@Composable +fun NavigationRow( + title: Int, + icon: ImageVector, + tint: Color, + nav: INav, + computeRoute: () -> Route, +) { + IconRow( + title = title, + icon = icon, + tint = tint, + onClick = { + nav.closeDrawer() + nav.nav(computeRoute) + }, + ) +} + @Composable fun IconRow( title: Int, @@ -576,30 +710,26 @@ fun IconRow( tint: Color, onClick: () -> Unit, ) { + val title = stringRes(title) + Row( modifier = - Modifier - .fillMaxWidth() - .clickable( - onClick = onClick, - ), + IconRowModifier.clickable( + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = IconRowModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterRes(icon, iconReference), - contentDescription = stringRes(title), - modifier = Size22Modifier, - tint = tint, - ) - Text( - modifier = IconRowTextModifier, - text = stringRes(title), - fontSize = Font18SP, - ) - } + Icon( + painter = painterRes(icon, iconReference), + contentDescription = title, + modifier = Size22Modifier, + tint = tint, + ) + Text( + modifier = IconRowTextModifier, + text = title, + fontSize = Font18SP, + ) } } @@ -610,32 +740,28 @@ fun IconRow( tint: Color, onClick: () -> Unit, ) { + val title = stringRes(title) + Row( modifier = - Modifier - .fillMaxWidth() - .clickable( - onClickLabel = stringRes(title), - onClick = onClick, - ), + IconRowModifier.clickable( + onClickLabel = title, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = IconRowModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = stringRes(title), - modifier = Size22Modifier.padding(end = 4.dp), - tint = tint, - ) + Icon( + imageVector = icon, + contentDescription = title, + modifier = Size22ModifierWith4Padding, + tint = tint, + ) - Text( - modifier = IconRowTextModifier, - text = stringRes(title), - fontSize = Font18SP, - ) - } + Text( + modifier = IconRowTextModifier, + text = title, + fontSize = Font18SP, + ) } } @@ -714,6 +840,7 @@ fun BottomContent( .fillMaxWidth() .padding(horizontal = 15.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween, ) { val string = remember { @@ -721,18 +848,16 @@ fun BottomContent( withLink( LinkAnnotation.Clickable( "clickable", - TextLinkStyles( - SpanStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ), - ), + TextStyleBottomNavBar, ) { nav.nav(Route.Note(BuildConfig.RELEASE_NOTES_ID)) nav.closeDrawer() }, ) { - append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) + append("v") + append(BuildConfig.VERSION_NAME) + append("-") + append(BuildConfig.FLAVOR.uppercase()) } } } @@ -743,7 +868,7 @@ fun BottomContent( overflow = TextOverflow.Ellipsis, maxLines = 1, ) - Box(modifier = Modifier.weight(1F)) + IconButton( onClick = { nav.nav(Route.QRDisplay(user.pubkeyHex)) @@ -753,7 +878,7 @@ fun BottomContent( Icon( painter = painterRes(R.drawable.ic_qrcode, 2), contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), - modifier = Modifier.size(24.dp), + modifier = Size24Modifier, tint = MaterialTheme.colorScheme.primary, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 905b203bd..6eabdd005 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -54,6 +53,7 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent fun routeFor( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 765748f7b..b7e953bc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -41,6 +41,8 @@ sealed class Route { @Serializable object Notification : Route() + @Serializable object Polls : Route() + @Serializable object Chess : Route() @Serializable object Wallet : Route() @@ -96,6 +98,8 @@ sealed class Route { ) } + @Serializable object WebBookmarks : Route() + @Serializable object Drafts : Route() @Serializable object AllSettings : Route() @@ -136,6 +140,10 @@ sealed class Route { @Serializable object EventSync : Route() + @Serializable object RequestToVanish : Route() + + @Serializable object VanishEvents : Route() + @Serializable object EditMediaServers : Route() @Serializable object UpdateReactionType : Route() @@ -218,6 +226,14 @@ sealed class Route { val url: String, ) : Route() + @Serializable data class RelayManagement( + val url: String, + ) : Route() + + @Serializable data class RelayMembers( + val url: String, + ) : Route() + @Serializable data class RelayFeed( val url: String, ) : Route() @@ -288,6 +304,8 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable data object NewGoal : Route() + @Serializable data class NewLongFormPost( val draft: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index be00e4845..4fe59d6de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -328,6 +328,7 @@ private enum class FeedGroup( HASHTAGS(R.string.feed_group_hashtags), COMMUNITIES(R.string.feed_group_communities), LISTS(R.string.feed_group_lists), + RELAYS(R.string.feed_group_relays), } private fun groupFeedDefinitions(options: ImmutableList): Map> { @@ -337,6 +338,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.HASHTAGS is CommunityName -> FeedGroup.COMMUNITIES is PeopleListName -> FeedGroup.LISTS + is RelayName -> FeedGroup.RELAYS else -> FeedGroup.FEEDS } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 321187de8..338eb71b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -66,71 +66,67 @@ fun BadgeCompose( val context = LocalContext.current.applicationContext - if (note == null) { - BlankNote(Modifier) - } else { - val backgroundColor = - calculateBackgroundColor( - createdAt = likeSetCard.createdAt(), - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - ) + val backgroundColor = + calculateBackgroundColor( + createdAt = likeSetCard.createdAt(), + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + ) - Column( + Column( + modifier = + Modifier + .background(backgroundColor.value) + .clickable( + onClick = { + routeFor( + note, + accountViewModel.account, + )?.let { nav.nav(it) } + }, + ), + ) { + Row( modifier = - Modifier - .background(backgroundColor.value) - .clickable( - onClick = { - routeFor( - note, - accountViewModel.account, - )?.let { nav.nav(it) } - }, - ), + Modifier.padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp, + ), ) { - Row( - modifier = - Modifier.padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp, - ), - ) { - // Draws the like picture outside the boosted card. - if (!isInnerNote) { - Box( - modifier = Modifier.width(55.dp).padding(0.dp), - ) { - Icon( - imageVector = Icons.Default.MilitaryTech, - null, - modifier = Modifier.size(25.dp).align(Alignment.TopEnd), - tint = MaterialTheme.colorScheme.primary, - ) - } + // Draws the like picture outside the boosted card. + if (!isInnerNote) { + Box( + modifier = Modifier.width(55.dp).padding(0.dp), + ) { + Icon( + imageVector = Icons.Default.MilitaryTech, + null, + modifier = Modifier.size(25.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { + Row { + Text( + stringRes(R.string.new_badge_award_notif), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 5.dp).weight(1f), + ) + + Text( + timeAgo(note.createdAt(), context = context), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + + MoreOptionsButton(note, null, accountViewModel, nav) } - Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { - Row { - Text( - stringRes(R.string.new_badge_award_notif), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 5.dp).weight(1f), - ) - - Text( - timeAgo(note.createdAt(), context = context), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - - MoreOptionsButton(note, null, accountViewModel, nav) - } - - note.replyTo?.firstOrNull()?.let { - BadgeDisplay(baseNote = it, accountViewModel) - } + note.replyTo?.firstOrNull()?.let { + BadgeDisplay(baseNote = it, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 36939b594..28e04659b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -123,7 +123,7 @@ fun LoadOts( withContext(Dispatchers.IO) { LocalCache.findEarliestOtsForNote( note = noteStatus?.note ?: note, - otsVerifCache = Amethyst.instance.otsVerifCache, + otsVerifCacheBuilder = { Amethyst.instance.otsVerifCache }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 85f1e54e0..325005dd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -134,7 +134,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine( if (nip05VerifState.isExpired()) { LaunchedEffect(key1 = nip05VerifState) { accountViewModel.runOnIO { - nip05State.checkAndUpdate(accountViewModel.nip05Client) + nip05State.checkAndUpdate(accountViewModel.nip05ClientBuilder) } } } @@ -442,7 +442,7 @@ fun ObserveAndRenderNIP05VerifiedSymbol( if (state.isExpired()) { LaunchedEffect(key1 = state) { accountViewModel.runOnIO { - nip05State.checkAndUpdate(accountViewModel.nip05Client) + nip05State.checkAndUpdate(accountViewModel.nip05ClientBuilder) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 92763735b..928c8ebb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi @@ -33,6 +35,7 @@ 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.PushPin import androidx.compose.material.icons.outlined.Timer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -111,7 +114,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderChat import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame @@ -119,10 +124,12 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGoal import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage @@ -131,8 +138,10 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessChallenge import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessGameEnd import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent +import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status +import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll @@ -140,9 +149,17 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderReaction +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember import com.vitorpamplona.amethyst.ui.note.types.RenderReport +import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderThread import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack @@ -191,7 +208,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -216,6 +232,11 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -237,10 +258,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -250,15 +274,22 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -273,6 +304,7 @@ fun NoteCompose( unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, isHiddenFeed: Boolean = false, + isPinned: Boolean = false, quotesLeft: Int, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, @@ -302,6 +334,7 @@ fun NoteCompose( unPackReply = unPackReply, makeItShort = makeItShort, canPreview = canPreview, + isPinned = isPinned, quotesLeft = quotesLeft, parentBackgroundColor = parentBackgroundColor, accountViewModel = accountViewModel, @@ -322,6 +355,7 @@ fun AcceptableNote( unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, canPreview: Boolean = true, + isPinned: Boolean = false, quotesLeft: Int, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, @@ -377,6 +411,7 @@ fun AcceptableNote( makeItShort = makeItShort, canPreview = canPreview, quotesLeft = quotesLeft, + isPinned = isPinned, parentBackgroundColor = parentBackgroundColor, accountViewModel = accountViewModel, showPopup = showPopup, @@ -433,6 +468,7 @@ fun AcceptableNote( unPackReply = unPackReply, makeItShort = makeItShort, canPreview = canPreview, + isPinned = isPinned, quotesLeft = quotesLeft, parentBackgroundColor = parentBackgroundColor, accountViewModel = accountViewModel, @@ -494,6 +530,7 @@ private fun CheckNewAndRenderNote( unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, canPreview: Boolean = true, + isPinned: Boolean = false, quotesLeft: Int, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, @@ -525,6 +562,7 @@ private fun CheckNewAndRenderNote( unPackReply = unPackReply, makeItShort = makeItShort, canPreview = canPreview, + isPinned = isPinned, quotesLeft = quotesLeft, accountViewModel = accountViewModel, nav = nav, @@ -582,6 +620,7 @@ fun InnerNoteWithReactions( unPackReply: ReplyRenderType, makeItShort: Boolean, canPreview: Boolean, + isPinned: Boolean, quotesLeft: Int, accountViewModel: AccountViewModel, nav: INav, @@ -624,6 +663,7 @@ fun InnerNoteWithReactions( makeItShort = makeItShort, canPreview = canPreview, showSecondRow = showSecondRow, + isPinned = isPinned, quotesLeft = quotesLeft, backgroundColor = backgroundColor, editState = editState, @@ -707,6 +747,7 @@ fun NoteBody( makeItShort: Boolean = false, canPreview: Boolean = true, showSecondRow: Boolean, + isPinned: Boolean = false, quotesLeft: Int, backgroundColor: MutableState, editState: State>, @@ -717,6 +758,7 @@ fun NoteBody( FirstUserInfoRow( baseNote = baseNote, showAuthorPicture = showAuthorPicture, + isPinned = isPinned, editState = editState, accountViewModel = accountViewModel, nav = nav, @@ -823,6 +865,20 @@ private fun RenderNoteRow( RenderLongFormContent(baseNote, accountViewModel, nav) } + is ThreadEvent -> { + RenderThread( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + ) + } + is CodeSnippetEvent -> { RenderCodeSnippetEvent(baseNote) } @@ -895,6 +951,30 @@ private fun RenderNoteRow( DisplayBroadcastRelayList(baseNote, backgroundColor, accountViewModel, nav) } + is RelayDiscoveryEvent -> { + RenderRelayDiscovery(baseNote, accountViewModel, nav) + } + + is RelayMembershipListEvent -> { + RenderRelayMembershipList(baseNote, accountViewModel, nav) + } + + is RelayAddMemberEvent -> { + RenderRelayAddMember(baseNote, accountViewModel, nav) + } + + is RelayRemoveMemberEvent -> { + RenderRelayRemoveMember(baseNote, accountViewModel, nav) + } + + is RelayJoinRequestEvent -> { + RenderRelayJoinRequest(baseNote, accountViewModel, nav) + } + + is RelayLeaveRequestEvent -> { + RenderRelayLeaveRequest(baseNote, accountViewModel, nav) + } + is PinListEvent -> { RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) } @@ -911,6 +991,14 @@ private fun RenderNoteRow( RenderGitRepositoryEvent(baseNote, accountViewModel, nav) } + is RootSiteEvent -> { + RenderRootSiteEvent(baseNote, accountViewModel, nav) + } + + is NamedSiteEvent -> { + RenderNamedSiteEvent(baseNote, accountViewModel, nav) + } + is GitPatchEvent -> { RenderGitPatchEvent( baseNote, @@ -1000,6 +1088,18 @@ private fun RenderNoteRow( ) } + is CashuMintEvent -> { + RenderCashuMint(noteEvent) + } + + is FedimintEvent -> { + RenderFedimint(noteEvent) + } + + is MintRecommendationEvent -> { + RenderMintRecommendation(noteEvent) + } + is ClassifiedsEvent -> { RenderClassifieds( noteEvent, @@ -1017,6 +1117,10 @@ private fun RenderNoteRow( RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) } + is GoalEvent -> { + RenderGoal(baseNote, accountViewModel, nav) + } + is HighlightEvent -> { RenderHighlight( baseNote, @@ -1076,6 +1180,18 @@ private fun RenderNoteRow( ) } + is ChatEvent -> { + RenderChat( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + is PollEvent -> { RenderPoll( baseNote, @@ -1438,6 +1554,7 @@ fun DisplayDraftChat() { fun FirstUserInfoRow( baseNote: Note, showAuthorPicture: Boolean, + isPinned: Boolean, editState: State>, accountViewModel: AccountViewModel, nav: INav, @@ -1492,6 +1609,10 @@ fun FirstUserInfoRow( DisplayDraft() } + if (isPinned) { + PinnedMark() + } + Expiration(baseNote) TimeAgo(baseNote) @@ -1504,6 +1625,16 @@ fun FirstUserInfoRow( } } +@Composable +fun PinnedMark() { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = stringRes(R.string.pinned_notes), + modifier = Modifier.padding(start = 5.dp).size(16.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) +} + @Composable fun Expiration(note: Note) { val event = note.event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 931b3d797..6b934d34d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -67,10 +67,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -80,6 +79,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.painterRes @@ -265,7 +265,7 @@ fun CardBody( ) { val context = LocalContext.current val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() val showToast = { stringRes: Int -> @@ -289,8 +289,10 @@ fun CardBody( label = stringRes(R.string.quick_action_copy_text), ) { accountViewModel.decrypt(note) { - clipboardManager.setText(AnnotatedString(it)) - showToast(R.string.copied_note_text_to_clipboard) + scope.launch { + clipboardManager.setText(it) + showToast(R.string.copied_note_text_to_clipboard) + } } onDismiss() @@ -302,7 +304,7 @@ fun CardBody( ) { note.author?.let { scope.launch { - clipboardManager.setText(AnnotatedString(it.toNostrUri())) + clipboardManager.setText(it.toNostrUri()) showToast(R.string.copied_user_id_to_clipboard) onDismiss() } @@ -314,7 +316,7 @@ fun CardBody( stringRes(R.string.quick_action_copy_note_id), ) { scope.launch { - clipboardManager.setText(AnnotatedString(note.toNostrUri())) + clipboardManager.setText(note.toNostrUri()) showToast(R.string.copied_note_id_to_clipboard) onDismiss() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 4859f8c83..a08e1c933 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -39,11 +39,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.theme.relayIconModifier import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.amethyst.ui.theme.warningColorOnSecondSurface import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.launch private const val DAMUS_RELAY_URL = "wss://relay.damus.io" @@ -129,7 +131,8 @@ fun RenderRelay( ) { val relayInfo by loadRelayInfo(relay) - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() val clickableModifier = remember(relay) { Modifier @@ -138,7 +141,9 @@ fun RenderRelay( indication = ripple24dp, interactionSource = MutableInteractionSource(), onLongClick = { - clipboardManager.setText(AnnotatedString(relay.url)) + scope.launch { + clipboardManager.setText(relay.url) + } }, onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index ca7afac13..ba4238425 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -84,11 +84,12 @@ 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.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.input.ImeAction @@ -102,6 +103,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.components.util.getText import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity @@ -120,6 +122,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch @OptIn(ExperimentalLayoutApi::class) @Composable @@ -147,8 +150,9 @@ fun UpdateZapAmountContent( accountViewModel: AccountViewModel, ) { val context = LocalContext.current - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current val uri = LocalUriHandler.current + val scope = rememberCoroutineScope() val zapTypes = listOf( @@ -443,15 +447,17 @@ fun UpdateZapAmountContent( // Paste from clipboard IconButton( onClick = { - val clipText = clipboardManager.getText()?.text - try { - clipText?.let { postViewModel.copyFromClipboard(it) } - } catch (e: IllegalArgumentException) { - accountViewModel.toastManager.toast( - R.string.invalid_nip47_uri_title, - R.string.invalid_nip47_uri_description, - clipText ?: "", - ) + scope.launch { + val clipText = clipboardManager.getText() + try { + clipText?.let { postViewModel.copyFromClipboard(it) } + } catch (e: IllegalArgumentException) { + accountViewModel.toastManager.toast( + R.string.invalid_nip47_uri_title, + R.string.invalid_nip47_uri_description, + clipText ?: "", + ) + } } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 14c5ed899..a41fad4c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -174,5 +174,5 @@ private fun speak( .speak(message) .highlight() .onDone { Log.d("TextToSpeak", "speak: done") } - .onError { Log.d("TextToSpeak", "speak error: $it") } + .onError { Log.d("TextToSpeak") { "speak error: $it" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt index d938d468d..91124f37d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt @@ -87,12 +87,21 @@ private fun DisplayUserNameWithDeleteMark( accountViewModel: AccountViewModel, ) { val innerUserState by observeUserInfo(user, accountViewModel) - innerUserState?.let { meta -> + + val meta = innerUserState + + if (meta != null) { CreateTextWithEmoji( text = remember(meta) { "✖ ${meta.info.bestName() ?: user.pubkeyDisplayHex()}" }, tags = meta.tags, color = Color.White, textAlign = TextAlign.Center, ) + } else { + Text( + text = remember(meta) { "✖ ${user.pubkeyDisplayHex()}" }, + color = Color.White, + textAlign = TextAlign.Center, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4fc579daa..4adc385d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -82,20 +82,27 @@ class UserSuggestionState( .map(::userSearchTermOrNull) .map { prefix -> if (prefix != null) { - if (prefix.contains('@')) { + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (prefix.contains('@')) { + Nip05Id.parse(prefix) + } else if (prefix.endsWith(".bit", ignoreCase = true)) { + Nip05Id("_", prefix.lowercase()) + } else { + null + } + if (nip05 != null) { runCatching { - Nip05Id.parse(prefix)?.let { nip05 -> - nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - user } + user } }.getOrNull() } else if (prefix.startsWithAny(userUriPrefixes)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 574b56771..3e64b7a13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import android.content.Intent import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.PlaylistAdd import androidx.compose.material.icons.outlined.Bookmark import androidx.compose.material.icons.outlined.BookmarkAdd import androidx.compose.material.icons.outlined.BookmarkRemove @@ -33,7 +34,7 @@ import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.PersonRemove -import androidx.compose.material.icons.outlined.PlaylistAdd +import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.Report import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Share @@ -45,10 +46,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote @@ -59,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo @@ -110,6 +110,7 @@ data class DropDownParams( val isFollowingAuthor: Boolean, val isPrivateBookmarkNote: Boolean, val isPublicBookmarkNote: Boolean, + val isPinnedNote: Boolean, val isLoggedUser: Boolean, val isSensitive: Boolean, val showSensitiveContent: Boolean?, @@ -130,6 +131,7 @@ fun NoteDropDownMenu( isFollowingAuthor = false, isPrivateBookmarkNote = false, isPublicBookmarkNote = false, + isPinnedNote = false, isLoggedUser = false, isSensitive = false, showSensitiveContent = null, @@ -164,7 +166,7 @@ fun NoteDropDownMenu( title = stringRes(R.string.note_actions_dialog_title), onDismiss = onDismiss, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current val actContext = LocalContext.current val scope = rememberCoroutineScope() @@ -183,7 +185,7 @@ fun NoteDropDownMenu( onDismiss() } } - M3ActionRow(icon = Icons.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) { + M3ActionRow(icon = Icons.AutoMirrored.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) { val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow nav.nav(Route.PeopleListManagement(authorHexKey)) onDismiss() @@ -194,20 +196,24 @@ fun NoteDropDownMenu( M3ActionSection { M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } + accountViewModel.decrypt(lastNoteVersion) { + scope.launch { + clipboardManager.setText(it) + } + } onDismiss() } M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) + clipboardManager.setText("nostr:${it.pubkeyNpub()}") onDismiss() } } } M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) { scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString(note.toNostrUri())) + clipboardManager.setText(note.toNostrUri()) onDismiss() } } @@ -220,7 +226,7 @@ fun NoteDropDownMenu( putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) } val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) + actContext.startActivity(shareIntent) onDismiss() } } @@ -265,6 +271,19 @@ fun NoteDropDownMenu( onDismiss() } } + if (state.isLoggedUser) { + if (state.isPinnedNote) { + M3ActionRow(icon = Icons.Outlined.PushPin, text = stringRes(R.string.unpin_from_profile)) { + accountViewModel.removePin(note) + onDismiss() + } + } else { + M3ActionRow(icon = Icons.Outlined.PushPin, text = stringRes(R.string.pin_to_profile)) { + accountViewModel.addPin(note) + onDismiss() + } + } + } val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { if (note.event is LongTextNoteEvent) { @@ -329,12 +348,14 @@ fun observeBookmarksFollowsAndAccount( combine( accountViewModel.account.kind3FollowList.flow, accountViewModel.account.bookmarkState.bookmarks, + accountViewModel.account.pinState.pinnedEventIdSet, accountViewModel.showSensitiveContent(), - ) { follows, bookmarks, showSensitiveContent -> + ) { follows, bookmarks, pinnedIds, showSensitiveContent -> DropDownParams( isFollowingAuthor = note.author?.pubkeyHex in follows.authors, isPrivateBookmarkNote = note in bookmarks.private, isPublicBookmarkNote = note in bookmarks.public, + isPinnedNote = note.idHex in pinnedIds, isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitiveOrNSFW() ?: false, showSensitiveContent = showSensitiveContent, @@ -345,6 +366,7 @@ fun observeBookmarksFollowsAndAccount( isFollowingAuthor = note.author?.pubkeyHex?.let { accountViewModel.account.isFollowing(it) } ?: false, isPrivateBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.private, isPublicBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.public, + isPinnedNote = accountViewModel.account.pinState.isPinned(note), isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitiveOrNSFW() ?: false, showSensitiveContent = accountViewModel.showSensitiveContent().value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index b150efe39..da55dd466 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -209,7 +209,7 @@ open class CommentPostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 3bee7dfe0..3c866565f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -49,6 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog @@ -103,7 +103,7 @@ import kotlinx.coroutines.withContext fun ReplyCommentPostScreen( replyId: HexKey? = null, message: String? = null, - attachment: Uri? = null, + attachment: String? = null, quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, @@ -127,7 +127,7 @@ fun ReplyCommentPostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 832c2a6c3..ad3c2f107 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -42,14 +42,14 @@ 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.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LinkIcon import com.vitorpamplona.amethyst.ui.painterRes @@ -74,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class) @@ -93,7 +95,8 @@ fun RenderAppDefinition( metadata?.let { theAppMetadata -> Box { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() val uri = LocalUriHandler.current if (!theAppMetadata.banner.isNullOrBlank()) { @@ -109,7 +112,13 @@ fun RenderAppDefinition( .height(125.dp) .combinedClickable( onClick = {}, - onLongClick = { clipboardManager.setText(AnnotatedString(theAppMetadata.banner!!)) }, + onLongClick = { + theAppMetadata.banner?.let { + scope.launch { + clipboardManager.setText(it) + } + } + }, ), ) @@ -161,7 +170,11 @@ fun RenderAppDefinition( .background(MaterialTheme.colorScheme.background) .combinedClickable( onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(picture)) }, + onLongClick = { + scope.launch { + clipboardManager.setText(picture) + } + }, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt index 49dfbc440..840063060 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -34,16 +34,13 @@ 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.Approval +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.HourglassTop import androidx.compose.material.icons.filled.Recommend import androidx.compose.material.icons.filled.RemoveDone -import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Star import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -84,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent @@ -106,8 +102,7 @@ fun RenderAttestationPreview() { arrayOf( arrayOf("d", "af5aa898:fe108febb997:1773941524"), arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"), - arrayOf("s", "verified"), - arrayOf("v", "valid"), + arrayOf("s", "valid"), arrayOf("client", "attestr.xyz"), ), ) @@ -161,19 +156,17 @@ fun RenderAttestation( accountViewModel: AccountViewModel, nav: INav, ) { - val validity = remember(noteEvent) { noteEvent.validity() } val status = remember(noteEvent) { noteEvent.status() } val validFrom = remember(noteEvent) { noteEvent.validFrom() } val validTo = remember(noteEvent) { noteEvent.validTo() } val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } - val statusColor = remember(status, validity) { attestationColor(status, validity) } - val statusIcon = remember(status, validity) { attestationIcon(status, validity) } - val statusLabel = attestationStatusLabel(status, validity) + val statusColor = remember(status) { attestationColor(status) } + val statusIcon = remember(status) { attestationIcon(status) } + val statusLabel = attestationStatusLabel(status) val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } - val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } Column( modifier = @@ -257,13 +250,6 @@ fun RenderAttestation( ) } } - } else if (aboutPubkey != null) { - LoadUser(aboutPubkey, accountViewModel) { - if (it != null) { - Spacer(modifier = DoubleVertSpacer) - UserCompose(it, accountViewModel = accountViewModel, nav = nav) - } - } } } @@ -322,7 +308,7 @@ fun RenderAttestationRequest( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = Icons.Default.Send, + imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = stringRes(R.string.attestation_request), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), @@ -590,49 +576,31 @@ fun RenderAttestorProficiency( } } -private fun attestationColor( - status: AttestationStatus?, - validity: Validity?, -): Color = +private fun attestationColor(status: AttestationStatus?): Color = when { - validity == Validity.INVALID -> Color(0xFFB71C1C) - validity == Validity.VALID -> Color(0xFF2E7D32) + status == AttestationStatus.INVALID -> Color(0xFFB71C1C) + status == AttestationStatus.VALID -> Color(0xFF2E7D32) status == AttestationStatus.REVOKED -> Color(0xFFB21CB7) - status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) - status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) status == AttestationStatus.VERIFYING -> Color(0xFF173CF5) - status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0) else -> Color(0xFF757575) } -private fun attestationIcon( - status: AttestationStatus?, - validity: Validity?, -): ImageVector = +private fun attestationIcon(status: AttestationStatus?): ImageVector = when { - validity == Validity.INVALID -> Icons.Default.Close - validity == Validity.VALID -> Icons.Default.CheckCircle + status == AttestationStatus.INVALID -> Icons.Default.Close + status == AttestationStatus.VALID -> Icons.Default.CheckCircle status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone - status == AttestationStatus.REJECTED -> Icons.Default.Delete - status == AttestationStatus.VERIFIED -> Icons.Default.Approval status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop - status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty else -> Icons.Default.ErrorOutline } @Composable -private fun attestationStatusLabel( - status: AttestationStatus?, - validity: Validity?, -): String = +private fun attestationStatusLabel(status: AttestationStatus?): String = when { - validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) - validity == Validity.VALID -> stringRes(R.string.attestation_valid) + status == AttestationStatus.INVALID -> stringRes(R.string.attestation_invalid) + status == AttestationStatus.VALID -> stringRes(R.string.attestation_valid) status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked) - status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected) - status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified) status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) - status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted) else -> stringRes(R.string.attestation) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt new file mode 100644 index 000000000..a499e2850 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags + +@Composable +fun RenderChat( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event ?: return + val eventContent = remember(noteEvent) { noteEvent.content } + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val tags = + remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + val callbackUri = remember(note) { note.toNostrUri() } + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 6e01eb7e6..81725b40a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -57,7 +57,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -519,7 +518,7 @@ fun ShareCommunityButton( val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) + actContext.startActivity(shareIntent) }, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/EcashMint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/EcashMint.kt new file mode 100644 index 000000000..c822a4137 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/EcashMint.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent + +@Composable +fun RenderCashuMint(noteEvent: CashuMintEvent) { + val mintUrl = remember(noteEvent) { noteEvent.mintUrl() } + val nuts = remember(noteEvent) { noteEvent.nuts() } + val network = remember(noteEvent) { noteEvent.network() } + + MintAnnouncementCard( + title = "Cashu Mint", + url = mintUrl, + network = network.code, + capabilities = nuts, + capabilitiesLabel = "NUTs", + metadata = noteEvent.content.ifBlank { null }, + ) +} + +@Composable +fun RenderFedimint(noteEvent: FedimintEvent) { + val inviteCodes = remember(noteEvent) { noteEvent.inviteCodes() } + val modules = remember(noteEvent) { noteEvent.modules() } + val network = remember(noteEvent) { noteEvent.network() } + + MintAnnouncementCard( + title = "Fedimint", + url = inviteCodes.firstOrNull(), + network = network.code, + capabilities = modules, + capabilitiesLabel = "Modules", + metadata = noteEvent.content.ifBlank { null }, + ) +} + +@Composable +fun RenderMintRecommendation(noteEvent: MintRecommendationEvent) { + val mintUrls = remember(noteEvent) { noteEvent.mintUrls() } + val mintType = + remember(noteEvent) { + when { + noteEvent.isCashuRecommendation() -> "Cashu Mint" + noteEvent.isFedimintRecommendation() -> "Fedimint" + else -> "Ecash Mint" + } + } + + MintAnnouncementCard( + title = "$mintType Recommendation", + url = mintUrls.firstOrNull(), + network = null, + capabilities = emptyList(), + capabilitiesLabel = null, + metadata = noteEvent.content.ifBlank { null }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MintAnnouncementCard( + title: String, + url: String?, + network: String?, + capabilities: List, + capabilitiesLabel: String?, + metadata: String?, +) { + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ).fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (network != null) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = network, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .clip(SmallBorder) + .border(1.dp, MaterialTheme.colorScheme.primary, SmallBorder) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + + url?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + + if (capabilities.isNotEmpty() && capabilitiesLabel != null) { + FlowRow( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "$capabilitiesLabel: ", + style = MaterialTheme.typography.labelSmall, + color = Color.Gray, + ) + capabilities.forEach { capability -> + Text( + text = capability, + style = MaterialTheme.typography.labelSmall, + color = Color.Gray, + modifier = + Modifier + .clip(SmallBorder) + .border(1.dp, Color.Gray.copy(alpha = 0.3f), SmallBorder) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + } + } + + metadata?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt new file mode 100644 index 000000000..c3e621001 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.fundraiserProgressColor +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import java.math.BigDecimal +import kotlin.math.roundToInt + +@Composable +fun RenderGoal( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? GoalEvent ?: return + + GoalHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +fun GoalHeader( + noteEvent: GoalEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val image = noteEvent.image() + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val goalAmountMillisats = noteEvent.amount() ?: 0L + val goalAmountSats = goalAmountMillisats / 1000 + val closedAt = noteEvent.closedAt() + val isClosed = closedAt != null && closedAt < TimeUtils.now() + + Column(MaterialTheme.colorScheme.replyModifier) { + image?.let { + Box { + MyAsyncImage( + imageUrl = it, + contentDescription = stringRes(R.string.preview_card_image_for, it), + contentScale = ContentScale.FillWidth, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, + onError = { DefaultImageHeader(note, accountViewModel) }, + ) + } + } + + Column(Modifier.padding(10.dp)) { + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (goalAmountSats > 0) { + GoalProgressBar( + note = note, + goalAmountSats = goalAmountSats, + accountViewModel = accountViewModel, + ) + } + + if (isClosed) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.goal_closed), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + } + } +} + +@Composable +fun GoalProgressBar( + note: Note, + goalAmountSats: Long, + accountViewModel: AccountViewModel, +) { + val zapsState by observeNoteZaps(note, accountViewModel) + + var zapraiserStatus by + remember { mutableStateOf(ZapraiserStatus(0F, showAmount(goalAmountSats.toBigDecimal()))) } + + LaunchedEffect(key1 = zapsState) { + zapsState?.note?.let { + val newZapAmount = accountViewModel.account.calculateZappedAmount(note) + var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat() + if (percentage > 1) percentage = 1f + + val left = + if (percentage > 0.99) { + "0" + } else { + showAmount( + goalAmountSats.toBigDecimal() * BigDecimal(1.0 - percentage), + ) + } + zapraiserStatus = ZapraiserStatus(percentage, left) + } + } + + Column(Modifier.fillMaxWidth()) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().height(24.dp), + color = MaterialTheme.colorScheme.fundraiserProgressColor, + progress = { zapraiserStatus.progress }, + gapSize = 0.dp, + strokeCap = StrokeCap.Square, + drawStopIndicator = {}, + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + val totalPercentage by + remember(zapraiserStatus) { + derivedStateOf { "${(zapraiserStatus.progress * 100).roundToInt()}%" } + } + + Text( + text = + stringRes( + R.string.goal_progress, + totalPercentage, + showAmount(goalAmountSats.toBigDecimal()), + ), + fontSize = Font14SP, + maxLines = 1, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 6cbb6251f..26a947d0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -361,7 +361,7 @@ fun DisplayEntryForAUrl( try { URL(url) } catch (_: Exception) { - Log.w("Note Compose", "Invalid URI: $url") + Log.w("Note Compose") { "Invalid URI: $url" } null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index 894dfacab..5f73c4c0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent @Composable fun RenderNIP90ContentDiscoveryResponse( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt index 16e1e66c2..c039390d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent @Composable fun RenderNIP90Status( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index 92d0b18ec..105a7c898 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -45,15 +45,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.PinIcon import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent @OptIn(ExperimentalLayoutApi::class) @@ -66,7 +65,7 @@ fun RenderPinListEvent( ) { val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pins()) } + val pins by remember { mutableStateOf(noteEvent.pinnedEvents()) } var expanded by remember { mutableStateOf(false) } @@ -78,7 +77,7 @@ fun RenderPinListEvent( } Text( - text = "#${noteEvent.dTag()}", + text = "Pinned Notes", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -103,15 +102,10 @@ fun RenderPinListEvent( Spacer(modifier = Modifier.width(5.dp)) - TranslatableRichTextViewer( - content = pin, - canPreview = true, - quotesLeft = 1, - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = baseNote.idHex, - accountViewModel = accountViewModel, - nav = nav, + Text( + text = NEvent.create(pin.eventId, pin.author, null, pin.relay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt index a2b8773ad..73b05b19c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt @@ -43,8 +43,8 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfHalfTopPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent @Composable fun RenderPublicMessage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayDiscovery.kt new file mode 100644 index 000000000..e1b24815a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayDiscovery.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.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.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.Dns +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.TravelExplore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderRelayDiscovery( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? RelayDiscoveryEvent ?: return + val context = LocalContext.current + + val relayUrl = remember(noteEvent) { noteEvent.relay() } + val rttOpen = remember(noteEvent) { noteEvent.rttOpen() } + val rttRead = remember(noteEvent) { noteEvent.rttRead() } + val rttWrite = remember(noteEvent) { noteEvent.rttWrite() } + val networkTypes = remember(noteEvent) { noteEvent.networkTypes() } + val relayTypes = remember(noteEvent) { noteEvent.relayTypes() } + val supportedNips = remember(noteEvent) { noteEvent.supportedNips() } + val requirements = remember(noteEvent) { noteEvent.requirements() } + val acceptedKinds = remember(noteEvent) { noteEvent.acceptedKinds() } + val topics = remember(noteEvent) { noteEvent.topics() } + val geohashes = remember(noteEvent) { noteEvent.geohashes() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Relay URL header + if (relayUrl != null) { + Text( + text = relayUrl.displayUrl(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // RTT metrics + if (rttOpen != null || rttRead != null || rttWrite != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + rttOpen?.let { + RttMetricChip(stringRes(R.string.relay_monitor_rtt_open), it) + } + rttRead?.let { + RttMetricChip(stringRes(R.string.relay_monitor_rtt_read), it) + } + rttWrite?.let { + RttMetricChip(stringRes(R.string.relay_monitor_rtt_write), it) + } + } + } + + // Network type + if (networkTypes.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Language, + label = stringRes(R.string.relay_monitor_network), + value = networkTypes.joinToString { it.code }, + ) + } + + // Relay type + if (relayTypes.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Dns, + label = stringRes(R.string.relay_monitor_relay_type), + value = relayTypes.joinToString(), + ) + } + + // Requirements + if (requirements.isNotEmpty()) { + DiscoveryInfoRow( + icon = if (requirements.any { !it.negated }) Icons.Default.Lock else Icons.Default.LockOpen, + label = stringRes(R.string.relay_monitor_requirements), + value = + requirements.joinToString { req -> + if (req.negated) "!${req.value}" else req.value + }, + ) + } + + // Supported NIPs + if (supportedNips.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Numbers, + label = stringRes(R.string.relay_monitor_supported_nips), + value = supportedNips.sorted().joinToString(), + ) + } + + // Accepted kinds + if (acceptedKinds.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.Dns, + label = stringRes(R.string.relay_discovery_accepted_kinds), + value = + acceptedKinds.joinToString { kind -> + if (kind.negated) "!${kind.kind}" else "${kind.kind}" + }, + ) + } + + // Topics + if (topics.isNotEmpty()) { + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Tag, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + Spacer(Modifier.width(12.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + topics.forEach { topic -> + TopicChip(topic) + } + } + } + } + + // Geohashes + if (geohashes.isNotEmpty()) { + DiscoveryInfoRow( + icon = Icons.Default.TravelExplore, + label = stringRes(R.string.relay_discovery_geohash), + value = geohashes.joinToString(), + ) + } + + // Content description + if (noteEvent.content.isNotBlank()) { + Text( + text = noteEvent.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RttMetricChip( + label: String, + ms: Long, +) { + val color = + when { + ms < 200 -> Color(0xFF4CAF50) + ms < 500 -> Color(0xFFFFC107) + else -> MaterialTheme.colorScheme.error + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + shape = RoundedCornerShape(50), + color = color.copy(alpha = 0.15f), + ) { + Text( + text = stringRes(R.string.relay_monitor_ms, ms.toInt()), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = color, + ) + } + } +} + +@Composable +private fun TopicChip(topic: String) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = "#$topic", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +private fun DiscoveryInfoRow( + icon: ImageVector, + label: String, + value: String, +) { + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + Spacer(Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + Text( + text = value, + textAlign = TextAlign.End, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index 30f5f002b..ca15807f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -33,12 +33,12 @@ import androidx.compose.runtime.MutableState 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.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.RelayListCard import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelayIntoList import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.AddRelayButton @@ -63,6 +64,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch @Composable fun DisplayRelaySet( @@ -410,12 +412,15 @@ private fun RelayOptionsAction( nav: INav, ) { val isCurrentlyOnTheUsersList by observeUserRelayIntoList(relay, accountViewModel) - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() if (isCurrentlyOnTheUsersList) { AddRelayButton { - clipboardManager.setText(AnnotatedString(relay.url)) - nav.nav(Route.EditRelays) + scope.launch { + clipboardManager.setText(relay.url) + nav.nav(Route.EditRelays) + } } } else { RemoveRelayButton { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt new file mode 100644 index 000000000..86287e585 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.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.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material3.Icon +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.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent + +@Composable +fun RenderRelayMembershipList( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? RelayMembershipListEvent ?: return + val memberCount = remember(noteEvent) { noteEvent.members().size } + + RelayMemberEventCard( + icon = Icons.Default.People, + title = stringRes(R.string.relay_membership_list), + subtitle = stringRes(R.string.relay_members_count, memberCount), + nav = nav, + relayPubKey = noteEvent.pubKey, + ) +} + +@Composable +fun RenderRelayAddMember( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? RelayAddMemberEvent ?: return + val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() } + val title = + if (memberKeys.size == 1) { + stringRes(R.string.relay_member_added) + } else { + stringRes(R.string.relay_members_added, memberKeys.size) + } + val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } } + + RelayMemberEventCard( + icon = Icons.Default.PersonAdd, + title = title, + subtitle = subtitle, + relayPubKey = noteEvent.pubKey, + ) +} + +@Composable +fun RenderRelayRemoveMember( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? RelayRemoveMemberEvent ?: return + val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() } + val title = + if (memberKeys.size == 1) { + stringRes(R.string.relay_member_removed) + } else { + stringRes(R.string.relay_members_removed, memberKeys.size) + } + val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } } + + RelayMemberEventCard( + icon = Icons.Default.PersonRemove, + title = title, + subtitle = subtitle, + relayPubKey = noteEvent.pubKey, + ) +} + +@Composable +fun RenderRelayJoinRequest( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayMemberEventCard( + icon = Icons.Default.PersonAdd, + title = stringRes(R.string.relay_join_request), + subtitle = null, + nav = nav, + relayPubKey = null, + ) +} + +@Composable +fun RenderRelayLeaveRequest( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayMemberEventCard( + icon = Icons.AutoMirrored.Filled.ExitToApp, + title = stringRes(R.string.relay_leave_request), + subtitle = null, + nav = nav, + relayPubKey = null, + ) +} + +@Composable +private fun RelayMemberEventCard( + icon: ImageVector, + title: String, + subtitle: String?, + nav: INav? = null, + relayPubKey: String? = null, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column { + Text( + text = title, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + ) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Preview +@Composable +private fun RelayMembershipListCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = Icons.Default.People, + title = "Relay membership list", + subtitle = "42 members", + ) + } +} + +@Preview +@Composable +private fun RelayAddMemberCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = Icons.Default.PersonAdd, + title = "Member added to relay", + subtitle = "a1b2c3d4e5f6a7b8...", + ) + } +} + +@Preview +@Composable +private fun RelayRemoveMemberCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = Icons.Default.PersonRemove, + title = "Member removed from relay", + subtitle = "a1b2c3d4e5f6a7b8...", + ) + } +} + +@Preview +@Composable +private fun RelayJoinRequestCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = Icons.Default.PersonAdd, + title = "Relay join request", + subtitle = null, + ) + } +} + +@Preview +@Composable +private fun RelayLeaveRequestCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = Icons.AutoMirrored.Filled.ExitToApp, + title = "Relay leave request", + subtitle = null, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt new file mode 100644 index 000000000..c3f120226 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent + +@Composable +fun RenderRootSiteEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? RootSiteEvent ?: return + + RenderStaticWebsite( + title = event.title(), + description = event.description(), + source = event.source(), + servers = event.servers(), + identifier = null, + ) +} + +@Composable +fun RenderNamedSiteEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? NamedSiteEvent ?: return + + RenderStaticWebsite( + title = event.title(), + description = event.description(), + source = event.source(), + servers = event.servers(), + identifier = event.identifier(), + ) +} + +@Composable +private fun RenderStaticWebsite( + title: String?, + description: String?, + source: String?, + servers: List, + identifier: String?, +) { + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ).padding(Size10dp), + ) { + Column { + val displayTitle = + title + ?: identifier + ?: stringRes(id = R.string.nsite_root_site) + + Text( + text = stringRes(id = R.string.nsite_title, displayTitle), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + description?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + source?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_source), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + + if (servers.isNotEmpty()) { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_servers), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + Column { + servers.forEach { server -> + ClickableUrl( + url = server, + urlText = server.removePrefix("https://").removePrefix("http://"), + ) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt new file mode 100644 index 000000000..59dc332ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.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.ui.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent + +@Composable +fun RenderThread( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + unPackReply: ReplyRenderType, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? ThreadEvent ?: return + val title = noteEvent.title() + + if (title != null) { + Column(MaterialTheme.colorScheme.replyModifier) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp), + ) + } + Spacer(modifier = StdVertSpacer) + } + + RenderTextEvent( + note, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index 7c5442ff0..a0fd3a87b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.clickable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index ecae95ec2..fa098494a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -48,7 +48,7 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) { val accountState by accountSessionManager.accountContent.collectAsStateWithLifecycle() - Log.d("ActivityLifecycle", "AccountScreen $accountState $accountSessionManager") + Log.d("ActivityLifecycle") { "AccountScreen $accountState $accountSessionManager" } Crossfade( targetState = accountState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 9e9fcf4b3..220191607 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -93,8 +93,8 @@ sealed class AccountState { @Stable class AccountSessionManager( val accountsCache: AccountCacheState, - val nip05Client: Nip05Client, - val client: INostrClient, + val nip05ClientBuilder: () -> Nip05Client, + val clientBuilder: () -> INostrClient, val localPreferences: LocalPreferences, val scope: CoroutineScope, ) { @@ -227,7 +227,7 @@ class AccountSessionManager( onError("Could not parse nip05 address: $nip05") } else { try { - val pubkeyInfo = nip05Client.get(nip05) + val pubkeyInfo = nip05ClientBuilder().get(nip05) if (pubkeyInfo == null) { onError("User not found in the nip05 server: $nip05") } else { @@ -287,13 +287,15 @@ class AccountSessionManager( val toPost = accountSettings.backupNIP65RelayList?.writeRelaysNorm()?.toSet() ?: DefaultNIP65RelaySet - accountSettings.backupUserMetadata?.let { client.send(it, toPost) } - accountSettings.backupContactList?.let { client.send(it, toPost) } - accountSettings.backupNIP65RelayList?.let { client.send(it, toPost) } - accountSettings.backupDMRelayList?.let { client.send(it, toPost) } - accountSettings.backupSearchRelayList?.let { client.send(it, toPost) } - accountSettings.backupIndexRelayList?.let { client.send(it, toPost) } - accountSettings.backupRelayFeedsList?.let { client.send(it, toPost) } + val client = clientBuilder() + + accountSettings.backupUserMetadata?.let { client.publish(it, toPost) } + accountSettings.backupContactList?.let { client.publish(it, toPost) } + accountSettings.backupNIP65RelayList?.let { client.publish(it, toPost) } + accountSettings.backupDMRelayList?.let { client.publish(it, toPost) } + accountSettings.backupSearchRelayList?.let { client.publish(it, toPost) } + accountSettings.backupIndexRelayList?.let { client.publish(it, toPost) } + accountSettings.backupRelayFeedsList?.let { client.publish(it, toPost) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 13f27017a..8a81b1a7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -238,7 +238,7 @@ class TopNavFilterState( .stateIn(scope, SharingStarted.Eagerly, defaultLists) fun destroy() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index f918cd9e9..d43caec0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -99,24 +99,24 @@ open class UserFeedViewModel( } init { - Log.d("Init", "${this.javaClass.simpleName}") + Log.d("Init") { "${this.javaClass.simpleName}" } viewModelScope.launch(Dispatchers.IO) { LocalCache.live.newEventBundles.collect { newNotes -> - Log.d("Rendering Metrics", "Update feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + Log.d("Rendering Metrics") { "Update feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}" } invalidateData() } } viewModelScope.launch(Dispatchers.IO) { LocalCache.live.deletedEventBundles.collect { newNotes -> - Log.d("Rendering Metrics", "Delete from feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + Log.d("Rendering Metrics") { "Delete from feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}" } invalidateData() } } } override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } bundler.cancel() super.onCleared() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index c2a2b12d7..a3c03ee08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -44,7 +44,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedConte import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter import kotlinx.coroutines.CoroutineScope class AccountFeedContentStates( @@ -68,6 +70,8 @@ class AccountFeedContentStates( val discoverCommunities = FeedContentState(DiscoverCommunityFeedFilter(account), scope, LocalCache) val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache) + val pollsFeed = FeedContentState(PollsFeedFilter(account), scope, LocalCache) + val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notificationsOpenPolls = OpenPollsState(account, scope) val notificationSummary = NotificationSummaryState(account) @@ -76,6 +80,8 @@ class AccountFeedContentStates( val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache) + val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache) + suspend fun init() { notificationSummary.initializeSuspend() } @@ -100,10 +106,14 @@ class AccountFeedContentStates( discoverCommunities.updateFeedWith(newNotes) discoverPublicChats.updateFeedWith(newNotes) + pollsFeed.updateFeedWith(newNotes) + notifications.updateFeedWith(newNotes) notificationSummary.invalidateInsertData(newNotes) drafts.updateFeedWith(newNotes) + + webBookmarks.updateFeedWith(newNotes) } fun deleteNotes(newNotes: Set) { @@ -126,10 +136,14 @@ class AccountFeedContentStates( discoverCommunities.deleteFromFeed(newNotes) discoverPublicChats.deleteFromFeed(newNotes) + pollsFeed.deleteFromFeed(newNotes) + notifications.deleteFromFeed(newNotes) notificationSummary.invalidateInsertData(newNotes) drafts.deleteFromFeed(newNotes) + + webBookmarks.deleteFromFeed(newNotes) } fun destroy() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 8dc7d01f9..246beb2fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -37,9 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalConfiguration +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.currentBackStackEntryAsState import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent -import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import kotlinx.coroutines.launch @@ -48,8 +51,7 @@ import kotlinx.coroutines.launch fun AccountSwitcherAndLeftDrawerLayout( accountViewModel: AccountViewModel, accountSessionManager: AccountSessionManager, - nav: INav, - gesturesEnabled: Boolean = true, + nav: Nav, content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() @@ -82,9 +84,19 @@ fun AccountSwitcherAndLeftDrawerLayout( } } + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val isTabPagerRoute = + navBackStackEntry?.destination?.let { dest -> + dest.hasRoute() || dest.hasRoute() + } ?: false + val drawerGesturesEnabled = + !isTabPagerRoute || + nav.drawerState.isOpen || + nav.drawerState.targetValue != nav.drawerState.currentValue + ModalNavigationDrawer( drawerState = nav.drawerState, - gesturesEnabled = gesturesEnabled, + gesturesEnabled = drawerGesturesEnabled, drawerContent = { DrawerContent(nav, openSheetFunction, accountViewModel) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0e83f65e5..bca58da33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper import android.util.LruCache +import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable @@ -112,6 +115,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -128,6 +132,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -136,7 +141,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -172,7 +177,7 @@ class AccountViewModel( val torSettings: TorSettingsFlow, val dataSources: RelaySubscriptionsCoordinator, val httpClientBuilder: IRoleBasedHttpClientBuilder, - val nip05Client: INip05Client, + val nip05ClientBuilder: () -> INip05Client, ) : ViewModel(), Dao { var firstRoute: Route? = null @@ -376,7 +381,7 @@ class AccountViewModel( if (currentReactions.isNotEmpty()) { account.delete(currentReactions) } else { - if (settings.isCompleteUIMode()) { + if (settings.isCompleteUIMode() && note.event !is NIP17Group) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> broadcastTracker.trackBroadcast( @@ -845,6 +850,42 @@ class AccountViewModel( fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + fun pinnedNotes(user: User): Note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex)) + + fun addPin(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createAddPinEvent(note)?.let { (event, relays) -> + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumePinEvent(event) + } + } + } else { + launchSigner { account.addPin(note) } + } + } + + fun removePin(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createRemovePinEvent(note)?.let { (event, relays) -> + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumePinEvent(event) + } + } + } else { + launchSigner { account.removePin(note) } + } + } + fun addPrivateBookmark(note: Note) { if (settings.isCompleteUIMode()) { launchSigner { @@ -922,6 +963,17 @@ class AccountViewModel( fun delete(note: Note) = launchSigner { account.delete(note) } + fun requestToVanish( + relays: List, + reason: String, + createdAt: Long, + ) = launchSigner { account.requestToVanish(relays, reason, createdAt) } + + fun requestToVanishFromEverywhere( + reason: String, + createdAt: Long, + ) = launchSigner { account.requestToVanishFromEverywhere(reason, createdAt) } + fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note) fun decrypt( @@ -962,6 +1014,12 @@ class AccountViewModel( Log.w("AccountViewModel", "AutomaticallyUnauthorizedException", e) } catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) { Log.w("AccountViewModel", "TimedOutRunningOnBackgroundWithoutAutomaticPermissionExceptionException", e) + } catch (e: IllegalStateException) { + toastManager.toast( + R.string.signer_not_found_exception, + R.string.signer_illegal_state_exception_description, + e, + ) } } } @@ -1283,7 +1341,7 @@ class AccountViewModel( val torSettings: TorSettingsFlow, val dataSources: RelaySubscriptionsCoordinator, val okHttpClient: RoleBasedHttpClientBuilder, - val nip05Client: Nip05Client, + val nip05ClientBuilder: () -> Nip05Client, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = @@ -1293,12 +1351,12 @@ class AccountViewModel( torSettings, dataSources, okHttpClient, - nip05Client, + nip05ClientBuilder, ) as T } init { - Log.d("Init", "AccountViewModel") + Log.d("AccountViewModel", "Init") viewModelScope.launch(Dispatchers.IO) { feedStates.init() // awaits for init to finish before starting to capture new events. @@ -1319,7 +1377,7 @@ class AccountViewModel( } override fun onCleared() { - Log.d("Init", "AccountViewModel onCleared") + Log.d("AccountViewModel", "onCleared") feedStates.destroy() super.onCleared() } @@ -1654,7 +1712,11 @@ class AccountViewModel( mimeType = mimeType, localContext = localContext, onSuccess = { - toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery) + Handler(Looper.getMainLooper()).post { + Toast + .makeText(localContext.applicationContext, R.string.video_saved_to_the_gallery, Toast.LENGTH_SHORT) + .show() + } }, onError = { toastManager.toast(R.string.failed_to_save_the_video, null, it) @@ -1801,9 +1863,9 @@ fun mockAccountViewModel(): AccountViewModel { Account( settings = AccountSettings(keyPair), signer = NostrSignerInternal(keyPair), - geolocationFlow = MutableStateFlow(LocationState.LocationResult.Loading), - nwcFilterAssembler = nwcFilters, - otsResolverBuilder = EmptyOtsResolverBuilder, + geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, + nwcFilterAssembler = { nwcFilters }, + otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = client, scope = scope, @@ -1815,7 +1877,7 @@ fun mockAccountViewModel(): AccountViewModel { torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), - nip05Client = EmptyNip05Client(), + nip05ClientBuilder = { EmptyNip05Client() }, ).also { mockedCache = it } @@ -1852,9 +1914,9 @@ fun mockVitorAccountViewModel(): AccountViewModel { Account( settings = AccountSettings(keyPair), signer = NostrSignerInternal(keyPair), - geolocationFlow = MutableStateFlow(LocationState.LocationResult.Loading), - nwcFilterAssembler = nwcFilters, - otsResolverBuilder = EmptyOtsResolverBuilder, + geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, + nwcFilterAssembler = { nwcFilters }, + otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = EmptyNostrClient(), scope = scope, @@ -1866,7 +1928,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), - nip05Client = EmptyNip05Client(), + nip05ClientBuilder = { EmptyNip05Client() }, ).also { vitorCache = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 2b3eeeaf9..a569b5ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -134,7 +134,7 @@ class EventProcessor( } if (deletedDrafts.isNotEmpty()) { - Log.w("EventProcessor", "Deleting ${deletedDrafts.size} draft notes") + Log.w("EventProcessor") { "Deleting ${deletedDrafts.size} draft notes" } account.delete(deletedDrafts) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index d1f44cea2..2fef9419e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -72,7 +72,7 @@ fun LoggedInPage( torSettings = Amethyst.instance.torPrefs.value, dataSources = Amethyst.instance.sources, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder, - nip05Client = Amethyst.instance.nip05Client, + nip05ClientBuilder = { Amethyst.instance.nip05Client }, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index 4ccee13ed..11d584796 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -27,8 +27,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,6 +36,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -46,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.PinnedNotesFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -67,15 +69,28 @@ fun BookmarkListScreen( factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) + val pinnedNotesFeedViewModel: PinnedNotesFeedViewModel = + viewModel( + key = "NostrPinnedNotesFeedViewModel", + factory = PinnedNotesFeedViewModel.Factory(accountViewModel.account), + ) + val bookmarkState by accountViewModel.account.bookmarkState.bookmarks .collectAsStateWithLifecycle(null) + val pinState by accountViewModel.account.pinState.pinnedNotesList + .collectAsStateWithLifecycle(null) + LaunchedEffect(bookmarkState) { publicFeedViewModel.invalidateData() privateFeedViewModel.invalidateData() } - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + LaunchedEffect(pinState) { + pinnedNotesFeedViewModel.invalidateData() + } + + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, pinnedNotesFeedViewModel, accountViewModel, nav) } @Composable @@ -83,10 +98,11 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + pinnedNotesFeedViewModel: PinnedNotesFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 2 } + val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() DisappearingScaffold( @@ -94,10 +110,11 @@ private fun RenderBookmarkScreen( topBar = { Column { TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack) - TabRow( + SecondaryScrollableTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, + edgePadding = 8.dp, modifier = TabRowHeight, ) { Tab( @@ -110,6 +127,11 @@ private fun RenderBookmarkScreen( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, text = { Text(text = stringRes(R.string.public_bookmarks)) }, ) + Tab( + selected = pagerState.currentPage == 2, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + text = { Text(text = stringRes(R.string.pinned_notes)) }, + ) } } }, @@ -135,6 +157,15 @@ private fun RenderBookmarkScreen( nav = nav, ) } + + 2 -> { + RefresheableFeedView( + pinnedNotesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt new file mode 100644 index 000000000..b20c5492f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.bookmarkgroups.default.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class PinnedNotesFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.pinState.pinnedNotesList.value + .hashCode() + .toString() + + override fun feed(): List = account.pinState.pinnedNotesList.value +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.kt new file mode 100644 index 000000000..ec3a43466 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.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.ui.screen.loggedIn.bookmarkgroups.default.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class PinnedNotesFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(PinnedNotesFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = PinnedNotesFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 73febbbf7..d71f5cb30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -41,10 +41,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -54,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo @@ -125,6 +124,7 @@ fun BookmarkGroupItemOptionsMenu( isFollowingAuthor = false, isPrivateBookmarkNote = false, isPublicBookmarkNote = false, + isPinnedNote = false, isLoggedUser = false, isSensitive = false, showSensitiveContent = null, @@ -159,7 +159,7 @@ fun BookmarkGroupItemOptionsMenu( title = stringRes(R.string.bookmark_item_actions_dialog_title), onDismiss = onDismiss, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current val actContext = LocalContext.current val scope = rememberCoroutineScope() @@ -202,20 +202,24 @@ fun BookmarkGroupItemOptionsMenu( M3ActionSection { M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } + accountViewModel.decrypt(lastNoteVersion) { + scope.launch { + clipboardManager.setText(it) + } + } onDismiss() } M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) + clipboardManager.setText("nostr:${it.pubkeyNpub()}") onDismiss() } } } M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) { scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString(note.toNostrUri())) + clipboardManager.setText(note.toNostrUri()) onDismiss() } } @@ -236,7 +240,7 @@ fun BookmarkGroupItemOptionsMenu( val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) + actContext.startActivity(shareIntent) onDismiss() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 943a262ab..18795d5e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -39,8 +39,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -279,7 +279,7 @@ fun BookmarkGroupHeaderTabs( } } - TabRow( + SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index c229dd718..d58040c70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -155,6 +155,7 @@ class BookmarkGroupViewModel( ) } + @Suppress("UNCHECKED_CAST") class Initializer( val account: Account, val bookmarkGroupIdentifier: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index ac2453dec..c798e382c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -37,12 +37,12 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -74,6 +75,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm +import kotlinx.coroutines.launch @Composable fun RenderCreateChannelNote( @@ -238,12 +240,15 @@ fun RenderRelayLinePublicChat( @Suppress("ProduceStateDoesNotAssignValue") val relayInfo by loadRelayInfo(relay) - val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val clipboardManager = LocalClipboard.current val clickableModifier = remember(relay) { Modifier.combinedClickable( onLongClick = { - clipboardManager.setText(AnnotatedString(relay.url)) + scope.launch { + clipboardManager.setText(relay.url) + } }, onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 358247a94..ebf4638a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 19e917bd7..15d28f635 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -107,7 +107,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart @@ -260,7 +259,7 @@ class ChatNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -801,7 +800,7 @@ class ChatNewMessageViewModel : override fun onCleared() { super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } // NIP-04 sending is deprecated. NIP-17 is always used. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 9ef4170ff..ea48b7c13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 489a744f8..94309697b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -186,7 +186,7 @@ open class ChannelNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -663,7 +663,7 @@ open class ChannelNewMessageViewModel : override fun onCleared() { super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } fun updateZapPercentage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 8b5961be5..d168e99ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -34,8 +34,8 @@ import androidx.compose.material.icons.outlined.MoveToInbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -80,7 +80,7 @@ fun MessagesTabHeader( val coroutineScope = rememberCoroutineScope() Box(Modifier.fillMaxWidth()) { - TabRow( + SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index d1d7e98b8..42dc6d1ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -207,7 +207,7 @@ private fun ImageVideoPostChat( label = "", placeholder = fileServers - .firstOrNull { it == accountViewModel.account.settings.defaultFileServer } + .firstOrNull { it.baseUrl == accountViewModel.account.settings.defaultFileServer.baseUrl } ?.name ?: fileServers.firstOrNull()?.name ?: DEFAULT_MEDIA_SERVERS[0].name, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt index f8a51f670..3edfcadb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt @@ -30,9 +30,9 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf @@ -167,7 +167,7 @@ fun CommunityScreen( } } - TabRow( + SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, modifier = TabRowHeight, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 3a59adf08..4620d750a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -219,7 +219,7 @@ private fun DiscoverPages( topBar = { Column { DiscoveryTopBar(accountViewModel, nav) - ScrollableTabRow( + SecondaryScrollableTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 664331698..30f60121a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -40,10 +40,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -268,7 +268,7 @@ private fun MarkdownPostScreenBody( HorizontalDivider(modifier = Modifier.padding(vertical = Size5dp)) // Edit / Preview tabs - TabRow( + SecondaryTabRow( selectedTabIndex = if (postViewModel.showPreview) 1 else 0, ) { Tab( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 49f23cee1..6388db9b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm -import android.R.attr.version import android.content.Context import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -211,7 +210,7 @@ class LongFormPostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -687,7 +686,7 @@ class LongFormPostViewModel : override fun onCleared() { super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } override fun updateZapPercentage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 5f39bccc9..ef9287527 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column @@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog @@ -92,7 +92,7 @@ import kotlinx.coroutines.withContext @Composable fun NewProductScreen( message: String? = null, - attachment: Uri? = null, + attachment: String? = null, quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, @@ -114,7 +114,7 @@ fun NewProductScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 2908f5a79..d0b7fd87c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -200,7 +200,7 @@ open class NewProductViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -608,7 +608,7 @@ open class NewProductViewModel : override fun onCleared() { super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } override fun updateZapPercentage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index b7268d473..0d81a4cc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -79,8 +79,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent @Composable fun DvmContentDiscoveryScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt index 8a1ac91e3..ec880e6a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent open class NIP90ContentDiscoveryResponseFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt index a83c8860a..25bd378ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt @@ -39,8 +39,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -274,7 +274,7 @@ private fun FollowPackFeedTopBar( ) } - TabRow( + SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, modifier = TabRowHeight, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt index 5aa7ccc44..513a09c79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -41,6 +40,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent class FollowPackFeedConversationsFeedFilter( val followPackNote: AddressableNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index 8eb16a396..7eb071ecc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @@ -41,7 +41,7 @@ import kotlinx.coroutines.withContext fun GeoHashPostScreen( geohash: String? = null, message: String? = null, - attachment: Uri? = null, + attachment: String? = null, replyId: HexKey? = null, quoteId: HexKey? = null, draftId: HexKey? = null, @@ -69,7 +69,7 @@ fun GeoHashPostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index c547347d1..46aad80cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @@ -41,7 +41,7 @@ import kotlinx.coroutines.withContext fun HashtagPostScreen( hashtag: String? = null, message: String? = null, - attachment: Uri? = null, + attachment: String? = null, replyId: HexKey? = null, quoteId: HexKey? = null, draftId: HexKey? = null, @@ -69,7 +69,7 @@ fun HashtagPostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 7392c05b3..9d0bcdb4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -37,8 +37,8 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -178,7 +178,7 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) - TabRow( + SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, modifier = TabRowHeight, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 3f0e0d79d..493f000ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.annotation.SuppressLint import android.content.Intent import android.net.Uri -import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -58,6 +57,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.IntentCompat +import androidx.core.net.toUri import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -126,7 +127,7 @@ import kotlinx.coroutines.withContext @Composable fun ShortNotePostScreen( message: String? = null, - attachment: Uri? = null, + attachment: String? = null, baseReplyToId: HexKey? = null, quoteId: HexKey? = null, forkId: HexKey? = null, @@ -151,7 +152,7 @@ fun ShortNotePostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) @@ -168,7 +169,7 @@ fun ShortNotePostScreen( postViewModel.addToMessage(it) } - (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { + IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 9b41af0b5..cd6c1729c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -301,7 +301,7 @@ open class ShortNotePostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -602,7 +602,7 @@ open class ShortNotePostViewModel : pollOptions[index] = tag } - pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE + pollType = draftEvent.pollType() closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() message = TextFieldValue(draftEvent.content) @@ -697,6 +697,8 @@ open class ShortNotePostViewModel : // Abort if upload failed - don't post without voice data if (voiceMetadata == null) { Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post") + deleteVoiceLocalFile() + voiceAnonymization.deleteDistortedFiles() return } // Update default server if voice message was successfully uploaded @@ -854,22 +856,16 @@ open class ShortNotePostViewModel : } } else if (wantsZapPoll) { val options = zapPollOptions.map { PollOptionTag(it.key, it.value) } - if (options.isEmpty()) return null - val quotes = findNostrUris(tagger.message) - val relays = - accountViewModel.account.nip65RelayList.outboxFlow.value - .toList() - ZapPollEvent.build(tagger.message, options) { + closedAt(zapPollClosedAt) zapPollValueMinimum?.let { minAmount(it) } zapPollValueMaximum?.let { maxAmount(it) } - zapPollClosedAt?.let { closedAt(it) } zapPollConsensusThreshold?.let { consensusThreshold(it / 100.0) } pTags(tagger.directMentionsUsers.map { it.toPTag() }) - quotes(quotes) + quotes(findNostrUris(tagger.message)) hashtags(findHashtags(tagger.message)) geoHash?.let { geohash(it) } @@ -1274,9 +1270,8 @@ open class ShortNotePostViewModel : private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.exists()) { - file.delete() - Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}") + if (file.delete()) { + Log.d("ShortNotePostViewModel") { "Deleted voice file: ${file.absolutePath}" } } } catch (e: Exception) { Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) @@ -1370,7 +1365,7 @@ open class ShortNotePostViewModel : override fun onCleared() { super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } override fun updateZapPercentage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index d44630a46..39c0e3919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -157,7 +157,7 @@ class VoiceReplyViewModel : ViewModel() { try { if (file.exists()) { file.delete() - Log.d("VoiceReplyViewModel", "Deleted voice file: ${file.absolutePath}") + Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } } } catch (e: Exception) { Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) @@ -315,6 +315,6 @@ class VoiceReplyViewModel : ViewModel() { override fun onCleared() { cancel() super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index 82a368184..6957b70e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -38,6 +37,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent class HomeConversationsFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt index fdf604366..923835b1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live -import android.util.Log import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -34,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -106,7 +106,7 @@ private suspend fun checkChannelIsOnline( } } } catch (e: Exception) { - Log.d("LiveStatusIndicator", "Network error checking channel ${channel.toBestDisplayName()}: ${e.message}") + Log.d("LiveStatusIndicator") { "Network error checking channel ${channel.toBestDisplayName()}: ${e.message}" } // Return false if any network error occurs false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt new file mode 100644 index 000000000..5a6348cb6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.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.ui.screen.loggedIn.home.nip75Goals + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewGoalScreen( + accountViewModel: AccountViewModel, + nav: Nav, +) { + val goalViewModel: NewGoalViewModel = viewModel() + goalViewModel.init(accountViewModel) + + LaunchedEffect(goalViewModel, accountViewModel) { + // no-op for now, could load drafts + } + + NewGoalScreen( + goalViewModel, + accountViewModel, + nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewGoalScreen( + goalViewModel: NewGoalViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + BackHandler { + goalViewModel.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.new_goal, + isActive = goalViewModel::canPost, + onCancel = { + goalViewModel.cancel() + nav.popBack() + }, + onPost = { + accountViewModel.launchSigner { + goalViewModel.sendPostSync() + nav.popBack() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + NewGoalBody(goalViewModel) + } + } +} + +@Composable +private fun NewGoalBody(goalViewModel: NewGoalViewModel) { + val scrollState = rememberScrollState() + + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + ) { + OutlinedTextField( + value = goalViewModel.description, + onValueChange = { goalViewModel.description = it }, + label = { Text(stringRes(R.string.goal_description_label)) }, + placeholder = { Text(stringRes(R.string.goal_description_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 6, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.amount, + onValueChange = { goalViewModel.amount = it }, + label = { Text(stringRes(R.string.goal_amount_label)) }, + placeholder = { Text(stringRes(R.string.goal_amount_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.summary, + onValueChange = { goalViewModel.summary = it }, + label = { Text(stringRes(R.string.goal_summary_label)) }, + placeholder = { Text(stringRes(R.string.goal_summary_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.imageUrl, + onValueChange = { goalViewModel.imageUrl = it }, + label = { Text(stringRes(R.string.goal_image_label)) }, + placeholder = { Text(stringRes(R.string.goal_image_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.websiteUrl, + onValueChange = { goalViewModel.websiteUrl = it }, + label = { Text(stringRes(R.string.goal_website_label)) }, + placeholder = { Text(stringRes(R.string.goal_website_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row(verticalAlignment = CenterVertically) { + Checkbox( + checked = goalViewModel.wantsDeadline, + onCheckedChange = { goalViewModel.wantsDeadline = it }, + ) + Text( + text = stringRes(R.string.goal_set_deadline), + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt new file mode 100644 index 000000000..d256b9f61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.home.nip75Goals + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Stable +class NewGoalViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var description by mutableStateOf(TextFieldValue("")) + var amount by mutableStateOf(TextFieldValue("")) + var summary by mutableStateOf(TextFieldValue("")) + var imageUrl by mutableStateOf(TextFieldValue("")) + var websiteUrl by mutableStateOf(TextFieldValue("")) + + var wantsDeadline by mutableStateOf(false) + var deadlineTimestamp by mutableLongStateOf(TimeUtils.now() + TimeUtils.ONE_WEEK) + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + } + + fun canPost(): Boolean = + description.text.isNotBlank() && + amount.text.isNotBlank() && + amount.text.toLongOrNull() != null && + (amount.text.toLongOrNull() ?: 0) > 0 + + fun cancel() { + description = TextFieldValue("") + amount = TextFieldValue("") + summary = TextFieldValue("") + imageUrl = TextFieldValue("") + websiteUrl = TextFieldValue("") + wantsDeadline = false + deadlineTimestamp = TimeUtils.now() + TimeUtils.ONE_WEEK + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + cancel() + account.signAndComputeBroadcast(template) + } + + private fun createTemplate(): EventTemplate? { + val amountSats = amount.text.toLongOrNull() ?: return null + val amountMillisats = amountSats * 1000L + + val relays = + account.outboxRelays.flow.value + .toList() + + val closedAt = if (wantsDeadline) deadlineTimestamp else null + val img = imageUrl.text.ifBlank { null } + val sum = summary.text.ifBlank { null } + val web = websiteUrl.text.ifBlank { null } + + return GoalEvent.build( + description = description.text, + amount = amountMillisats, + relays = relays, + closedAt = closedAt, + image = img, + summary = sum, + websiteUrl = web, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index 94a178514..68bd1e6f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -61,19 +61,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -92,6 +86,7 @@ import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.resolveDefaults import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.authenticate @@ -112,7 +107,6 @@ import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -@OptIn(ExperimentalComposeUiApi::class) @Composable fun AccountBackupScreen( accountViewModel: AccountViewModel, @@ -131,7 +125,7 @@ fun AccountBackupScreenPreview() { } } -@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AccountBackupScreenContent( accountViewModel: AccountViewModel, @@ -199,30 +193,12 @@ private fun AccountBackupScreenContent( var errorMessage by remember { mutableStateOf("") } var showCharsPassword by remember { mutableStateOf(false) } - val autofillNode = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { password.value = TextFieldValue(it) }, - ) - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNode - Spacer(modifier = Modifier.height(20.dp)) OutlinedTextField( modifier = Modifier - .onGloballyPositioned { coordinates -> - autofillNode.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNode) - } else { - cancelAutofillForNode(autofillNode) - } - } - }, + .semantics { contentType = ContentType.Password }, value = password.value, onValueChange = { password.value = it @@ -281,7 +257,7 @@ private fun AccountBackupScreenContent( @Composable private fun NSecCopyButton(accountViewModel: AccountViewModel) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current val context = LocalContext.current val scope = rememberCoroutineScope() @@ -329,7 +305,7 @@ private fun EncryptNSecCopyButton( accountViewModel: AccountViewModel, password: MutableState, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current val context = LocalContext.current val scope = rememberCoroutineScope() @@ -390,11 +366,11 @@ private fun copyNSec( context: Context, scope: CoroutineScope, account: Account, - clipboardManager: ClipboardManager, + clipboardManager: Clipboard, ) { account.settings.keyPair.privKey?.let { - clipboardManager.setText(AnnotatedString(it.toNsec())) scope.launch { + clipboardManager.setText(it.toNsec()) Toast .makeText( context, @@ -410,7 +386,7 @@ private fun encryptCopyNSec( context: Context, scope: CoroutineScope, accountViewModel: AccountViewModel, - clipboardManager: ClipboardManager, + clipboardManager: Clipboard, ) { if (password.value.text.isBlank()) { scope.launch { @@ -425,8 +401,8 @@ private fun encryptCopyNSec( accountViewModel.account.settings.keyPair.privKey?.let { val key = runCatching { Nip49().encrypt(it.toHexKey(), password.value.text) }.getOrNull() if (key != null) { - clipboardManager.setText(AnnotatedString(key)) scope.launch { + clipboardManager.setText(key) Toast .makeText( context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index 95b23a2ae..0bf5c2c6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -52,8 +52,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -70,7 +70,6 @@ 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 androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -164,7 +163,7 @@ private fun TopAppTabs( viewModel: PeopleListViewModel, pagerState: PagerState, ) { - TabRow( + SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, @@ -448,7 +447,7 @@ private fun ListActionsMenuButton( val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) - ContextCompat.startActivity(context, shareIntent, null) + context.startActivity(shareIntent) isActionListOpen.value = false } M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index 03db90149..d68148af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -72,7 +72,7 @@ class PeopleListViewModel : ViewModel() { ) { if (!this::account.isInitialized || this.account != accountVM.account) { this.account = accountVM.account - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) } this.selectedDTag.tryEmit(selectedDTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt index c10fbc237..c96b50fb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -63,7 +63,6 @@ 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 androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -303,7 +302,7 @@ private fun ListActionsMenuButton( val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) - ContextCompat.startActivity(context, shareIntent, null) + context.startActivity(shareIntent) isActionListOpen.value = false } M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_pack_edit_list_metadata)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt index eab070de4..9324e8cdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -72,7 +72,7 @@ class FollowPackViewModel : ViewModel() { ) { if (!this::account.isInitialized || this.account != accountVM.account) { this.account = accountVM.account - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) } this.selectedDTag.tryEmit(selectedDTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt index 48d130c0d..bebc54b07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt @@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine -import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt index 9b0c9286c..03b156ecf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt @@ -116,7 +116,7 @@ fun ImportFollowListSelectUserScreen( ) { val viewModel: ImportFollowListSelectUserViewModel = viewModel( - factory = ImportFollowListSelectUserViewModel.Factory(accountViewModel.account, accountViewModel.nip05Client), + factory = ImportFollowListSelectUserViewModel.Factory(accountViewModel.account, accountViewModel.nip05ClientBuilder()), ) Scaffold( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index a0cd1da9d..ad7de41b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -463,7 +463,7 @@ class CardFeedContentState( } fun destroy() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } clear() bundlerInsert.cancel() bundler.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index f112a4173..09cdd19ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -271,6 +271,6 @@ class NotificationSummaryState( fun destroy() { bundlerInsert.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt index 08c9faf78..83b626a13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache.notes import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index ce26d0bf7..97ca6526e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -72,6 +71,7 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent class NotificationFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 35e94f93f..9c58dd396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -66,8 +66,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -107,6 +105,8 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -209,7 +209,7 @@ class NewPublicMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -657,7 +657,7 @@ class NewPublicMessageViewModel : override fun onCleared() { super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } override fun updateZapPercentage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt new file mode 100644 index 000000000..8d24c6817 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription + +@Composable +fun PollsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PollsScreen( + pollsFeedContentState = accountViewModel.feedStates.pollsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PollsScreen( + pollsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(pollsFeedContentState) + WatchAccountForPollsScreen(pollsFeedContentState = pollsFeedContentState, accountViewModel = accountViewModel) + PollsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PollsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.Polls, accountViewModel) { route -> + if (route == Route.Polls) { + pollsFeedContentState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + accountViewModel = accountViewModel, + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + RefresheableBox(pollsFeedContentState, true) { + SaveableFeedContentState(pollsFeedContentState, scrollStateKey = ScrollStateKeys.POLLS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = pollsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PollsFeed", + ) + } + } + } + } +} + +@Composable +fun WatchAccountForPollsScreen( + pollsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveDiscoveryFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + pollsFeedContentState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt new file mode 100644 index 000000000..683fded42 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.polls + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun PollsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultDiscoveryFollowList + .collectAsStateWithLifecycle() + + PollsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultDiscoveryFollowList, + ) + } +} + +@Composable +private fun PollsTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/PollsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/PollsFeedFilter.kt new file mode 100644 index 000000000..b082fc2a8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/PollsFeedFilter.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.polls.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent + +class PollsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + (noteEvent is PollEvent || noteEvent is ZapPollEvent) && params.match(noteEvent, it.relays) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveDiscoveryFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is PollEvent && params.match(noteEvent, it.relays) + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssembler.kt new file mode 100644 index 000000000..a518072e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssembler.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.ui.screen.loggedIn.polls.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PollsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PollsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + PollsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssemblerSubscription.kt new file mode 100644 index 000000000..52e489c57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssemblerSubscription.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.amethyst.ui.screen.loggedIn.polls.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PollsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PollsFilterAssemblerSubscription( + accountViewModel.dataSources().polls, + accountViewModel, + ) +} + +@Composable +fun PollsFilterAssemblerSubscription( + dataSource: PollsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PollsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsSubAssembler.kt new file mode 100644 index 000000000..0fe63b78a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsSubAssembler.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.polls.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PollsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PollsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + + return makePollsFilter(feedSettings, since, key.feedStates.pollsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PollsQueryState) = key.account.userProfile() + + override fun list(key: PollsQueryState) = key.listName() + + fun PollsQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun PollsQueryState.listName() = listNameFlow().value + + fun PollsQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun PollsQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PollsQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.pollsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt new file mode 100644 index 000000000..ea6981b57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.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.ui.screen.loggedIn.polls.datasource + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePollsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllFollowsTopNavPerRelayFilterSet -> filterPollsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPollsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPollsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPollsByHashtag(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPollsByMutedAuthors(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt new file mode 100644 index 000000000..45f813665 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent + +fun filterPollsByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPollsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPollsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPollsByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPollsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByFollows.kt new file mode 100644 index 000000000..f610f2e46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByFollows.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPollsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterPollsByAuthors(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt new file mode 100644 index 000000000..7460ce7e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.polls.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent + +fun filterPollsByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long? = null, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), + tags = mapOf("t" to hashtags.toList()), + limit = 200, + since = since, + ), + ), + ) + +fun filterPollsByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPollsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt new file mode 100644 index 000000000..ad0e8a58b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.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.ui.screen.loggedIn.polls.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterPollsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo() + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), + limit = 200, + since = since, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index 5a21fcb12..df8be0587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar @@ -40,6 +41,12 @@ import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacyOptionsScreen(nav: INav) { + PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 0fd732876..4e2fcfb8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualCon import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader @@ -223,6 +224,16 @@ fun PrepareViewModels( ), ) + val pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfilePinnedNotesFeedViewModel", + factory = + UserProfilePinnedNotesFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) + val reportsFeedViewModel: UserProfileReportFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", @@ -244,6 +255,7 @@ fun PrepareViewModels( externalIdentities, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel = accountViewModel, @@ -263,6 +275,7 @@ fun ProfileScreen( externalIdentities: UserExternalIdentitiesViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -273,6 +286,7 @@ fun ProfileScreen( WatchLifecycleAndUpdateModel(mutualViewModel) WatchLifecycleAndUpdateModel(appRecommendations) WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) + WatchLifecycleAndUpdateModel(pinnedNotesFeedViewModel) WatchLifecycleAndUpdateModel(galleryFeedViewModel) UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) @@ -291,6 +305,7 @@ fun ProfileScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -385,6 +400,7 @@ private fun RenderScreen( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -394,7 +410,7 @@ private fun RenderScreen( Column { ProfileHeader(baseUser, appRecommendations, externalIdentities, nav, accountViewModel) - ScrollableTabRow( + SecondaryScrollableTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, @@ -431,6 +447,7 @@ private fun RenderScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -451,6 +468,7 @@ private fun CreateAndRenderPages( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -464,7 +482,7 @@ private fun CreateAndRenderPages( ) when (page) { - 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) + 0 -> TabNotesNewThreads(threadsViewModel, pinnedNotesFeedViewModel, accountViewModel, nav) 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) 2 -> TabMutualConversations(mutualViewModel, accountViewModel, nav) 3 -> TabGallery(galleryFeedViewModel, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index 273433753..18bfaeae7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal import com.vitorpamplona.amethyst.model.Account @@ -26,7 +28,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent class UserProfileConversationsFeedFilter( val user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index b7c317ac8..7783b9de9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.hashtagList.HashtagListEvent @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendatio val UserProfileListKinds = listOf( BookmarkListEvent.KIND, + PinListEvent.KIND, PeopleListEvent.KIND, FollowListEvent.KIND, HashtagListEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index ef9ac5fbb..967c1422d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.model.LocalCache @@ -26,7 +28,6 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent val UserProfilePostKinds1 = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index 122ec9654..b86e6b380 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt @@ -66,7 +66,7 @@ class UserProfileFollowersUserFeedViewModel( @OptIn(kotlinx.coroutines.FlowPreview::class) val followersFlow: StateFlow> = account.cache - .observeEvents(followerFilter) + .observeEvents(followerFilter) .sample(500) .map { followerContactLists -> followerContactLists.toNonHiddenOwners().sortedWith(sortingModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index b153ef37e..fce93165f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -169,18 +169,17 @@ fun GalleryContentView( accountViewModel: AccountViewModel, ) { AutoNonlazyGrid(contentList.size, modifier = Modifier.fillMaxSize()) { contentIndex -> - when (val content = contentList[contentIndex]) { - is MediaUrlContent -> { - val sensitivityReason = - when (content) { - is MediaUrlVideo -> content.contentWarning - is MediaUrlImage -> content.contentWarning - else -> null - } - SensitivityWarning(sensitivityReason, accountViewModel) { - UrlImageView(content, accountViewModel) - } + val content = contentList[contentIndex] + + val sensitivityReason = + when (content) { + is MediaUrlVideo -> content.contentWarning + is MediaUrlImage -> content.contentWarning + else -> null } + + SensitivityWarning(sensitivityReason, accountViewModel) { + UrlImageView(content, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 3acc587d9..58cb3829b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.timeAgo -import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index 7576872ed..1f2a822ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -29,11 +29,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R @@ -41,9 +41,11 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBanner import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -63,7 +65,8 @@ fun DrawBanner( accountViewModel: AccountViewModel, ) { if (!banner.isNullOrBlank()) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() var zoomImageDialogOpen by remember { mutableStateOf(false) } AsyncImage( @@ -78,7 +81,11 @@ fun DrawBanner( .height(150.dp) .combinedClickable( onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(banner)) }, + onLongClick = { + scope.launch { + clipboardManager.setText(banner) + } + }, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 0dd30e5ee..e730f3e0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -28,18 +28,20 @@ import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Report import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.note.externalLinkForUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip56Reports.ReportType +import kotlinx.coroutines.launch @Composable fun UserProfileDropDownMenu( @@ -54,7 +56,8 @@ fun UserProfileDropDownMenu( title = stringRes(R.string.profile_actions_dialog_title), onDismiss = onDismiss, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() val context = LocalContext.current // Share section @@ -63,8 +66,10 @@ fun UserProfileDropDownMenu( icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_id), ) { - clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) - onDismiss() + scope.launch { + clipboardManager.setText(user.pubkeyNpub()) + onDismiss() + } } M3ActionRow( icon = Icons.Outlined.Share, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index ce44c3921..b303c50d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser @@ -43,6 +42,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent class UserProfileMutualFeedFilter( val user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index c2d523570..9b256d96f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -20,28 +20,166 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun TabNotesNewThreads( feedViewModel: UserProfileNewThreadsFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { + LaunchedEffect(Unit) { pinnedNotesFeedViewModel.invalidateData() } + Column(Modifier.fillMaxHeight()) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) + RefresheableBox(feedViewModel, enablePullRefresh = false) { + val listState = + androidx.compose.foundation.lazy + .rememberLazyListState() + + WatchScrollToTop(feedViewModel.feedState, listState) + + val feedState by feedViewModel.feedState.feedContent.collectAsStateWithLifecycle() + val pinnedFeedState by pinnedNotesFeedViewModel.feedState.feedContent.collectAsStateWithLifecycle() + + when (val state = feedState) { + is FeedState.Empty -> { + val pinnedLoaded = pinnedFeedState as? FeedState.Loaded + if (pinnedLoaded != null) { + val pinnedItems by pinnedLoaded.feed.collectAsStateWithLifecycle() + if (pinnedItems.list.isNotEmpty()) { + FeedLoadedWithPinnedNotes( + pinnedFeedState = pinnedLoaded, + loaded = null, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + FeedEmpty { feedViewModel.invalidateData() } + } + } else { + FeedEmpty { feedViewModel.invalidateData() } + } + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage) { feedViewModel.invalidateData() } + } + + is FeedState.Loaded -> { + FeedLoadedWithPinnedNotes( + pinnedFeedState = pinnedFeedState as? FeedState.Loaded, + loaded = state, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + is FeedState.Loading -> { + LoadingFeed() + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FeedLoadedWithPinnedNotes( + pinnedFeedState: FeedState.Loaded?, + loaded: FeedState.Loaded?, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val pinnedItems = + pinnedFeedState?.let { + val state by it.feed.collectAsStateWithLifecycle() + state + } + + val feedItems = + loaded?.let { + val state by it.feed.collectAsStateWithLifecycle() + state + } + + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + if (pinnedItems != null && pinnedItems.list.isNotEmpty()) { + items( + pinnedItems.list, + key = { "pinned-${it.idHex}" }, + contentType = { it.event?.kind ?: -1 }, + ) { item -> + Column(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + item, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = null, + isBoostedNote = false, + isHiddenFeed = pinnedItems.showHidden, + isPinned = true, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + HorizontalDivider(thickness = DividerThickness) + } + } + + if (feedItems != null) { + itemsIndexed( + feedItems.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + item, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = null, + isBoostedNote = false, + isHiddenFeed = feedItems.showHidden, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + HorizontalDivider(thickness = DividerThickness) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt new file mode 100644 index 000000000..bddbc283a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.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.amethyst.ui.screen.loggedIn.profile.pinnedNotes + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPinnedNotesCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun PinnedNotesTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val count by observeUserPinnedNotesCount(baseUser, accountViewModel) + + Text(text = "$count ${stringRes(R.string.pinned_notes)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt new file mode 100644 index 000000000..82b52432d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.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.ui.screen.loggedIn.profile.pinnedNotes + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel + +@Composable +fun TabPinnedNotes( + feedViewModel: UserProfilePinnedNotesFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt new file mode 100644 index 000000000..8f2bddb8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.pinnedNotes.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nip51Lists.PinListEvent + +class UserProfilePinnedNotesFeedFilter( + val user: User, + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex + + override fun feed(): List { + val note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex)) + val noteEvent = note.event as? PinListEvent ?: return emptyList() + + return noteEvent + .pinnedEvents() + .mapNotNull { + LocalCache.checkGetOrCreateNote(it.eventId) + }.reversed() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt new file mode 100644 index 000000000..ac14c8e65 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.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.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class UserProfilePinnedNotesFeedViewModel( + val user: User, + val account: Account, +) : AndroidFeedViewModel(UserProfilePinnedNotesFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfilePinnedNotesFeedViewModel(user, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 1bc249e05..523cad222 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -28,18 +28,20 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import kotlinx.coroutines.launch @Composable fun RelayFeedView( @@ -94,13 +96,16 @@ private fun RenderRelayRow( accountViewModel: AccountViewModel, nav: INav, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() RelayCompose( relay, accountViewModel = accountViewModel, onAddRelay = { - clipboardManager.setText(AnnotatedString(relay.url.url)) - nav.nav(Route.EditRelays) + scope.launch { + clipboardManager.setText(relay.url.url) + nav.nav(Route.EditRelays) + } }, onRemoveRelay = { nav.nav(Route.EditRelays) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index 30579bda9..e1dc0546d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -195,7 +195,7 @@ class RelayFeedViewModel : } override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 35fd0bd23..33ebc1b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.utils.BigDecimal @@ -96,16 +95,14 @@ class UserProfileZapsViewModel( } } - suspend fun List.sumAmountsByUser(): List { + suspend fun List.sumAmountsByUser(): List { val results = mutableMapOf() this.forEach { zapEvent -> - if (zapEvent is LnZapEvent) { - val zapAmount = mapRequest(zapEvent) - if (zapAmount != null) { - val existingAmount = results[zapAmount.user] ?: BigDecimal.ZERO - results[zapAmount.user] = existingAmount + zapAmount.amount - } + val zapAmount = mapRequest(zapEvent) + if (zapAmount != null) { + val existingAmount = results[zapAmount.user] ?: BigDecimal.ZERO + results[zapAmount.user] = existingAmount + zapAmount.amount } } @@ -115,7 +112,7 @@ class UserProfileZapsViewModel( @OptIn(kotlinx.coroutines.FlowPreview::class) val receivedZapAmountsByUser: StateFlow> = account.cache - .observeEvents(zapsToUser) + .observeEvents(zapsToUser) .sample(500) .map { zapEvents -> zapEvents.sumAmountsByUser() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 139790f9a..2b00709cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays import androidx.compose.foundation.clickable @@ -61,7 +63,9 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Payment +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.PrivacyTip +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Topic @@ -80,6 +84,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -97,6 +102,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish @@ -110,6 +116,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.graspLink import com.vitorpamplona.amethyst.ui.note.nipLink import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot @@ -117,6 +124,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Height25Modifier @@ -147,9 +155,6 @@ import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -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.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -254,27 +259,33 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.map import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -325,8 +336,12 @@ fun RelayInformationScreen( ) }, ) { pad -> + RelayInfoNip66FilterAssemblerSubscription(relay, accountViewModel) + val relayInfo by loadRelayInfo(relay) + val discoveryEvents by loadRelayDiscoveryEvents(relay) + val messages = remember(relay) { Amethyst.instance.relayStats @@ -338,7 +353,7 @@ fun RelayInformationScreen( .toImmutableList() } - RelayInformationBody(relay, relayInfo, Amethyst.instance.relayStats.get(relay), messages, pad, accountViewModel, nav) + RelayInformationBody(relay, relayInfo, discoveryEvents, Amethyst.instance.relayStats.get(relay), messages, pad, accountViewModel, nav) } } @@ -346,6 +361,7 @@ fun RelayInformationScreen( fun RelayInformationBody( relay: NormalizedRelayUrl, relayInfo: Nip11RelayInformation, + discoveryEvents: ImmutableList, relayStats: RelayStat, messages: ImmutableList, pad: PaddingValues, @@ -425,6 +441,13 @@ fun RelayInformationBody( item { SoftwareCard(relayInfo) } } + if (discoveryEvents.isNotEmpty()) { + item { SectionHeader(stringRes(R.string.relay_monitor_reports)) } + items(discoveryEvents, key = { it.addressTag() }) { event -> + RelayMonitorReportCard(event, accountViewModel, nav) + } + } + if (usedBy.isNotEmpty()) { item { SectionHeader(stringRes(R.string.used_by)) @@ -525,7 +548,8 @@ fun RelayInformationBody( // Active subscriptions + outbox display // --------------------------------------------------------------------------- -private fun kindDisplayName(kind: Int): Int = +@Suppress("DEPRECATION") +fun kindDisplayName(kind: Int): Int = when (kind) { AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays AppDefinitionEvent.KIND -> R.string.kind_apps @@ -658,6 +682,7 @@ private fun kindDisplayName(kind: Int): Int = VideoShortEvent.KIND -> R.string.kind_shorts VoiceEvent.KIND -> R.string.kind_voice_msg VoiceReplyEvent.KIND -> R.string.kind_voice_reply + WebBookmarkEvent.KIND -> R.string.kind_web_bookmark WikiNoteEvent.KIND -> R.string.kind_wiki else -> -1 } @@ -1101,22 +1126,57 @@ private fun RelayHeader( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - OutlinedButton( + + Row( modifier = Modifier.padding(horizontal = 30.dp), - shape = ButtonBorder, - onClick = { nav.nav(Route.RelayFeed(url = relay.url)) }, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Feed, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringRes(R.string.see_relay_feed)) + OutlinedButton( + shape = ButtonBorder, + onClick = { nav.nav(Route.RelayFeed(url = relay.url)) }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Feed, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringRes(R.string.see_relay_feed)) + } + + if (supportsNip43(relayInfo.supported_nips)) { + OutlinedButton( + onClick = { nav.nav(Route.RelayMembers(relay.url)) }, + shape = ButtonBorder, + ) { + Icon( + Icons.Default.People, + contentDescription = stringRes(R.string.relay_members), + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringRes(R.string.relay_members)) + } + } + + if (Nip86Client.supportsNip86(relayInfo.supported_nips)) { + OutlinedButton( + onClick = { nav.nav(Route.RelayManagement(relay.url)) }, + shape = ButtonBorder, + ) { + Icon( + Icons.Default.Settings, + contentDescription = stringRes(R.string.manage), + modifier = Height25Modifier, + ) + } + } } } } +fun supportsNip43(supportedNips: List?): Boolean = supportedNips?.any { it == "43" } == true + @Composable fun FeesCard( fees: Nip11RelayInformation.RelayInformationFees, @@ -1533,6 +1593,160 @@ fun PoliciesCard(relay: Nip11RelayInformation) { } } +@Composable +fun loadRelayDiscoveryEvents(relay: NormalizedRelayUrl): State> = + remember(relay) { + LocalCache + .observeEvents( + Filter( + kinds = listOf(RelayDiscoveryEvent.KIND), + tags = mapOf("d" to listOf(relay.url)), + since = TimeUtils.oneWeekAgo(), + limit = 3, + ), + ).map { + it.toImmutableList() + } + }.collectAsStateWithLifecycle(persistentListOf()) + +@Composable +private fun RelayMonitorReportCard( + event: RelayDiscoveryEvent, + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Monitor author + timestamp + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + LoadUser(baseUserHex = event.pubKey, accountViewModel) { user -> + if (user != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + UserPicture( + user = user, + size = Size25dp, + accountViewModel = accountViewModel, + nav = nav, + ) + UsernameDisplay(user, weight = Modifier.weight(1f), accountViewModel = accountViewModel) + } + } + } + + Text( + text = timeAgoNoDot(event.createdAt, context), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + + HorizontalDivider() + + // RTT metrics + val rttOpen = event.rttOpen() + val rttRead = event.rttRead() + val rttWrite = event.rttWrite() + + if (rttOpen != null || rttRead != null || rttWrite != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + rttOpen?.let { + RttChip(stringRes(R.string.relay_monitor_rtt_open), it) + } + rttRead?.let { + RttChip(stringRes(R.string.relay_monitor_rtt_read), it) + } + rttWrite?.let { + RttChip(stringRes(R.string.relay_monitor_rtt_write), it) + } + } + } + + // Network type + val networkTypes = event.networkTypes() + if (networkTypes.isNotEmpty()) { + InfoRow( + Icons.Default.Language, + stringRes(R.string.relay_monitor_network), + networkTypes.joinToString { it.code }, + ) + } + + // Relay type + val relayTypes = event.relayTypes() + if (relayTypes.isNotEmpty()) { + InfoRow( + Icons.Default.Dns, + stringRes(R.string.relay_monitor_relay_type), + relayTypes.joinToString(), + ) + } + + // Requirements + val requirements = event.requirements() + if (requirements.isNotEmpty()) { + InfoRow( + Icons.Default.Lock, + stringRes(R.string.relay_monitor_requirements), + requirements.joinToString { req -> + if (req.negated) "!${req.value}" else req.value + }, + ) + } + } + } +} + +@Composable +private fun RttChip( + label: String, + ms: Long, +) { + val color = + when { + ms < 200 -> Color(0xFF4CAF50) + + // green + ms < 500 -> Color(0xFFFFC107) + + // amber + else -> MaterialTheme.colorScheme.error + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + shape = RoundedCornerShape(50), + color = color.copy(alpha = 0.15f), + ) { + Text( + text = stringRes(R.string.relay_monitor_ms, ms.toInt()), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = color, + ) + } + } +} + @Composable fun SectionHeader(title: String) { Text( @@ -1657,6 +1871,7 @@ fun RelayHeaderPreview() { ), supported_grasps = listOf("GRASP-01"), ), + discoveryEvents = persistentListOf(), pad = PaddingValues(0.dp), relayStats = RelayStat(), messages = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index 625fde7c2..567246686 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -32,13 +32,14 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserPicture @@ -52,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -67,14 +69,17 @@ fun BasicRelaySetupInfoClickableRow( accountViewModel: AccountViewModel, nav: INav, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() Column( Modifier .fillMaxWidth() .combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.relay.url)) + scope.launch { + clipboardManager.setText(item.relay.url) + } }, ), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 91bc1f373..af9899177 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -99,7 +99,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { filters.forEach { countFilter -> viewModelScope.launch(Dispatchers.IO) { - val result = client.queryCountSuspend(item.relay, countFilter.filter) + val result = client.count(item.relay, countFilter.filter) if (result != null) { _countResults.update { currentMap -> val current = currentMap[item.relay] ?: RelayCountResult() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt index 2dac14447..aedd53255 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt @@ -33,18 +33,20 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -54,7 +56,8 @@ fun RelayNameAndRemoveButton( onDelete: ((BasicRelaySetupInfo) -> Unit)?, modifier: Modifier, ) { - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Text( @@ -63,7 +66,9 @@ fun RelayNameAndRemoveButton( Modifier.combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.relay.url)) + scope.launch { + clipboardManager.setText(item.relay.url) + } }, ), maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt index fff469fde..19e9ddcd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common -import android.R.attr.onClick import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -41,7 +40,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.service.countToHumanReadable import com.vitorpamplona.amethyst.service.countToHumanReadableBytes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt index d23bd28a6..220d558aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common -import android.R.id.input import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.LocalCache import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index 35441b625..1b49e15bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -122,9 +122,23 @@ fun RelayUrlEditField( onNewRelay: (NormalizedRelayUrl) -> Unit, accountViewModel: AccountViewModel, nav: INav, +) { + RelayUrlEditField( + onNewRelay = onNewRelay, + nip11CachedRetriever = Amethyst.instance.nip11Cache, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun RelayUrlEditField( + onNewRelay: (NormalizedRelayUrl) -> Unit, + nip11CachedRetriever: Nip11CachedRetriever, + accountViewModel: AccountViewModel, + nav: INav, ) { val relaySuggestions = remember { RelaySuggestionState() } - val nip11CachedRetriever = remember { Amethyst.instance.nip11Cache } RelayUrlEditField( onNewRelay = onNewRelay, relaySuggestions = relaySuggestions, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt index 39106f361..97e17abfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common -import android.R.attr.onClick import androidx.compose.foundation.layout.Column import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index e7664350b..6fe03453d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterAssembler.kt new file mode 100644 index 000000000..8a2654284 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterAssembler.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.ui.screen.loggedIn.relays.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class RelayInfoNip66QueryState( + val relayUrl: NormalizedRelayUrl, + val relays: Set, +) + +class RelayInfoNip66FilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayInfoNip66FilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterAssemblerSubscription.kt new file mode 100644 index 000000000..772278573 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterAssemblerSubscription.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.amethyst.ui.screen.loggedIn.relays.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Composable +fun RelayInfoNip66FilterAssemblerSubscription( + relayUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +) { + val state = + remember(relayUrl) { + RelayInfoNip66QueryState(relayUrl, accountViewModel.account.followOutboxesOrProxy.flow.value) + } + + KeyDataSourceSubscription(state, accountViewModel.dataSources().relayInfoNip66) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt new file mode 100644 index 000000000..676288ee0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent + +class RelayInfoNip66FilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayInfoNip66QueryState, + since: SincePerRelayMap?, + ): List { + val relayUrl = key.relayUrl.url + + return key.relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(RelayDiscoveryEvent.KIND), + tags = mapOf("d" to listOf(relayUrl)), + limit = 20, + since = since?.get(relay)?.time, + ), + ) + } + } + + override fun id(key: RelayInfoNip66QueryState) = key.relayUrl.url +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt index fe2efdfe1..3858d7397 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt @@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_ACTIVITY_LOG import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_CONCURRENT_RELAYS -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.RELAY_TIMEOUT_MS import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.LiveSyncActivity.SourceRelayInfo import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -65,7 +64,7 @@ import kotlin.coroutines.cancellation.CancellationException * Each relay is paginated individually: after EOSE the oldest [Event.createdAt] seen on that * relay becomes the next `until` cursor, repeating until the relay returns no new events. * - * OK (true) responses from destination relays are tracked via [IRelayClientListener] and + * OK (true) responses from destination relays are tracked via [RelayConnectionListener] and * attributed back to the source relay that contributed each event. * * Live activity is emitted via [liveActivity] so the UI can show a per-relay log of events @@ -406,7 +405,7 @@ class EventSync( ) val okListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onCannotConnect( relay: IRelayClient, errorMessage: String, @@ -500,7 +499,7 @@ class EventSync( _syncState.emit(runningState) clientBuilder().use { client -> - client.subscribe(okListener) + client.addConnectionListener(okListener) try { client.downloadFromPool( relays = relaysToProcess, @@ -523,21 +522,21 @@ class EventSync( // Each routing rule is independent: an event can match more than one. if (isMyEvent && outboxTargets.isNotEmpty()) { if (outboxDedup.add(event.id)) { - client.send(event, outboxTargets) + client.publish(event, outboxTargets) newEvent = true } matchesAtLeastOneFilter = true } if (mentionsMe && isDmKind && dmTargets.isNotEmpty()) { if (dmDedup.add(event.id)) { - client.send(event, dmTargets) + client.publish(event, dmTargets) newEvent = true } matchesAtLeastOneFilter = true } if (mentionsMe && !isDmKind && inboxTargets.isNotEmpty()) { if (inboxDedup.add(event.id)) { - client.send(event, inboxTargets) + client.publish(event, inboxTargets) newEvent = true } matchesAtLeastOneFilter = true @@ -610,7 +609,7 @@ class EventSync( if (e is CancellationException) throw e _syncState.value = SyncState.Error(e.message ?: "Unknown error", filterSince, filterUntil) } finally { - client.unsubscribe(okListener) + client.removeConnectionListener(okListener) } } } @@ -666,5 +665,5 @@ class EventSync( filters: List, onNewPage: (Long) -> Unit, onEvent: (Event) -> Unit, - ): Int = reqBypassingRelayLimits(relay, filters, RELAY_TIMEOUT_MS, onNewPage, onEvent) + ): Int = fetchAllPages(relay, filters, RELAY_TIMEOUT_MS, onNewPage, onEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index 46da1f83e..9b53017dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -46,7 +46,7 @@ class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { filter = Filter(kinds = listOf(MetadataEvent.KIND)), ), CountFilter( - label = R.string.relay_settings_lower, + label = R.string.relay_settings_lower2, filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 1003e9560..de158507f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -40,7 +40,7 @@ class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = R.string.events, + label = R.string.events_from_you, filter = Filter(authors = listOf(account.pubKey)), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt new file mode 100644 index 000000000..22ee60b46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43 + +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow +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 com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RelayMembersScreen( + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val normalizedRelayUrl = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } + if (normalizedRelayUrl == null) return + + var members by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var isMember by remember { mutableStateOf(false) } + var joinRequestSent by remember { mutableStateOf(false) } + var leaveRequestSent by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(normalizedRelayUrl) { + launch(Dispatchers.IO) { + val filter = + Filter( + kinds = listOf(RelayMembershipListEvent.KIND), + limit = 1, + ) + + val events = + accountViewModel.account.client + .fetchAsFlow(normalizedRelayUrl, filter) + .lastOrNull() + + val membershipEvent = + events + ?.mapNotNull { it as? RelayMembershipListEvent } + ?.maxByOrNull { it.createdAt } + + val memberList = membershipEvent?.members() ?: emptyList() + members = memberList + isMember = memberList.contains(accountViewModel.account.signer.pubKey) + isLoading = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringRes(R.string.relay_members_title, normalizedRelayUrl.displayUrl()), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding), + ) { + MembershipActions( + isMember = isMember, + isLoading = isLoading, + joinRequestSent = joinRequestSent, + leaveRequestSent = leaveRequestSent, + onJoinRequest = { + scope.launch(Dispatchers.IO) { + sendJoinRequest(normalizedRelayUrl, accountViewModel) + joinRequestSent = true + } + }, + onLeaveRequest = { + scope.launch(Dispatchers.IO) { + sendLeaveRequest(normalizedRelayUrl, accountViewModel) + leaveRequestSent = true + } + }, + ) + + HorizontalDivider() + + if (isLoading) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringRes(R.string.relay_members_loading)) + } + } else if (members.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.People, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringRes(R.string.relay_members_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Text( + text = stringRes(R.string.relay_members_count, members.size), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(members, key = { it }) { memberPubKey -> + val user = remember(memberPubKey) { accountViewModel.account.cache.getOrCreateUser(memberPubKey) } + UserCompose( + baseUser = user, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } +} + +@Composable +fun MembershipActions( + isMember: Boolean, + isLoading: Boolean, + joinRequestSent: Boolean, + leaveRequestSent: Boolean, + onJoinRequest: () -> Unit, + onLeaveRequest: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) return@Row + + if (isMember) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringRes(R.string.relay_members_you_are_member), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + if (leaveRequestSent) { + Text( + text = stringRes(R.string.relay_members_leave_sent), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + OutlinedButton( + onClick = onLeaveRequest, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringRes(R.string.relay_members_request_leave)) + } + } + } else { + if (joinRequestSent) { + Text( + text = stringRes(R.string.relay_members_join_sent), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } else { + Button(onClick = onJoinRequest) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringRes(R.string.relay_members_request_join)) + } + } + } + } +} + +@Preview +@Composable +private fun MembershipActionsNotMemberPreview() { + ThemeComparisonColumn { + MembershipActions( + isMember = false, + isLoading = false, + joinRequestSent = false, + leaveRequestSent = false, + onJoinRequest = {}, + onLeaveRequest = {}, + ) + } +} + +@Preview +@Composable +private fun MembershipActionsIsMemberPreview() { + ThemeComparisonColumn { + MembershipActions( + isMember = true, + isLoading = false, + joinRequestSent = false, + leaveRequestSent = false, + onJoinRequest = {}, + onLeaveRequest = {}, + ) + } +} + +@Preview +@Composable +private fun MembershipActionsJoinSentPreview() { + ThemeComparisonColumn { + MembershipActions( + isMember = false, + isLoading = false, + joinRequestSent = true, + leaveRequestSent = false, + onJoinRequest = {}, + onLeaveRequest = {}, + ) + } +} + +@Preview +@Composable +private fun MembershipActionsLeaveSentPreview() { + ThemeComparisonColumn { + MembershipActions( + isMember = true, + isLoading = false, + joinRequestSent = false, + leaveRequestSent = true, + onJoinRequest = {}, + onLeaveRequest = {}, + ) + } +} + +suspend fun sendJoinRequest( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +) { + val template = RelayJoinRequestEvent.build() + val signedEvent = accountViewModel.account.signer.sign(template) + accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent) + accountViewModel.account.client.publish(signedEvent, setOf(relay)) +} + +suspend fun sendLeaveRequest( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +) { + val template = RelayLeaveRequestEvent.build() + val signedEvent = accountViewModel.account.signer.sign(template) + accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent) + accountViewModel.account.client.publish(signedEvent, setOf(relay)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index e6fb7883a..36e725821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo @@ -131,13 +131,13 @@ class Nip65RelayListViewModel : ViewModel() { _homeRelays.value.forEach { item -> viewModelScope.launch(Dispatchers.IO) { - val result = client.queryCountSuspend(item.relay, Filter(authors = listOf(account.pubKey))) + val result = client.count(item.relay, Filter(authors = listOf(account.pubKey))) if (result != null) { val countResult = RelayCountResult( listOf( RelayCountResult.CountEntry( - label = R.string.events, + label = R.string.events_from_you, count = result.count, approximate = result.approximate, ), @@ -150,13 +150,13 @@ class Nip65RelayListViewModel : ViewModel() { _notificationRelays.value.forEach { item -> viewModelScope.launch(Dispatchers.IO) { - val result = client.queryCountSuspend(item.relay, Filter(tags = mapOf("p" to listOf(account.pubKey)))) + val result = client.count(item.relay, Filter(tags = mapOf("p" to listOf(account.pubKey)))) if (result != null) { val countResult = RelayCountResult( listOf( RelayCountResult.CountEntry( - label = R.string.events, + label = R.string.events_to_you, count = result.count, approximate = result.approximate, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt new file mode 100644 index 000000000..5c7827b58 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -0,0 +1,1059 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize +import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.nip05 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun RelayManagementScreen( + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let { + RelayManagementScreen( + relay = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RelayManagementScreen( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, +) { + val retriever = + remember { + Nip86Retriever(Amethyst.instance.torEvaluatorFlow::okHttpClientForRelay) + } + + val viewModel = + remember(relay) { + RelayManagementViewModel( + relayUrl = relay, + account = accountViewModel.account, + retriever = retriever, + ) + } + + LaunchedEffect(relay) { + viewModel.loadSupportedMethods() + } + + val supportedMethods by viewModel.supportedMethods.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val error by viewModel.error.collectAsState() + + LaunchedEffect(supportedMethods) { + if (supportedMethods.isNotEmpty()) { + viewModel.loadAllLists() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + actions = {}, + title = { + Text( + stringResource(R.string.relay_management_title, relay.displayUrl()), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + BackButton(onPress = nav::popBack) + } + }, + ) + }, + ) { pad -> + if (isLoading && supportedMethods.isEmpty()) { + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text(stringResource(R.string.relay_management_loading)) + } + } else if (supportedMethods.isEmpty() && error != null) { + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Block, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringResource(R.string.relay_management_error), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + error ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } else { + RelayManagementContent(pad, viewModel, supportedMethods, error, accountViewModel) + } + } +} + +@Composable +private fun RelayManagementContent( + pad: PaddingValues, + viewModel: RelayManagementViewModel, + supportedMethods: ImmutableList, + error: String?, + accountViewModel: AccountViewModel, +) { + val tabs = + remember(supportedMethods) { + buildList { + if (supportedMethods.any { it in listOf(Nip86Method.BAN_PUBKEY, Nip86Method.LIST_BANNED_PUBKEYS, Nip86Method.ALLOW_PUBKEY, Nip86Method.LIST_ALLOWED_PUBKEYS) }) { + add(ManagementTab.PUBKEYS) + } + if (supportedMethods.any { + it in listOf(Nip86Method.BAN_EVENT, Nip86Method.LIST_BANNED_EVENTS, Nip86Method.ALLOW_EVENT, Nip86Method.LIST_EVENTS_NEEDING_MODERATION) + } + ) { + add(ManagementTab.EVENTS) + } + if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS) }) { + add(ManagementTab.KINDS) + } + if (supportedMethods.any { it in listOf(Nip86Method.BLOCK_IP, Nip86Method.UNBLOCK_IP, Nip86Method.LIST_BLOCKED_IPS) }) { + add(ManagementTab.IPS) + } + if (supportedMethods.any { it in listOf(Nip86Method.CHANGE_RELAY_NAME, Nip86Method.CHANGE_RELAY_DESCRIPTION, Nip86Method.CHANGE_RELAY_ICON) }) { + add(ManagementTab.SETTINGS) + } + } + } + + var selectedTab by remember { mutableIntStateOf(0) } + + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .fillMaxSize(), + ) { + if (error != null) { + Snackbar( + modifier = Modifier.padding(8.dp), + action = { + TextButton(onClick = { viewModel.clearError() }) { + Text(stringResource(R.string.relay_management_dismiss)) + } + }, + ) { + Text(error) + } + } + + if (tabs.isNotEmpty()) { + SecondaryScrollableTabRow( + selectedTabIndex = selectedTab.coerceAtMost(tabs.size - 1), + edgePadding = 8.dp, + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(stringResource(tab.titleRes)) }, + ) + } + } + + when (tabs.getOrNull(selectedTab)) { + ManagementTab.PUBKEYS -> { + PubkeysTab(viewModel, supportedMethods, accountViewModel) + } + + ManagementTab.EVENTS -> { + EventsTab(viewModel, supportedMethods) + } + + ManagementTab.KINDS -> { + KindsTab(viewModel, supportedMethods) + } + + ManagementTab.IPS -> { + IpsTab(viewModel, supportedMethods) + } + + ManagementTab.SETTINGS -> { + SettingsTab(viewModel, supportedMethods) + } + + null -> {} + } + } else { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + stringResource(R.string.relay_management_no_methods), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } +} + +private enum class ManagementTab( + val titleRes: Int, +) { + PUBKEYS(R.string.relay_management_tab_pubkeys), + EVENTS(R.string.relay_management_tab_events), + KINDS(R.string.relay_management_tab_kinds), + IPS(R.string.relay_management_tab_ips), + SETTINGS(R.string.relay_management_tab_settings), +} + +// Pubkeys Tab +@Composable +private fun PubkeysTab( + viewModel: RelayManagementViewModel, + supportedMethods: List, + accountViewModel: AccountViewModel, +) { + val bannedPubkeyUsers by viewModel.bannedPubkeyUsers.collectAsStateWithLifecycle(emptyList()) + val allowedPubkeyUsers by viewModel.allowedPubkeyUsers.collectAsStateWithLifecycle(emptyList()) + var showBanDialog by remember { mutableStateOf(false) } + var showAllowDialog by remember { mutableStateOf(false) } + + LazyColumn( + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (supportedMethods.contains(Nip86Method.LIST_BANNED_PUBKEYS)) { + item { + SectionHeaderWithAdd( + stringResource(R.string.relay_management_banned_pubkeys), + showAdd = supportedMethods.contains(Nip86Method.BAN_PUBKEY), + onAdd = { showBanDialog = true }, + ) + } + + if (bannedPubkeyUsers.isEmpty()) { + item { EmptyListMessage(stringResource(R.string.relay_management_no_banned_pubkeys)) } + } else { + items(bannedPubkeyUsers, key = { it.user.pubkeyHex }) { entry -> + PubkeyUserCard( + entry = entry, + showRemove = supportedMethods.contains(Nip86Method.UNBAN_PUBKEY), + onRemove = { viewModel.unbanPubkey(entry.user.pubkeyHex) }, + accountViewModel = accountViewModel, + ) + } + } + } + + if (supportedMethods.contains(Nip86Method.LIST_ALLOWED_PUBKEYS)) { + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + SectionHeaderWithAdd( + stringResource(R.string.relay_management_allowed_pubkeys), + showAdd = supportedMethods.contains(Nip86Method.ALLOW_PUBKEY), + onAdd = { showAllowDialog = true }, + ) + } + + if (allowedPubkeyUsers.isEmpty()) { + item { EmptyListMessage(stringResource(R.string.relay_management_no_allowed_pubkeys)) } + } else { + items(allowedPubkeyUsers, key = { it.user.pubkeyHex }) { entry -> + PubkeyUserCard( + entry = entry, + showRemove = supportedMethods.contains(Nip86Method.UNALLOW_PUBKEY), + onRemove = { viewModel.unallowPubkey(entry.user.pubkeyHex) }, + accountViewModel = accountViewModel, + ) + } + } + } + } + + if (showBanDialog) { + HexInputDialog( + title = stringResource(R.string.relay_management_ban_pubkey), + label = stringResource(R.string.relay_management_pubkey_hex), + onConfirm = { hex, reason -> + viewModel.banPubkey(hex, reason.ifBlank { null }) + showBanDialog = false + }, + onDismiss = { showBanDialog = false }, + ) + } + + if (showAllowDialog) { + HexInputDialog( + title = stringResource(R.string.relay_management_allow_pubkey), + label = stringResource(R.string.relay_management_pubkey_hex), + onConfirm = { hex, reason -> + viewModel.allowPubkey(hex, reason.ifBlank { null }) + showAllowDialog = false + }, + onDismiss = { showAllowDialog = false }, + ) + } +} + +@Composable +private fun PubkeyUserCard( + entry: PubkeyUser, + showRemove: Boolean, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, +) { + SlimListItem( + modifier = Modifier.fillMaxWidth(), + leadingContent = { + ClickableUserPicture(entry.user, Size55dp, accountViewModel = accountViewModel, onClick = null) + }, + headlineContent = { + UsernameDisplay(entry.user, accountViewModel = accountViewModel) + }, + supportingContent = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + PubkeyNip05Row(entry.user, accountViewModel) + } + entry.reason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + trailingContent = { + if (showRemove) { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.relay_management_remove), + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) +} + +@Composable +private fun PubkeyNip05Row( + user: User, + accountViewModel: AccountViewModel, +) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + when (val nip05State = nip05StateMetadata) { + is Nip05State.Exists -> { + if (nip05State.nip05.name != "_") { + Text( + text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) }, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.nip05, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel) + + Text( + text = nip05State.nip05.domain, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, + ) + } + + else -> { + Text( + text = user.pubkeyDisplayHex(), + fontSize = Font14SP, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +// Events Tab +@Composable +private fun EventsTab( + viewModel: RelayManagementViewModel, + supportedMethods: List, +) { + val bannedEvents by viewModel.bannedEvents.collectAsState() + val eventsNeedingModeration by viewModel.eventsNeedingModeration.collectAsState() + var showBanDialog by remember { mutableStateOf(false) } + + LazyColumn( + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (supportedMethods.contains(Nip86Method.LIST_EVENTS_NEEDING_MODERATION)) { + item { + SectionHeaderWithAdd( + stringResource(R.string.relay_management_moderation_queue), + showAdd = false, + onAdd = {}, + ) + } + + if (eventsNeedingModeration.isEmpty()) { + item { EmptyListMessage(stringResource(R.string.relay_management_no_moderation_events)) } + } else { + items(eventsNeedingModeration, key = { it.id }) { entry -> + ModerationEventCard( + eventId = entry.id, + reason = entry.reason, + canAllow = supportedMethods.contains(Nip86Method.ALLOW_EVENT), + canBan = supportedMethods.contains(Nip86Method.BAN_EVENT), + onAllow = { viewModel.allowEvent(entry.id) }, + onBan = { viewModel.banEvent(entry.id) }, + ) + } + } + } + + if (supportedMethods.contains(Nip86Method.LIST_BANNED_EVENTS)) { + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + SectionHeaderWithAdd( + stringResource(R.string.relay_management_banned_events), + showAdd = supportedMethods.contains(Nip86Method.BAN_EVENT), + onAdd = { showBanDialog = true }, + ) + } + + if (bannedEvents.isEmpty()) { + item { EmptyListMessage(stringResource(R.string.relay_management_no_banned_events)) } + } else { + items(bannedEvents, key = { it.id }) { entry -> + HexEntryCard( + hex = entry.id, + reason = entry.reason, + showRemove = false, + onRemove = {}, + ) + } + } + } + } + + if (showBanDialog) { + HexInputDialog( + title = stringResource(R.string.relay_management_ban_event), + label = stringResource(R.string.relay_management_event_id_hex), + onConfirm = { hex, reason -> + viewModel.banEvent(hex, reason.ifBlank { null }) + showBanDialog = false + }, + onDismiss = { showBanDialog = false }, + ) + } +} + +// Kinds Tab +@Composable +private fun KindsTab( + viewModel: RelayManagementViewModel, + supportedMethods: List, +) { + val allowedKinds by viewModel.allowedKinds.collectAsState() + var showAddDialog by remember { mutableStateOf(false) } + + LazyColumn( + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + SectionHeaderWithAdd( + stringResource(R.string.relay_management_allowed_kinds), + showAdd = supportedMethods.contains(Nip86Method.ALLOW_KIND), + onAdd = { showAddDialog = true }, + ) + } + + if (allowedKinds.isEmpty()) { + item { EmptyListMessage(stringResource(R.string.relay_management_no_allowed_kinds)) } + } else { + items(allowedKinds, key = { it }) { kind -> + KindEntryCard( + kind = kind, + showRemove = supportedMethods.contains(Nip86Method.DISALLOW_KIND), + onRemove = { viewModel.disallowKind(kind) }, + ) + } + } + } + + if (showAddDialog) { + KindInputDialog( + onConfirm = { kind -> + viewModel.allowKind(kind) + showAddDialog = false + }, + onDismiss = { showAddDialog = false }, + ) + } +} + +// IPs Tab +@Composable +private fun IpsTab( + viewModel: RelayManagementViewModel, + supportedMethods: List, +) { + val blockedIps by viewModel.blockedIps.collectAsState() + var showBlockDialog by remember { mutableStateOf(false) } + + LazyColumn( + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + SectionHeaderWithAdd( + stringResource(R.string.relay_management_blocked_ips), + showAdd = supportedMethods.contains(Nip86Method.BLOCK_IP), + onAdd = { showBlockDialog = true }, + ) + } + + if (blockedIps.isEmpty()) { + item { EmptyListMessage(stringResource(R.string.relay_management_no_blocked_ips)) } + } else { + items(blockedIps, key = { it.ip }) { entry -> + IpEntryCard( + ip = entry.ip, + reason = entry.reason, + showRemove = supportedMethods.contains(Nip86Method.UNBLOCK_IP), + onRemove = { viewModel.unblockIp(entry.ip) }, + ) + } + } + } + + if (showBlockDialog) { + HexInputDialog( + title = stringResource(R.string.relay_management_block_ip), + label = stringResource(R.string.relay_management_ip_address), + onConfirm = { ip, reason -> + viewModel.blockIp(ip, reason.ifBlank { null }) + showBlockDialog = false + }, + onDismiss = { showBlockDialog = false }, + ) + } +} + +// Settings Tab +@Composable +private fun SettingsTab( + viewModel: RelayManagementViewModel, + supportedMethods: List, +) { + var relayName by remember { mutableStateOf("") } + var relayDescription by remember { mutableStateOf("") } + var relayIcon by remember { mutableStateOf("") } + + LazyColumn( + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (supportedMethods.contains(Nip86Method.CHANGE_RELAY_NAME)) { + item { + SettingsField( + label = stringResource(R.string.relay_management_relay_name), + value = relayName, + onValueChange = { relayName = it }, + onApply = { viewModel.changeRelayName(relayName) }, + ) + } + } + + if (supportedMethods.contains(Nip86Method.CHANGE_RELAY_DESCRIPTION)) { + item { + SettingsField( + label = stringResource(R.string.relay_management_relay_description), + value = relayDescription, + onValueChange = { relayDescription = it }, + onApply = { viewModel.changeRelayDescription(relayDescription) }, + ) + } + } + + if (supportedMethods.contains(Nip86Method.CHANGE_RELAY_ICON)) { + item { + SettingsField( + label = stringResource(R.string.relay_management_relay_icon_url), + value = relayIcon, + onValueChange = { relayIcon = it }, + onApply = { viewModel.changeRelayIcon(relayIcon) }, + ) + } + } + } +} + +// Reusable components + +@Composable +private fun SectionHeaderWithAdd( + title: String, + showAdd: Boolean, + onAdd: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + if (showAdd) { + IconButton(onClick = onAdd) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.relay_management_add), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + HorizontalDivider() +} + +@Composable +private fun EmptyListMessage(message: String) { + Text( + message, + modifier = Modifier.padding(vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun HexEntryCard( + hex: String, + reason: String?, + showRemove: Boolean, + onRemove: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + hex, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + reason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (showRemove) { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.relay_management_remove), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@Composable +private fun ModerationEventCard( + eventId: String, + reason: String?, + canAllow: Boolean, + canBan: Boolean, + onAllow: () -> Unit, + onBan: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + eventId, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + reason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (canAllow) { + IconButton(onClick = onAllow) { + Icon( + Icons.Default.CheckCircle, + contentDescription = stringResource(R.string.relay_management_allow), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + if (canBan) { + IconButton(onClick = onBan) { + Icon( + Icons.Default.Block, + contentDescription = stringResource(R.string.relay_management_ban), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } +} + +@Composable +private fun KindEntryCard( + kind: Int, + showRemove: Boolean, + onRemove: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val nameResId = kindDisplayName(kind) + val name = if (nameResId != -1) stringResource(nameResId) else "" + + Text( + "Kind $kind: $name", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + if (showRemove) { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.relay_management_remove), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@Composable +private fun IpEntryCard( + ip: String, + reason: String?, + showRemove: Boolean, + onRemove: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + ip, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + reason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (showRemove) { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.relay_management_remove), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@Composable +private fun SettingsField( + label: String, + value: String, + onValueChange: (String) -> Unit, + onApply: () -> Unit, +) { + Column { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(4.dp)) + TextButton( + onClick = onApply, + modifier = Modifier.align(Alignment.End), + enabled = value.isNotBlank(), + ) { + Text(stringResource(R.string.relay_management_apply)) + } + } +} + +@Composable +private fun HexInputDialog( + title: String, + label: String, + onConfirm: (String, String) -> Unit, + onDismiss: () -> Unit, +) { + var hexValue by remember { mutableStateOf("") } + var reasonValue by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + OutlinedTextField( + value = hexValue, + onValueChange = { hexValue = it }, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = reasonValue, + onValueChange = { reasonValue = it }, + label = { Text(stringResource(R.string.relay_management_reason_optional)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(hexValue.trim(), reasonValue.trim()) }, + enabled = hexValue.isNotBlank(), + ) { + Text(stringResource(R.string.relay_management_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.relay_management_cancel)) + } + }, + ) +} + +@Composable +private fun KindInputDialog( + onConfirm: (Int) -> Unit, + onDismiss: () -> Unit, +) { + var kindValue by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.relay_management_allow_kind)) }, + text = { + OutlinedTextField( + value = kindValue, + onValueChange = { kindValue = it.filter { c -> c.isDigit() } }, + label = { Text(stringResource(R.string.relay_management_kind_number)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { + kindValue.toIntOrNull()?.let { onConfirm(it) } + }, + enabled = kindValue.toIntOrNull() != null, + ) { + Text(stringResource(R.string.relay_management_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.relay_management_cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt new file mode 100644 index 000000000..cf570b2e7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86 + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BlockedIp +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.EventNeedingModeration +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class PubkeyUser( + val user: User, + val reason: String?, +) + +@Stable +class RelayManagementViewModel( + relayUrl: NormalizedRelayUrl, + account: Account, + private val retriever: Nip86Retriever, +) : ViewModel() { + val client = Nip86Client(relayUrl, account.signer) + + private val _supportedMethods = MutableStateFlow>(persistentListOf()) + val supportedMethods: StateFlow> = _supportedMethods + + private val _bannedPubkeys = MutableStateFlow>(emptyList()) + val bannedPubkeys: StateFlow> = _bannedPubkeys + + private val _allowedPubkeys = MutableStateFlow>(emptyList()) + val allowedPubkeys: StateFlow> = _allowedPubkeys + + private val _bannedEvents = MutableStateFlow>(emptyList()) + val bannedEvents: StateFlow> = _bannedEvents + + private val _eventsNeedingModeration = MutableStateFlow>(emptyList()) + val eventsNeedingModeration: StateFlow> = _eventsNeedingModeration + + private val _allowedKinds = MutableStateFlow>(emptyList()) + val allowedKinds: StateFlow> = _allowedKinds + + private val _blockedIps = MutableStateFlow>(emptyList()) + val blockedIps: StateFlow> = _blockedIps + + val bannedPubkeyUsers: Flow> = + _bannedPubkeys.map { list -> + list + .mapNotNull { entry -> + LocalCache.checkGetOrCreateUser(entry.pubkey)?.let { PubkeyUser(it, entry.reason) } + }.sortedByDescending { account.isKnown(it.user) } + } + + val allowedPubkeyUsers: Flow> = + _allowedPubkeys.map { list -> + list + .mapNotNull { entry -> + LocalCache.checkGetOrCreateUser(entry.pubkey)?.let { PubkeyUser(it, entry.reason) } + }.sortedByDescending { account.isKnown(it.user) } + } + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error + + fun loadSupportedMethods() { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + val response = retriever.execute(client, Nip86Request.supportedMethods()) + if (response.error != null) { + _error.value = response.error + } else { + _supportedMethods.value = client.parseSupportedMethods(response)?.toImmutableList() ?: persistentListOf() + } + _isLoading.value = false + } + } + + fun loadBannedPubkeys() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listBannedPubkeys()) + if (response.error != null) { + _error.value = response.error + } else { + _bannedPubkeys.value = client.parseBannedPubkeys(response)?.distinctBy { it.pubkey } ?: emptyList() + } + } + } + + fun loadAllowedPubkeys() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listAllowedPubkeys()) + if (response.error != null) { + _error.value = response.error + } else { + _allowedPubkeys.value = client.parseAllowedPubkeys(response)?.distinctBy { it.pubkey } ?: emptyList() + } + } + } + + fun loadBannedEvents() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listBannedEvents()) + if (response.error != null) { + _error.value = response.error + } else { + _bannedEvents.value = client.parseBannedEvents(response)?.distinctBy { it.id } ?: emptyList() + } + } + } + + fun loadEventsNeedingModeration() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listEventsNeedingModeration()) + if (response.error != null) { + _error.value = response.error + } else { + _eventsNeedingModeration.value = client.parseEventsNeedingModeration(response)?.distinctBy { it.id } ?: emptyList() + } + } + } + + fun loadAllowedKinds() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listAllowedKinds()) + if (response.error != null) { + _error.value = response.error + } else { + _allowedKinds.value = client.parseAllowedKinds(response)?.distinctBy { it } ?: emptyList() + } + } + } + + fun loadBlockedIps() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listBlockedIps()) + if (response.error != null) { + _error.value = response.error + } else { + _blockedIps.value = client.parseBlockedIps(response)?.distinctBy { it.ip } ?: emptyList() + } + } + } + + fun banPubkey( + pubkey: String, + reason: String? = null, + ) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.banPubkey(pubkey, reason)) + if (response.error != null) { + _error.value = response.error + } else { + loadBannedPubkeys() + } + } + } + + fun unbanPubkey(pubkey: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.unbanPubkey(pubkey)) + if (response.error != null) { + _error.value = response.error + } else { + loadBannedPubkeys() + } + } + } + + fun allowPubkey( + pubkey: String, + reason: String? = null, + ) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.allowPubkey(pubkey, reason)) + if (response.error != null) { + _error.value = response.error + } else { + loadAllowedPubkeys() + } + } + } + + fun unallowPubkey(pubkey: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.unallowPubkey(pubkey)) + if (response.error != null) { + _error.value = response.error + } else { + loadAllowedPubkeys() + } + } + } + + fun banEvent( + eventId: String, + reason: String? = null, + ) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.banEvent(eventId, reason)) + if (response.error != null) { + _error.value = response.error + } else { + loadBannedEvents() + } + } + } + + fun allowEvent( + eventId: String, + reason: String? = null, + ) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.allowEvent(eventId, reason)) + if (response.error != null) { + _error.value = response.error + } else { + loadEventsNeedingModeration() + } + } + } + + fun changeRelayName(newName: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.changeRelayName(newName)) + if (response.error != null) { + _error.value = response.error + } + } + } + + fun changeRelayDescription(newDescription: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.changeRelayDescription(newDescription)) + if (response.error != null) { + _error.value = response.error + } + } + } + + fun changeRelayIcon(newIconUrl: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.changeRelayIcon(newIconUrl)) + if (response.error != null) { + _error.value = response.error + } + } + } + + fun allowKind(kind: Int) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.allowKind(kind)) + if (response.error != null) { + _error.value = response.error + } else { + loadAllowedKinds() + } + } + } + + fun disallowKind(kind: Int) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.disallowKind(kind)) + if (response.error != null) { + _error.value = response.error + } else { + loadAllowedKinds() + } + } + } + + fun blockIp( + ip: String, + reason: String? = null, + ) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.blockIp(ip, reason)) + if (response.error != null) { + _error.value = response.error + } else { + loadBlockedIps() + } + } + } + + fun unblockIp(ip: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.unblockIp(ip)) + if (response.error != null) { + _error.value = response.error + } else { + loadBlockedIps() + } + } + } + + fun clearError() { + _error.value = null + } + + fun loadAllLists() { + val methods = _supportedMethods.value + if (methods.contains("listbannedpubkeys")) loadBannedPubkeys() + if (methods.contains("listallowedpubkeys")) loadAllowedPubkeys() + if (methods.contains("listbannedevents")) loadBannedEvents() + if (methods.contains("listeventsneedingmoderation")) loadEventsNeedingModeration() + if (methods.contains("listallowedkinds")) loadAllowedKinds() + if (methods.contains("listblockedips")) loadBlockedIps() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index 2f219c534..7351f8349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -40,7 +40,7 @@ class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = R.string.events, + label = R.string.searchable_events, filter = Filter(), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt new file mode 100644 index 000000000..988531be4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -0,0 +1,479 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish + +import androidx.compose.foundation.border +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.toImmutableList +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RequestToVanishScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + RequestToVanishScreen( + nip11CachedRetriever = Amethyst.instance.nip11Cache, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RequestToVanishScreen( + nip11CachedRetriever: Nip11CachedRetriever, + accountViewModel: AccountViewModel, + nav: INav, +) { + val connectedRelays by accountViewModel.account.client + .connectedRelaysFlow() + .collectAsStateWithLifecycle() + + var selectedRelayUrls by remember { mutableStateOf(emptyList()) } + var allRelaysSelected by remember { mutableStateOf(false) } + var vanishDate by remember { mutableLongStateOf(TimeUtils.now()) } + var reason by remember { mutableStateOf("") } + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + var showConfirmDialog by remember { mutableStateOf(false) } + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = vanishDate * 1000, + ) + + val currentTime = Instant.ofEpochMilli(vanishDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = false, + ) + + val relayOptions = + remember(connectedRelays) { + connectedRelays + .sortedBy { it.url } + .map { relay -> + TitleExplainer(relay.displayUrl(), relay.url) + }.toImmutableList() + } + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.request_to_vanish), nav::popBack) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + // Description + Text( + text = stringRes(R.string.request_to_vanish_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // ALL RELAYS checkbox + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // Relay Selection + Text( + text = stringRes(R.string.vanish_target_relay), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.weight(1f)) + Checkbox( + checked = allRelaysSelected, + onCheckedChange = { + allRelaysSelected = it + }, + ) + Text( + text = stringRes(R.string.vanish_all_relays), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + } + + if (!allRelaysSelected) { + selectedRelayUrls.forEach { + val info = + remember(it) { + relaySetupInfoBuilder(it, false) + } + + BasicRelaySetupInfoDialog( + info, + onDelete = { selectedRelayUrls -= selectedRelayUrls }, + nip11CachedRetriever = nip11CachedRetriever, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + RelayUrlEditField( + onNewRelay = { + selectedRelayUrls += selectedRelayUrls + it + allRelaysSelected = false + }, + nip11CachedRetriever = nip11CachedRetriever, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + Row( + modifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.error, + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringRes(R.string.vanish_all_relays_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(20.dp)) + + // Date Picker + Text( + text = stringRes(R.string.vanish_date_label), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringRes(R.string.vanish_date_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.CalendarMonth, + contentDescription = stringRes(R.string.vanish_select_date), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = formatTimestamp(vanishDate), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(20.dp)) + + // Reason + OutlinedTextField( + value = reason, + onValueChange = { reason = it }, + label = { Text(stringRes(R.string.vanish_reason_label)) }, + placeholder = { Text(stringRes(R.string.vanish_reason_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 1, + maxLines = 4, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Send button + Button( + onClick = { showConfirmDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = allRelaysSelected || selectedRelayUrls.isNotEmpty(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + Icons.Outlined.DeleteForever, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringRes(R.string.vanish_send_request)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { Text(stringRes(R.string.next)) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePicker) { + TimePickerDialog( + title = { + Text(stringRes(R.string.vanish_select_time)) + }, + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + val datetimeLocalTimeZone = + datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis -> + (localDayAtZeroHourMillis / 1000) + + (timePickerState.hour * TimeUtils.ONE_HOUR) + + (timePickerState.minute * TimeUtils.ONE_MINUTE) + } ?: TimeUtils.now() + + val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) + + vanishDate = datetimeLocalTimeZone - offset.totalSeconds + + showTimePicker = false + }, + ) { Text(stringRes(R.string.confirm)) } + }, + ) { + TimePicker(state = timePickerState) + } + } + + if (showConfirmDialog) { + ConfirmVanishDialog( + isAllRelays = allRelaysSelected, + relays = selectedRelayUrls, + onConfirm = { + showConfirmDialog = false + if (allRelaysSelected) { + accountViewModel.requestToVanishFromEverywhere(reason, vanishDate) + } else { + if (selectedRelayUrls.isNotEmpty()) { + accountViewModel.requestToVanish(selectedRelayUrls, reason, vanishDate) + } + } + accountViewModel.toastManager.toast( + R.string.request_to_vanish, + R.string.vanish_request_sent, + ) + nav.popBack() + }, + onDismiss = { showConfirmDialog = false }, + ) + } +} + +@Composable +private fun ConfirmVanishDialog( + isAllRelays: Boolean, + relays: List, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), + ) + }, + title = { + Text( + text = stringRes(R.string.vanish_confirm_title), + textAlign = TextAlign.Center, + ) + }, + text = { + Text( + text = + if (isAllRelays) { + stringRes(R.string.vanish_confirm_all_relays) + } else { + val relayNames = + relays.joinToString( + ", ", + limit = 10, + transform = { it.displayUrl() }, + ) + stringRes(R.string.vanish_confirm_single_relay, relayNames) + }, + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringRes(R.string.vanish_send_request)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} + +private fun formatTimestamp(epochSeconds: Long): String { + val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) + return sdf.format(Date(epochSeconds * 1000)) +} + +@Preview +@Composable +fun RequestToVanishScreenPreview() { + ThemeComparisonColumn { + RequestToVanishScreen( + nip11CachedRetriever = Nip11CachedRetriever { TODO() }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt new file mode 100644 index 000000000..fbdca605d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material.icons.outlined.PublicOff +import androidx.compose.material.icons.outlined.Science +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip62Vanish.ComplianceStatus +import com.vitorpamplona.amethyst.model.nip62Vanish.VanishEventItem +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun VanishEventsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton( + stringRes(id = R.string.vanish_events_title), + nav::popBack, + ) + }, + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + VanishEventsBody(accountViewModel, nav) + } + } +} + +@Composable +fun VanishEventsBody( + accountViewModel: AccountViewModel, + nav: INav, +) { + val vanishEvents by accountViewModel.account.vanish.testableFlow + .collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + if (vanishEvents.isEmpty()) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Outlined.PublicOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringRes(R.string.vanish_events_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringRes(R.string.vanish_events_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + item { + Text( + text = stringRes(R.string.vanish_events_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + items(vanishEvents, key = { it.event.id }) { item -> + VanishEventCard( + item = item, + onTestCompliance = { relay -> + scope.launch { + accountViewModel.account.vanish.testVanishCompliance(item, relay) + } + }, + ) + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } + } +} + +@Composable +private fun VanishEventCard( + item: VanishEventItem, + onTestCompliance: (NormalizedRelayUrl) -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.vanish_date_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatTimestamp(item.event.createdAt), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + + if (item.isAllRelays) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringRes(R.string.vanish_all_relays), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringRes(R.string.vanish_all_relays_compliance_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = stringRes(R.string.vanish_target_relays_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + RenderRelaysWithComplianceResults(item, onTestCompliance) + } + + if (item.event.content.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(thickness = DividerThickness) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = item.event.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun RenderRelaysWithComplianceResults( + item: VanishEventItem, + onTestCompliance: (NormalizedRelayUrl) -> Unit, +) { + val complianceResults by item.complianceResults.collectAsStateWithLifecycle() + item.relays.forEach { relay -> + RelayComplianceRow( + relay = relay, + status = complianceResults[relay] ?: ComplianceStatus.UNTESTED, + onTest = { onTestCompliance(relay) }, + ) + } +} + +@Composable +private fun RelayComplianceRow( + relay: NormalizedRelayUrl, + status: ComplianceStatus, + onTest: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = relay.displayUrl(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + when (status) { + ComplianceStatus.UNTESTED -> { + FilledTonalButton( + onClick = onTest, + modifier = Modifier.height(32.dp), + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Icon( + Icons.Outlined.Science, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_test_button), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + ComplianceStatus.TESTING -> { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + + ComplianceStatus.COMPLIANT -> { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = stringRes(R.string.vanish_compliant), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_compliant), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + ComplianceStatus.NON_COMPLIANT -> { + Icon( + Icons.Outlined.Error, + contentDescription = stringRes(R.string.vanish_non_compliant), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_non_compliant), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + + ComplianceStatus.ERROR -> { + Icon( + Icons.Outlined.Error, + contentDescription = stringRes(R.string.vanish_test_error), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringRes(R.string.vanish_test_error), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun formatTimestamp(epochSeconds: Long): String { + val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) + return sdf.format(Date(epochSeconds * 1000)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index d29bb7037..3aaf8dcb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -95,20 +95,28 @@ class SearchBarViewModel( searchTerm .debounce(400) .mapLatest { term -> - if (term.contains('@')) { + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (term.contains('@')) { + Nip05Id.parse(term) + } else if (term.endsWith(".bit", ignoreCase = true)) { + // Bare .bit domain → synthesize _@domain.bit + Nip05Id("_", term.lowercase()) + } else { + null + } + if (nip05 != null) { runCatching { - Nip05Id.parse(term)?.let { nip05 -> - nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - user } + user } }.getOrNull() } else if (term.startsWithAny(userUriPrefixes)) { @@ -225,8 +233,10 @@ class SearchBarViewModel( val lower = term.lowercase() val relays = - listOfNotNull(relayUrl) + - LocalCache.relayHints.relayDB.filter { _, relay -> relay.url.contains(lower) } + ( + listOfNotNull(relayUrl) + + LocalCache.relayHints.relayDB.filter { _, relay -> relay.url.contains(lower) } + ).distinctBy { it.url } relays .map { relaySetupInfoBuilder(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 68d952c01..2dffe5d4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -91,7 +91,7 @@ fun SearchScreen( factory = SearchBarViewModel.Factory( accountViewModel.account, - accountViewModel.nip05Client, + accountViewModel.nip05ClientBuilder(), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 732ef5fcd..a75accba5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -26,10 +26,14 @@ 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security @@ -84,7 +88,7 @@ fun AllSettingsScreen( TopBarWithBackButton(stringRes(id = R.string.settings), nav::popBack) }, ) { padding -> - Column(Modifier.padding(padding)) { + Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) { SettingsSectionHeader(R.string.account_settings) SettingsNavigationRow( title = R.string.relay_setup, @@ -136,7 +140,21 @@ fun AllSettingsScreen( tint = tint, onClick = { nav.nav(Route.AccountBackup) }, ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.request_to_vanish, + icon = Icons.Outlined.DeleteForever, + tint = tint, + onClick = { nav.nav(Route.RequestToVanish) }, + ) } + HorizontalDivider() + SettingsNavigationRow( + title = R.string.vanish_history, + icon = Icons.Outlined.History, + tint = tint, + onClick = { nav.nav(Route.VanishEvents) }, + ) HorizontalDivider(thickness = 4.dp) SettingsSectionHeader(R.string.app_settings) SettingsNavigationRow( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 870570194..0fdbd7be8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -28,25 +28,38 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NamecoinSettingsScreen(nav: INav) { + NamecoinSettingsScreen( + Amethyst.instance.namecoinPrefs, + electrumXClient = { Amethyst.instance.electrumXClient }, + nav, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun NamecoinSettingsScreen( namecoinPrefs: NamecoinSharedPreferences, + electrumXClient: () -> ElectrumXClient, nav: INav, ) { - val namecoinSettings by namecoinPrefs.settings.collectAsState() + val namecoinSettings by namecoinPrefs.settings.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() Scaffold( @@ -75,6 +88,13 @@ fun NamecoinSettingsScreen( onReset = { scope.launch { namecoinPrefs.reset() } }, + onTestServer = { server -> electrumXClient().testServer(server) }, + onPinCert = { pem -> + scope.launch { + namecoinPrefs.addPinnedCert(pem) + electrumXClient().addPinnedCert(pem) + } + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index db747e138..5db51d692 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -41,6 +42,11 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,20 +59,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Complete settings section for Namecoin ElectrumX server configuration. @@ -79,6 +94,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S * @param onAddServer Called with `host:port[:tcp]` when user adds a server * @param onRemoveServer Called with the server string to remove * @param onReset Called when user resets to defaults + * @param onTestServer Suspend function to test a single server + * @param onPinCert Called with PEM string to persist a TOFU-pinned cert */ @Composable fun NamecoinSettingsSection( @@ -87,6 +104,8 @@ fun NamecoinSettingsSection( onAddServer: (String) -> Unit, onRemoveServer: (String) -> Unit, onReset: () -> Unit, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Column(modifier = modifier.padding(16.dp)) { @@ -150,12 +169,366 @@ fun NamecoinSettingsSection( } } } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(16.dp)) + + // ── Test Connection ──────────────────────────────── + TestConnectionSection( + settings = settings, + onTestServer = onTestServer, + onPinCert = onPinCert, + ) } } } } -// ── Sub-composables ──────────────────────────────────────────────────── +// ── Test Connection ──────────────────────────────────────────────────── + +/** + * Holds a cert pending user confirmation before pinning (TOFU). + */ +private data class PendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + +@Composable +private fun TestConnectionSection( + settings: NamecoinSettings, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var isTesting by remember { mutableStateOf(false) } + var testResults by remember { mutableStateOf>(emptyList()) } + var lastTestTimestamp by remember { mutableStateOf(null) } + // Certs discovered during testing that need user confirmation + var pendingCerts by remember { mutableStateOf>(emptyList()) } + // Which cert is currently shown in the confirmation dialog + var confirmingCert by remember { mutableStateOf(null) } + + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + + // ── Cert confirmation dialog ─────────────────────────────── + confirmingCert?.let { pending -> + AlertDialog( + onDismissRequest = { + // Remove from pending list and move to next (or close) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }, + title = { Text(stringResource(R.string.namecoin_pin_cert_title)) }, + text = { + Column { + Text( + stringResource(R.string.namecoin_pin_cert_body, pending.serverHost), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pending.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pending.pem) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_accept)) + } + }, + dismissButton = { + TextButton(onClick = { + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_reject)) + } + }, + ) + } + + Column { + // ── Test button ──────────────────────────────────────── + Button( + onClick = { + if (!isTesting) { + isTesting = true + testResults = emptyList() + pendingCerts = emptyList() + scope.launch { + val results = mutableListOf() + val newCerts = mutableListOf() + for (server in servers) { + val result = onTestServer(server) + results.add(result) + testResults = results.toList() + // Collect certs for user confirmation (not auto-pinned) + val pem = result.serverCertPem + val fp = result.certFingerprint + if (result.success && pem != null && fp != null) { + newCerts.add( + PendingCertPin( + serverHost = "${server.host}:${server.port}", + fingerprint = fp, + pem = pem, + ), + ) + } + } + lastTestTimestamp = System.currentTimeMillis() + isTesting = false + // Show confirmation dialog for each new cert + if (newCerts.isNotEmpty()) { + pendingCerts = newCerts + confirmingCert = newCerts.first() + } + } + } + }, + enabled = !isTesting, + modifier = Modifier.fillMaxWidth(), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.namecoin_testing)) + } else { + Text(stringResource(R.string.namecoin_test_connection)) + } + } + + // ── Per-server results ───────────────────────────────── + if (testResults.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + + Text( + stringResource(R.string.namecoin_test_results), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + + testResults.forEach { result -> + ServerTestResultRow(result) + } + + if (isTesting && testResults.size < servers.size) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Testing next server…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // ── Diagnostic card ──────────────────────────────────── + if (testResults.isNotEmpty() || lastTestTimestamp != null) { + Spacer(Modifier.height(16.dp)) + DiagnosticCard( + testResults = testResults, + lastTestTimestamp = lastTestTimestamp, + ) + } + } +} + +@Composable +private fun ServerTestResultRow(result: ServerTestResult) { + val serverLabel = "${result.server.host}:${result.server.port}" + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = if (result.success) "✅" else "❌", + fontSize = 14.sp, + modifier = Modifier.padding(end = 6.dp, top = 1.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = serverLabel, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.namecoin_response_time, result.responseTimeMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (result.success) { + Text( + text = stringResource(R.string.namecoin_test_success), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF2E8B57), + ) + val fp = result.certFingerprint + if (fp != null) { + Text( + text = "Cert: ${fp.take(23)}…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + val errorText = result.error + if (errorText != null) { + Text( + text = errorText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ── Diagnostic Card ──────────────────────────────────────────────────── + +@Composable +private fun DiagnosticCard( + testResults: List, + lastTestTimestamp: Long?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + stringResource(R.string.namecoin_diagnostics), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + + // Last test timestamp + if (lastTestTimestamp != null) { + val formatted = + remember(lastTestTimestamp) { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + .format(Date(lastTestTimestamp)) + } + val successCount = testResults.count { it.success } + val totalCount = testResults.size + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = "$formatted ($successCount/$totalCount OK)", + ) + } else { + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = stringResource(R.string.namecoin_no_test_yet), + ) + } + + Spacer(Modifier.height(4.dp)) + + // Device info + DiagnosticRow( + label = stringResource(R.string.namecoin_device_info), + value = "${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + ) + + Spacer(Modifier.height(4.dp)) + + // TLS info from test results + val tlsVersions = + testResults + .mapNotNull { it.tlsVersion } + .distinct() + val tlsDisplay = + if (tlsVersions.isNotEmpty()) { + tlsVersions.joinToString(", ") + } else { + "—" + } + DiagnosticRow( + label = stringResource(R.string.namecoin_tls_info), + value = tlsDisplay, + ) + } + } +} + +@Composable +private fun DiagnosticRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.35f), + ) + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(0.65f), + ) + } +} + +// ── Original Sub-composables ─────────────────────────────────────────── @Composable private fun SectionHeader( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt index 8bbf6d743..2d33dddee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -42,6 +43,12 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OtsSettingsScreen(nav: INav) { + OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun OtsSettingsScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 1f2473402..1752a6022 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -38,7 +38,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text @@ -171,7 +171,7 @@ fun SecurityFiltersScreen( HorizontalDivider(thickness = DividerThickness) - ScrollableTabRow( + SecondaryScrollableTabRow( containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, edgePadding = 8.dp, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt index 631ee4fd3..3cd6bea29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt @@ -101,20 +101,20 @@ open class StringFeedViewModel( Log.d("Init", this.javaClass.simpleName) viewModelScope.launch(Dispatchers.IO) { LocalCache.live.newEventBundles.collect { newNotes -> - Log.d("Rendering Metrics", "Update feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + Log.d("Rendering Metrics") { "Update feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}" } invalidateData() } } viewModelScope.launch(Dispatchers.IO) { LocalCache.live.deletedEventBundles.collect { newNotes -> - Log.d("Rendering Metrics", "Delete feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + Log.d("Rendering Metrics") { "Delete feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}" } invalidateData() } } } override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } bundler.cancel() super.onCleared() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index 16af23f49..922885d88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import android.R.attr.targetName import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -80,7 +79,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -import okio.`-DeprecatedOkio`.source import java.util.Locale as JavaLocale @Preview(device = "spec:width=2160px,height=2340px,dpi=440") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt index 0ec1d5b64..119a427c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt @@ -40,7 +40,7 @@ class HiddenAccountsFeedFilter( LocalCache.getOrCreateUser(it) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("HiddenAccountsFeedFilter", "Failed to parse key $it") + Log.e("HiddenAccountsFeedFilter") { "Failed to parse key $it" } null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 9dad17e3b..a9cd2a2fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:Suppress("DEPRECATION") + package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview import android.annotation.SuppressLint @@ -145,23 +147,36 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderChat import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGoal import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap +import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList +import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember +import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent @@ -206,7 +221,6 @@ import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope @@ -228,6 +242,11 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -246,19 +265,29 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -547,6 +576,7 @@ private fun FullBleedNoteCompose( when (noteEvent) { is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel) is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) + is ThreadEvent -> RenderThreadHeaderForThread(noteEvent) is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) is CodeSnippetEvent -> RenderCodeSnippetHeaderForThread(noteEvent) @@ -662,6 +692,10 @@ private fun FullBleedNoteCompose( RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { RenderGitRepositoryEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is RootSiteEvent) { + RenderRootSiteEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is NamedSiteEvent) { + RenderNamedSiteEvent(baseNote, accountViewModel, nav) } else if (noteEvent is InteractiveStoryBaseEvent) { RenderInteractiveStory( baseNote, @@ -688,6 +722,8 @@ private fun FullBleedNoteCompose( RenderCalendarTimeSlotEvent(baseNote, accountViewModel, nav) } else if (noteEvent is CalendarDateSlotEvent) { RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is GoalEvent) { + RenderGoal(baseNote, accountViewModel, nav) } else if (noteEvent is CommentEvent) { RenderTextEvent( baseNote, @@ -702,6 +738,18 @@ private fun FullBleedNoteCompose( ) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is RelayDiscoveryEvent) { + RenderRelayDiscovery(baseNote, accountViewModel, nav) + } else if (noteEvent is RelayMembershipListEvent) { + RenderRelayMembershipList(baseNote, accountViewModel, nav) + } else if (noteEvent is RelayAddMemberEvent) { + RenderRelayAddMember(baseNote, accountViewModel, nav) + } else if (noteEvent is RelayRemoveMemberEvent) { + RenderRelayRemoveMember(baseNote, accountViewModel, nav) + } else if (noteEvent is RelayJoinRequestEvent) { + RenderRelayJoinRequest(baseNote, accountViewModel, nav) + } else if (noteEvent is RelayLeaveRequestEvent) { + RenderRelayLeaveRequest(baseNote, accountViewModel, nav) } else if (noteEvent is TextNoteModificationEvent) { RenderTextModificationEvent( note = baseNote, @@ -723,6 +771,22 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) + } else if (noteEvent is CashuMintEvent) { + RenderCashuMint(noteEvent) + } else if (noteEvent is FedimintEvent) { + RenderFedimint(noteEvent) + } else if (noteEvent is MintRecommendationEvent) { + RenderMintRecommendation(noteEvent) + } else if (noteEvent is ChatEvent) { + RenderChat( + note = baseNote, + makeItShort = false, + canPreview = canPreview, + quotesLeft = 3, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } else if (noteEvent is PollEvent) { RenderPoll( note = baseNote, @@ -1094,6 +1158,20 @@ private fun RenderLongFormHeaderForThread( } } +@Composable +private fun RenderThreadHeaderForThread(noteEvent: ThreadEvent) { + noteEvent.title()?.let { + Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Text( + text = it, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + @Preview @Composable private fun RenderWikiHeaderForThreadPreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt new file mode 100644 index 000000000..526e8b1fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -0,0 +1,493 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.webBookmarks + +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.fillMaxHeight +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.itemsIndexed +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.Edit +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.UrlCachedPreviewer +import com.vitorpamplona.amethyst.ui.components.UrlPreviewState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import kotlinx.coroutines.launch + +@Composable +fun WebBookmarksScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + RenderWebBookmarksScreen(accountViewModel.feedStates.webBookmarks, accountViewModel, nav) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenderWebBookmarksScreen( + feedState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedState) + + var showAddDialog by remember { mutableStateOf(false) } + + if (showAddDialog) { + WebBookmarkEditDialog( + accountViewModel = accountViewModel, + onDismiss = { showAddDialog = false }, + onSave = { url, title, description, tags -> + accountViewModel.launchSigner { + accountViewModel.account.sendWebBookmark(url, title, description, tags) + } + showAddDialog = false + }, + ) + } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + ShorterTopAppBar( + title = { + Text(text = stringRes(id = R.string.web_bookmarks)) + }, + navigationIcon = { + IconButton(onClick = nav::popBack) { + ArrowBackIcon() + } + }, + ) + }, + floatingButton = { + FloatingActionButton( + onClick = { showAddDialog = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.web_bookmark_add_title), + ) + } + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it).fillMaxHeight()) { + RefresheableBox(feedState) { + SaveableFeedState(feedState, ScrollStateKeys.WEB_BOOKMARKS) { listState -> + RenderFeedContentState( + feedContentState = feedState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = null, + onLoaded = { WebBookmarksFeedLoaded(it, listState, accountViewModel, nav) }, + ) + } + } + } + } +} + +@Composable +private fun WebBookmarksFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + WebBookmarkCard(item, accountViewModel, nav) + + HorizontalDivider(thickness = DividerThickness) + } + } +} + +@Composable +private fun WebBookmarkCard( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? WebBookmarkEvent ?: return + val uriHandler = LocalUriHandler.current + + var showEditDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showEditDialog) { + WebBookmarkEditDialog( + accountViewModel = accountViewModel, + initialUrl = event.url(), + initialTitle = event.title() ?: "", + initialDescription = event.description(), + initialTags = event.hashtags().joinToString(", "), + onDismiss = { showEditDialog = false }, + onSave = { url, title, description, tags -> + accountViewModel.launchSigner { + accountViewModel.account.sendWebBookmark(url, title, description, tags) + } + showEditDialog = false + }, + ) + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.web_bookmark_delete)) }, + text = { Text(stringResource(R.string.web_bookmark_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + accountViewModel.launchSigner { + accountViewModel.account.deleteWebBookmark(event) + } + showDeleteDialog = false + }) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.no)) + } + }, + ) + } + + @Suppress("ProduceStateDoesNotAssignValue") + val urlPreviewState by + produceState( + initialValue = UrlCachedPreviewer.cache.get(event.url()) ?: UrlPreviewState.Loading, + key1 = event.url(), + ) { + if (value == UrlPreviewState.Loading) { + accountViewModel.urlPreview(event.url()) { value = it } + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { uriHandler.openUri(event.url()) } + .padding(16.dp), + ) { + val previewInfo = (urlPreviewState as? UrlPreviewState.Loaded)?.previewInfo + + if (previewInfo?.imageUrlFullPath != null) { + AsyncImage( + model = previewInfo.imageUrlFullPath, + contentDescription = event.title() ?: previewInfo.title, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .height(180.dp) + .clip(QuoteBorder), + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = event.title() ?: previewInfo?.title?.ifBlank { null } ?: event.url(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = previewInfo?.verifiedUrl?.host ?: event.url(), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row { + IconButton(onClick = { uriHandler.openUri(event.url()) }) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = stringResource(R.string.web_bookmark_open_url), + ) + } + IconButton(onClick = { showEditDialog = true }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.web_bookmark_edit_title), + ) + } + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.web_bookmark_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + + val description = event.description().ifBlank { previewInfo?.description ?: "" } + if (description.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + val tags = event.hashtags() + if (tags.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Row { + tags.forEach { tag -> + Text( + text = "#$tag", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(end = 8.dp), + ) + } + } + } + } +} + +@Composable +fun WebBookmarkEditDialog( + accountViewModel: AccountViewModel, + initialUrl: String = "", + initialTitle: String = "", + initialDescription: String = "", + initialTags: String = "", + onDismiss: () -> Unit, + onSave: (url: String, title: String?, description: String, tags: List) -> Unit, +) { + var url by remember { mutableStateOf(initialUrl) } + var title by remember { mutableStateOf(initialTitle) } + var description by remember { mutableStateOf(initialDescription) } + var tags by remember { mutableStateOf(initialTags) } + var isLoadingPreview by remember { mutableStateOf(false) } + var lastFetchedUrl by remember { mutableStateOf(initialUrl) } + + val isEditing = initialUrl.isNotEmpty() + val scope = rememberCoroutineScope() + + fun fetchOpenGraphData(urlToFetch: String) { + if (urlToFetch.isBlank() || urlToFetch == lastFetchedUrl) return + + val normalizedUrl = if (!urlToFetch.startsWith("http")) "https://$urlToFetch" else urlToFetch + lastFetchedUrl = urlToFetch + isLoadingPreview = true + + accountViewModel.urlPreview(normalizedUrl) { state -> + scope.launch { + when (state) { + is UrlPreviewState.Loaded -> { + val info = state.previewInfo + if (title.isBlank() && info.title.isNotBlank()) { + title = info.title + } + if (description.isBlank() && info.description.isNotBlank()) { + description = info.description + } + } + + else -> {} + } + isLoadingPreview = false + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource( + if (isEditing) R.string.web_bookmark_edit_title else R.string.web_bookmark_add_title, + ), + ) + }, + text = { + Column { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.web_bookmark_url_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_url_placeholder)) }, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + if (!focusState.isFocused && url.isNotBlank()) { + fetchOpenGraphData(url) + } + }, + trailingIcon = { + if (isLoadingPreview) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.web_bookmark_title_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_title_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(R.string.web_bookmark_description_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_description_placeholder)) }, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + label = { Text(stringResource(R.string.web_bookmark_tags_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_tags_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (url.isNotBlank()) { + val normalizedUrl = if (!url.startsWith("http")) "https://$url" else url + val tagList = tags.split(",").map { it.trim() }.filter { it.isNotEmpty() } + onSave(normalizedUrl, title.ifBlank { null }, description, tagList) + } + }, + enabled = url.isNotBlank(), + ) { + Text(stringResource(R.string.web_bookmark_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt new file mode 100644 index 000000000..ed628f5ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.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.ui.screen.loggedIn.webBookmarks.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent + +class WebBookmarkFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "/webBookmarks" + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + acceptableEvent(it) + } + + override fun feed(): List { + val bookmarks = + LocalCache.addressables.filterIntoSet(WebBookmarkEvent.KIND, account.userProfile().pubkeyHex) { _, note -> + acceptableEvent(note) + } + + return sort(bookmarks) + } + + fun acceptableEvent(it: Note): Boolean { + val noteEvent = it.event + return noteEvent is WebBookmarkEvent && noteEvent.pubKey == account.userProfile().pubkeyHex + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt index 327f2967e..24e85b586 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt @@ -37,15 +37,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -58,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText -@OptIn(ExperimentalComposeUiApi::class) @Composable fun KeyTextField( value: TextFieldValue, @@ -69,36 +63,10 @@ fun KeyTextField( var showCharsKey by remember { mutableStateOf(false) } - val autofillNodeKey = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it), false) }, - ) - - val autofillNodePassword = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it), false) }, - ) - - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNodeKey - LocalAutofillTree.current += autofillNodePassword - OutlinedTextField( modifier = Modifier - .onGloballyPositioned { coordinates -> - autofillNodeKey.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodeKey) - } else { - cancelAutofillForNode(autofillNodeKey) - } - } - }, + .semantics { contentType = ContentType.Password }, value = value, onValueChange = { onValueChange(it, false) }, keyboardOptions = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index d671fe53e..bb3809b27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -53,19 +53,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -285,7 +280,6 @@ private fun PasswordField(loginViewModel: LoginViewModel) { } } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun PasswordField( value: TextFieldValue, @@ -293,38 +287,12 @@ fun PasswordField( passwordFocusRequester: FocusRequester, onGo: () -> Unit, ) { - val autofillNodeKey = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it)) }, - ) - - val autofillNodePassword = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it)) }, - ) - - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNodeKey - LocalAutofillTree.current += autofillNodePassword - var showCharsPassword by remember { mutableStateOf(false) } OutlinedTextField( modifier = Modifier .focusRequester(passwordFocusRequester) - .onGloballyPositioned { coordinates -> - autofillNodePassword.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodePassword) - } else { - cancelAutofillForNode(autofillNodePassword) - } - } - }, + .semantics { contentType = ContentType.Password }, value = value, onValueChange = onValueChange, keyboardOptions = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index f51773ef6..bce51df82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -48,9 +48,13 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize val Shapes = @@ -389,3 +393,13 @@ val SuggestionListDefaultHeightChat = Modifier.heightIn(0.dp, 200.dp) val SuggestionListDefaultHeightPage = Modifier.heightIn(0.dp, 300.dp) val FollowPackHeaderModifier = Modifier.fillMaxWidth().height(TopBarSize) + +val Size22ModifierWith4Padding = Modifier.size(22.dp).padding(end = 4.dp) + +val TextStyleBottomNavBar = + TextLinkStyles( + SpanStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index ccdea4a0f..e25508d0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -65,7 +65,7 @@ class TorService( active.torControlConnection = torService.torControlConnection trySend(active) - Log.d("TorService", "Tor Service Connected ${torService.socksPort}") + Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" } } } @@ -86,7 +86,7 @@ class TorService( try { context.unbindService(serviceConnection) } catch (e: Exception) { - Log.d("TorService", "Failed to unbind Tor Service: ${e.message}") + Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" } } launch { context.stopService(currentIntent) diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg deleted file mode 100644 index 126d9edf6..000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.webp b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.webp new file mode 100644 index 000000000..0b80b67a1 Binary files /dev/null and b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.webp differ diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 14d9da01d..73343848f 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -662,4 +662,6 @@ متأكد من حذف جميع المسودات؟ + + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index e371e164f..9dbbdea8b 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -71,7 +71,7 @@ " অনুসরণ" " অনুসারী" "%1$s অনুসরণ" - "" + "%1$s অনুসারী" প্রোফাইল নিরাপত্তা-ফিল্টার লগ আউট @@ -574,4 +574,6 @@ QR কোড স্ক্যান করুন + + diff --git a/amethyst/src/main/res/values-ca-rES/strings.xml b/amethyst/src/main/res/values-ca-rES/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-ca-rES/strings.xml +++ b/amethyst/src/main/res/values-ca-rES/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 7775e3628..6f60b8975 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -61,6 +61,8 @@ Podepisovatel neautorizoval dešifrování potřebné k provedení této operace. Aktivujte dešifrování NIP-44 ve své aplikaci pro podepisování a zkuste to znovu Podepisovatel nenalezen Byla aplikace pro podepisování odinstalována? Zkontrolujte, zda je aplikace nainstalována a obsahuje tento účet. Odhlaste se a přihlaste znovu, pokud se aplikace změnila. + Podepisovatel se choval neočekávaně + Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli. Zapy Počet zobrazení Zvýšení @@ -377,12 +379,16 @@ Výchozí záložky Vaše výchozí záložky, které mnoho klientů podporuje Koncepty + Ankety Soukromé záložky Veřejné záložky Přidat do soukromých záložek Přidat do veřejných záložek Odebrat ze soukromých záložek Odebrat z veřejných záložek + Připnuté poznámky + Připnout na profil + Odepnout z profilu Seznamy záložek Ikona seznamu záložek Nový seznam záložek @@ -446,6 +452,8 @@ Maximální zaps Konsensus (0–100)% + Jedna možnost + Více možností Datum a čas ukončení ankety Anketa končí za %1$s Uzavřít po @@ -485,6 +493,9 @@ Příjemce a veřejnost neví, kdo platbu poslal Né Zap Žádná stopa v Nostr, pouze v Lightning + Anonymní + Odeslat jako novou jednorázovou identitu. Váš účet nebude s touto odpovědí spojen. + Tato odpověď bude odeslána z nové anonymní identity Souborový server Zvolte server pro nahrání tohoto souboru LnAddress nebo @Uživatel @@ -708,6 +719,17 @@ Podmínky & ujednání N/A Chyby a upozornění z tohoto relé + Zprávy monitoru relé + Otevření + Čtení + Zápis + RTT + Síť + Typ + Podporované NIP + Požadavky + Poslední kontrola + %1$d ms Aktivní odběry Čekající události k odeslání REQ odběry (%1$d) @@ -1324,6 +1346,7 @@ Hashtagy Komunity Seznamy + Relé Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1560,6 +1583,7 @@ Krátká videa Hlasová zpráva Hlasová odpověď + Webová záložka Wiki Začněte se skvělým feedem tím, že budete sledovat stejné lidi jako někdo, komu důvěřujete. Importovat seznam sledovaných @@ -1588,6 +1612,21 @@ Vybrat vše %1$d%% dostupnost Nastavení Namecoin + Otestovat připojení + Testování serverů… + Připojeno + Selhalo + Výsledky testu + Diagnostika + Poslední test + Informace o zařízení + Informace o TLS + Zatím nebyl proveden žádný test + %dms + Důvěřovat certifikátu serveru? + Server %1$s předložil certifikát, který dosud není ve vašem úložišti důvěry. Ověřte, že otisk níže odpovídá tomu, co zveřejnil provozovatel serveru, a poté zvolte, zda mu budete důvěřovat pro budoucí připojení. + Důvěřovat + Odmítnout Synchronizace relé Synchronizace relé Znovu publikujte své události na všech známých relé, aby byly vaše relé pro odchozí, příchozí a DM zprávy aktuální. Vyžaduje Wi-Fi — může spotřebovat velké množství dat. @@ -1626,8 +1665,12 @@ žádné události Průzkumník Bitcoin (OTS) události + události od vás + události pro vás + vyhledatelné události DMs profily + odchozí seznamy nastavení relé Naposledy viděn před %1$s <%1$s @@ -1675,4 +1718,111 @@ Vždy Poslední synchronizace: %1$s Od poslední synchronizace + Webové záložky + Zatím žádné webové záložky. Klepněte na + pro přidání. + Přidat webovou záložku + Upravit webovou záložku + URL + https://example.com + Název + Název záložky + Popis + Krátký popis + Tagy (oddělené čárkou) + nostr, tech, blog + Uložit + Smazat + Smazat tuto webovou záložku? + Otevřít URL + + Tento cíl byl ukončen + %1$s financováno z %2$s sats cíle + Cílová částka (sats) + 100000 + Popište svůj cíl + Na co sbíráte prostředky? + Krátký souhrn + Stručný popis zobrazený v náhledech + URL obrázku (volitelné) + https://example.com/image.jpg + URL webu (volitelné) + https://example.com + Termín (volitelný) + Nastavit termín + Nový cíl + Vytvořit cíl + Žádost o zmizení + Požádejte relé o trvalé smazání všech vašich dat do vybraného data. Tato akce je založena na NIP-62 a v některých jurisdikcích je právně závazná. + Vybrat relé + Cílové relé + VŠECHNA RELÉ + Toto požádá VŠECHNA relé o smazání všeho spojeného s vaším klíčem do vybraného data. Tato událost bude vysílána co nejšířeji. Tuto akci nelze vrátit zpět. + Smazat data do + Všechny vaše události vytvořené před tímto datem budou požadovány ke smazání z vybraného relé. + Důvod (volitelný) + Důvod nebo právní oznámení pro provozovatele relé + Odeslat žádost o zmizení + Potvrdit žádost o zmizení + Chystáte se požádat %1$s o trvalé smazání všech vašich dat vytvořených před vybraným datem. Tuto akci nelze vrátit zpět. + Chystáte se požádat KAŽDÉ relé o trvalé smazání všech vašich dat vytvořených před vybraným datem. Bude to vysíláno všude a nelze to vrátit zpět. + Žádost o zmizení odeslána + Vybrat datum + Vybrat čas + Historie zmizení + Obnovit + Toto jsou vaše minulé události Žádost o zmizení nalezené na připojených relé. Relé označená v těchto událostech by neměla uchovávat žádná vaše data z doby před datem události. + Nebyly nalezeny žádné žádosti o zmizení + Zatím jste neodeslali žádné události Žádost o zmizení. + Cílové relé + Tento požadavek cílí na všechna relé. Použijte obrazovku Žádost o zmizení k otestování konkrétních relé na shodu. + Testovat + Vyhovující + Nevyhovující + Chyba + Historie zmizení + + Spravovat %1$s + Načítání schopností správy relé… + Nelze se připojit ke správě relé + Nejsou dostupné žádné metody správy + Zavřít + Uživatelé + Události + Druhy + IP adresy + Nastavení + Zakázaní uživatelé + Žádní zakázaní uživatelé + Povolení uživatelé + Žádní povolení uživatelé + Fronta moderování + Žádné události k moderování + Zakázané události + Žádné zakázané události + Povolené druhy + Žádné povolené druhy + Blokované IP adresy + Žádné blokované IP adresy + Přidat + Odebrat + Povolit + Zakázat + Zakázat pubkey + Povolit pubkey + Zakázat událost + Povolit druh + Blokovat IP + Veřejný klíč (hex) + ID události (hex) + Číslo druhu + IP adresa + Důvod (volitelný) + Potvrdit + Zrušit + Použít + Název relé + Popis relé + URL ikony relé + Spravovat relé + Spravovat diff --git a/amethyst/src/main/res/values-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-da-rDK/strings.xml b/amethyst/src/main/res/values-da-rDK/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-da-rDK/strings.xml +++ b/amethyst/src/main/res/values-da-rDK/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index fcf5a470e..693a7a64f 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -61,6 +61,8 @@ Der Signierer hat die erforderliche Entschlüsselung nicht autorisiert. Aktiviere NIP-44-Entschlüsselung in deiner Signierer-App und versuche es erneut Signierer nicht gefunden Wurde die Signierer-App deinstalliert? Überprüfe, ob sie installiert ist und dieses Konto enthält. Melde dich ab und wieder an, falls sich die App geändert hat. + Signierer hat sich unerwartet verhalten + Externer Signierer hat Daten zurückgegeben, die für die Anfrage ungewöhnlich sind. Es könnte ein Fehler in Amethyst oder im Signierer vorliegen. Zaps Aufrufe Boost @@ -383,12 +385,16 @@ anz der Bedingungen ist erforderlich Standard-Lesezeichen Deine Standard-Lesezeichen, die viele Clients unterstützen Entwürfe + Umfragen Private Lesezeichen Öffentliche Lesezeichen Zu den privaten Lesezeichen hinzufügen Zu den öffentlichen Lesezeichen hinzufügen Aus den privaten Lesezeichen entfernen Aus den öffentlichen Lesezeichen entfernen + Angeheftete Notizen + An Profil anheften + Von Profil lösen Lesezeichenlisten Symbol für Lesezeichenliste Neue Lesezeichenliste @@ -452,6 +458,8 @@ anz der Bedingungen ist erforderlich Maximaler Zap-Betrag Konsens (0–100)% + Einzelauswahl + Mehrfachauswahl Schließdatum und -zeit Umfrage endet in %1$s Schließen nach @@ -491,6 +499,9 @@ anz der Bedingungen ist erforderlich Empfänger und die Öffentlichkeit wissen nicht, wer die Zahlung gesendet hat Keine Zap Keine Spur in Nostr, nur in Lightning + Anonym + Als neue Wegwerfidentität posten. Dein Konto wird nicht mit dieser Antwort verknüpft. + Diese Antwort wird von einer neuen anonymen Identität veröffentlicht Dateiserver Wählen Sie einen Server zum Hochladen dieser Datei LnAddress oder @Benutzer @@ -713,6 +724,17 @@ anz der Bedingungen ist erforderlich Allgemeine Geschäftsbedingungen N/A Fehler und Hinweise von diesem Relais + Relay-Überwachungsberichte + Öffnen + Lesen + Schreiben + RTT + Netzwerk + Typ + Unterstützte NIPs + Anforderungen + Letzte Prüfung + %1$d ms Aktive Abonnements Ausstehende Outbox-Ereignisse REQ-Abonnements (%1$d) @@ -1329,6 +1351,7 @@ anz der Bedingungen ist erforderlich Hash-Tags Gemeinschaften Listen + Relais Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1565,6 +1588,7 @@ anz der Bedingungen ist erforderlich Shorts Sprachnachricht Sprachantwort + Web-Lesezeichen Wiki Starte mit einem großartigen Feed, indem du dieselben Personen folgst wie jemand, dem du vertraust. Folgeliste importieren @@ -1593,6 +1617,21 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen + Verbindung testen + Teste Server… + Verbunden + Fehlgeschlagen + Testergebnisse + Diagnose + Letzter Test + Geräteinformationen + TLS-Informationen + Noch kein Test durchgeführt + %dms + Serverzertifikat vertrauen? + Der Server %1$s hat ein Zertifikat vorgelegt, das noch nicht in deinem Vertrauensspeicher ist. Überprüfe, ob der Fingerabdruck unten mit dem übereinstimmt, was der Serverbetreiber veröffentlicht hat, und wähle dann, ob du ihm für zukünftige Verbindungen vertrauen möchtest. + Vertrauen + Ablehnen 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. @@ -1631,8 +1670,12 @@ anz der Bedingungen ist erforderlich keine Ereignisse Bitcoin Explorer (OTS) ereignisse + Ereignisse von dir + Ereignisse an dich + durchsuchbare Ereignisse DMs profile + Ausgangs-Listen relaiseinstellungen Zuletzt gesehen vor %1$s <%1$s @@ -1680,4 +1723,111 @@ anz der Bedingungen ist erforderlich Gesamter Zeitraum Letzte Synchronisierung: %1$s Seit letzter Synchronisierung + Web-Lesezeichen + Noch keine Web-Lesezeichen. Tippe auf + um eines hinzuzufügen. + Web-Lesezeichen hinzufügen + Web-Lesezeichen bearbeiten + URL + https://beispiel.com + Titel + Lesezeichen-Titel + Beschreibung + Eine kurze Beschreibung + Tags (kommagetrennt) + nostr, tech, blog + Speichern + Löschen + Dieses Web-Lesezeichen löschen? + URL öffnen + + Dieses Ziel wurde geschlossen + %1$s finanziert von %2$s Sats Ziel + Zielbetrag (Sats) + 100000 + Beschreibe dein Ziel + Wofür sammelst du Geld? + Kurze Zusammenfassung + Kurze Beschreibung für Vorschauen + Bild-URL (optional) + https://example.com/image.jpg + Website-URL (optional) + https://beispiel.com + Frist (optional) + Frist festlegen + Neues Ziel + Ziel erstellen + Löschanfrage + Fordere Relays auf, alle deine Daten bis zum gewählten Datum dauerhaft zu löschen. Diese Aktion basiert auf NIP-62 und ist in einigen Rechtsgebieten rechtlich bindend. + Relay auswählen + Ziel-Relays + ALLE RELAYS + Dies wird ALLE Relays auffordern, alles zu löschen, was mit deinem Schlüssel bis zum gewählten Datum verbunden ist. Dieses Ereignis wird so breit wie möglich gesendet. Diese Aktion kann nicht rückgängig gemacht werden. + Daten löschen bis + Alle deine Ereignisse, die vor diesem Datum erstellt wurden, werden zur Löschung vom ausgewählten Relay angefordert. + Grund (optional) + Grund oder rechtlicher Hinweis für den Relay-Betreiber + Löschanfrage senden + Löschanfrage bestätigen + Du bist dabei, %1$s aufzufordern, alle deine Daten, die vor dem gewählten Datum erstellt wurden, dauerhaft zu löschen. Diese Aktion kann nicht rückgängig gemacht werden. + Du bist dabei, JEDES Relay aufzufordern, alle deine Daten, die vor dem gewählten Datum erstellt wurden, dauerhaft zu löschen. Dies wird überall gesendet und kann nicht rückgängig gemacht werden. + Löschanfrage gesendet + Datum wählen + Uhrzeit wählen + Löschverlauf + Aktualisieren + Dies sind deine vergangenen Löschanfrage-Ereignisse, die auf verbundenen Relays gefunden wurden. Relays, die in diesen Ereignissen markiert sind, sollten keine deiner Daten von vor dem Ereignisdatum mehr speichern. + Keine Löschanfragen gefunden + Du hast noch keine Löschanfrage-Ereignisse gesendet. + Ziel-Relays + Diese Anfrage richtet sich an alle Relays. Verwende den Löschanfrage-Bildschirm, um einzelne Relays auf Konformität zu testen. + Testen + Konform + Nicht konform + Fehler + Löschverlauf + + %1$s verwalten + Lade Relay-Verwaltungsfunktionen… + Verbindung zur Relay-Verwaltung nicht möglich + Keine Verwaltungsmethoden verfügbar + Schließen + Benutzer + Ereignisse + Arten + IPs + Einstellungen + Gesperrte Benutzer + Keine gesperrten Benutzer + Erlaubte Benutzer + Keine erlaubten Benutzer + Moderationswarteschlange + Keine Ereignisse zur Moderation + Gesperrte Ereignisse + Keine gesperrten Ereignisse + Erlaubte Arten + Keine erlaubten Arten + Blockierte IPs + Keine blockierten IPs + Hinzufügen + Entfernen + Erlauben + Sperren + Pubkey sperren + Pubkey erlauben + Ereignis sperren + Art erlauben + IP blockieren + Öffentlicher Schlüssel (hex) + Ereignis-ID (hex) + Art-Nummer + IP-Adresse + Grund (optional) + Bestätigen + Abbrechen + Anwenden + Relay-Name + Relay-Beschreibung + Relay-Symbol-URL + Relay verwalten + Verwalten diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index ebe34bf4e..55b6acf4b 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -516,4 +516,6 @@ + + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index fec8238fe..b2c1c24b5 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -9,4 +9,6 @@ 👀 + + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 1719ad9c9..e6a98cbdc 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -444,4 +444,6 @@ Okej + + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index ff871b996..cf90e2b99 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -1184,4 +1184,6 @@ 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 8a6186601..6c436e4f1 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -1160,4 +1160,6 @@ 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 817fc89e8..96d0bc4d4 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -1161,4 +1161,6 @@ 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 b7918beed..7bfe7b79e 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -949,4 +949,6 @@ پیام خصوصی + + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 1e71d96df..f21586e05 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -571,4 +571,6 @@ 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 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 378c19b9f..dd7c15754 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -1439,4 +1439,6 @@ 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 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index d87351af4..c55bc900c 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1664,4 +1664,6 @@ प्रकारों के सत्यापन में निपुण : %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 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 3d7172fb0..6abe741fd 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -61,6 +61,8 @@ 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. + Az aláíró hibásan működött + A külső aláíró a kéréstől eltérő, szokatlan választ küldött. Lehet, hogy hiba lépett fel az Amethystben vagy az aláíró alkalmazásban. Zap-ek Megtekintések száma Megtolás @@ -241,7 +243,7 @@ Már van Nostr-fiókja? Új fiók létrehozása Új kulcs előállítása - Hírfolyam betöltése… + Hírfolyam betöltése Fiók betöltése… "Hiba a válaszok betöltésekor: " Próbálja újra @@ -379,12 +381,16 @@ Alapértelmezett könyvjelzők Az alapértelmezett könyvjelzők, amelyeket sok kliens támogat Piszkozatok + Szavazások Privát könyvjelzők Nyilvános könyvjelzők Hozzáadás a privát könyvjelzőkhöz Hozzáadás a nyilvános könyvjelzőkhöz Törlés a privát könyvjelzőkből Törlés a nyilvános könyvjelzőkből + Rögzített bejegyzések + Rögzítés a profilhoz + Rögzítés megszüntetése a profiltól Könyvjelzőlisták Könyvjelzőlista ikonja Új könyvjelzőlista @@ -395,7 +401,7 @@ Bejegyzések megtekintése Cikkek megtekintése Hivakozások megtekintése - Hashtagek megtekintése + Kulcsszavak megtekintése Még nincs egyetlen könyvjelzőlistája sem. Koppintson az „Új” gombra, hogy létrehozzon egyet. Privát bejegyzések Privát bejegyzések (%1$s) @@ -489,6 +495,9 @@ A kedvezményezett és a nyilvánosság nem tudja, hogy ki küldte a fizetést Nem Zap Nostr-ban nyoma sincs, csak a Lightning-ban + Névtelen + Közzététel új, eldobható profillal. Az Ön saját fiókja nem lesz köthető ehhez a válaszhoz. + Ez a válasz egy új, névtelen profilból lesz közzétéve Fájlkiszolgáló Válasszon ki egy kiszolgálót a fájl feltöltéséhez neki: Ln-cím vagy @Felhasználó @@ -522,8 +531,8 @@ Igen Nem Követési lista - Követettek bejegyzései - Összes követett felhasználó + Minden ami követett + Minden követett felhasználó Alapértelmezett követési lista Követés proxyn keresztül Közelben lévők bejegyzései @@ -714,6 +723,19 @@ Általános szerződési feltételek Nem érhető el Hibák és megjegyzések ettől az átjátszótól + Átjátszófigyelési jelentések + Megnyitás + Olvasás + Írás + RTT (válaszidő) + Hálózat + Típus + Támogatott NIP-ek + Követelmények + Utoljára ellenőrizve + %1$d ms + Elfogadott típusok + Helyszín Aktív előfizetések Függőben lévő kimenő események Szükséges előfizetések (%1$d) @@ -838,8 +860,8 @@ Hozzáadja a helyszínének geokivonatát a bejegyzéséhez. A nyilvánosság tudni fogja, hogy a jelenlegi helytől 5 km-en (3 mi) belül tertózkodik Helyszín-alapú bejegyzés Csak a helyszín követői láthatják. Az általános követők nem fogják látni. - Hashtag-exkluzív bejegyzés - Csak a hashtag követői fogják látni, de az Ön általános követői viszont nem. + Kulcsszó-exkluzív bejegyzés + Csak a kulcsszó követői fogják látni, de az Ön általános követői viszont nem. Helyszín betöltése… A helyszín-meghatározás nincs engedélyezve Hozzáadja az érzékeny tartalomra vonatkozó figyelmeztetést a tartalom megjelenítése előtt. Ez ideális bármilyen NSFW tartalom vagy olyan tartalom esetén, amelyet egyesek sértőnek vagy zavarónak találhatnak @@ -1183,7 +1205,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 @@ -1328,9 +1350,10 @@ Felhasználók Szempont kiválasztása a hírfolyam szűréséhez Hírfolyamok - Hashtagek + Kulcsszavak Közösségek Listák + Átjátszók Kijelentkeztetés az eszköz zárolása esetén Privát üzenet Nyílvános üzenet @@ -1341,7 +1364,7 @@ Videó megosztása… Nem sikerült megosztani a videót, próbálja meg újra később… Videó letöltése… - Hashtag keresése: #%1$s + Kulcsszó keresése: #%1$s Innentől NE fordítsa le Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet. Fordítás erre: @@ -1454,7 +1477,7 @@ Blossom-hitelesítés Közvetítési átjátszók Könyvjelzőlista - Napi bejegyzés + Napi időpont Naptár Találkozó Időpont-visszaigazolás @@ -1502,7 +1525,7 @@ Git tároló Git válasz Zap-célok - Hashtag-követések + Kulcsszó-követések Kiemelések Http-hitelesítés Indexelő átjátszók listája @@ -1567,13 +1590,14 @@ Rövidek Hangüzenet Hangos válasz + Webes könyvjelző Wiki Kezdje egy remek hírfolyammal, követve azokat az embereket, akikben megbízik. Követési lista importálása Felhasználók kiválasztása a követéshez Importálandó profil keresés, npub1…, aliz@pelda.hu - Támogatja az npub, nprofile, NIP-05, hex és namecoin (.bit, d/, id/) formátumokat + Támogatja az npub, nprofile, NIP-05, hex és Namecoin (.bit) formátumokat Követési lista keresése Borravaló %1$d felhasználói fiók megtalálva @@ -1595,6 +1619,21 @@ Összes kijelölése Üzemidő: %1$d%% Namecoin-beállítások + Kapcsolat tesztelése + Kiszolgálók tesztelése… + Kapcsolódva + Sikertelen + Teszteredmények + Diagnosztika + Utoljára tesztelve + Eszközinformáció + TLS-információ + Még nem volt futtatva a tesztelés + %d ms + Megbízik a kiszolgáló tanúsítványában? + A(z) %1$s kiszolgáló olyan tanúsítványt mutatott be, amely még nem szerepel az Ön megbízható tanúsítványai között. Ellenőrizze, hogy az alábbi ujjlenyomat megegyezik-e a szerver üzemeltetője által közzétett adattal, majd döntse el, megbízik-e benne a jövőbeni kapcsolódások során. + Elfogadás + Elutasítás Á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. @@ -1633,8 +1672,12 @@ nincsenek események Bitcoin felfedező (OTS) események + Öntől származó események + Önnek szóló események + kereshető események Közvetlen üzenetek profilok + kimenő átjátszók listája átjátszóbeállítások Utoljára %1$s ezelőtt látták <%1$s @@ -1682,4 +1725,111 @@ Összes Utoljára szinkronizálva: %1$s Utolsó szinkronizálás óta + Webes könyvjelzők + Még nincsenek webes könyvjelzők. Koppintson a „+” gombra a hozzáadáshoz. + Webes könyvjelző hozzáadása + Webes könyvjelző szerkesztése + Webcím + https://pelda.hu + Cím + Könyvjelző neve + Leírás + Egy rövid leírás + Címkék (vesszővel elválasztva) + nostr, tech, blog + Mentés + Törlés + Törli ezt a webes könyvjelzőt? + Webcím megnyitása + + Ez a gyűjtési cél lezárult + %1$s gyűlt össze a(z) %2$s satoshis célból + Célösszeg (satoshiban) + 100 000 + Cél leírása, bemutatása + Mire gyűjt adományokat? + Rövid összefoglalás + Rövid leírás, amely megjelenik az előnézetekben + Kép webcíme (nem kötelező) + https://pelda.hu/kep.jpg + Weboldal webcíme (nem kötelező) + https://pelda.hu + Határidő (nem kötelező) + Határidő beállítása + Új cél + Cél létrehozása + Törlési kérés + Kérés az átjátszóknak, hogy a kiválasztott dátumig visszamenőleg véglegesen töröljék az Ön összes adatát. Ez a művelet a NIP-62 szabványon alapul, és egyes joghatóságokban jogilag kötelező érvényű. + Egy átjátszó kiválasztása + Célátjátszók + Összes átjátszó + Ezzel az ÖSSZES átjátszót arra kéri, hogy a kiválasztott dátumig töröljön mindent, ami az Ön kulcsához kapcsolódik. Ezt az eseményt a lehető legszélesebb körben közzétesszük. Ez a művelet nem vonható vissza. + Adatok törlése eddig: + Az ezen időpont előtt létrehozott összes esemény törlését kérelmezi a kiválasztott átjátszótól. + Indoklás (nem kötelező) + Indoklás vagy jogi nyilatkozat az átjátszó üzemeltetőjének + Törlési kérés küldése + Törlési kérés megerősítése + Ön arra kéri a(z) %1$s átjátszót, hogy a kiválasztott dátum előtt létrehozott összes adatát végleges törölje. Ez a művelet nem vonható vissza. + Ön arra kéri az ÖSSZES átjátszót, hogy a kiválasztott dátum előtt létrehozott összes adatát végleges törölje. Ez a művelet közvetítve lesz mindenhová és nem vonható vissza. + Törlési kérés elküldve + Dátum kiválasztása + Idő kiválasztása + Törlési előzmények + Frissítés + Ezek az Ön korábbi, a kapcsolódott átjátszókon talált törlési kérései. Az ezekben az eseményekben megjelölt átjátszók nem tárolhatják az Ön adatait az esemény dátuma előtti időszakból. + Nem találhatók törlési kérések + Ön még nem küldött törlési kérést. + Célátjátszók + Ez a kérés érinti az összes átjátszót. Használja a törlési kérés menüjét az egyes átjátszók megfelelőségének teszteléséhez. + Tesztelés + Megfelelő + Nem megfelelő + Hiba + Törlési előzmények + + %1$s kezelése + Átjátszókezelési funkciók betöltése… + Nem sikerült kapcsolódni az átjátszókezeléshez + Nem érhető el kezelési eljárás + Elvetés + Felhasználók + Események + Típusok + IP-címek + Beállítások + Letiltott felhasználók + Nincsenek letiltott felhasználók + Engedélyezett felhasználók + Nincsenek engedélyezett felhasználók + Moderációs várólista + Nincsenek moderálandó események + Letiltott események + Nincsenek letiltott események + Engedélyezett típusok + Nincsenek engedélyezett típusok + Letiltott IP-címek + Nincsenek letiltott IP-címek + Hozzáadás + Eltávolítás + Engedélyezés + Letiltás + Nyilvános kulcs letiltása + Nyilvános kulcs engedélyezése + Esemény letiltása + Típus engedélyezése + IP-cím letiltása + Nyilvános kulcs (hex) + Eseményazonosító (hex) + Típus száma + IP-cím + Indoklás (nem kötelező) + Megerősítés + Mégse + Alkamaz + Átjátszó neve + Átjátszó leírása + Átjátszó ikonjának webcíme + Átjátszó kezelése + Kezelés diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 4e5b60c38..18f4dcda4 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -513,4 +513,6 @@ Seharusnya %3$s + + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 01cc929ad..10aad47e9 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -434,4 +434,6 @@ Cerca + + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 3fd134573..269746856 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -391,4 +391,6 @@ 画像読み込み + + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 5d0dd475a..cc88235ce 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -173,4 +173,6 @@ 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 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 3f51aeb8f..31fbfe598 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -1146,4 +1146,6 @@ 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 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e0790763f..352cd7cc5 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -61,10 +61,12 @@ Sygnatariusz nie autoryzował deszyfrowania wymaganego do wykonania tej operacji. Aktywuj deszyfrowanie NIP-44 w aplikacji logującej i spróbuj ponownie Nie odnaleziono sygnatariusza Czy aplikacja sygnatariusza została odinstalowana? Sprawdź, czy aplikacja sygnatariusza jest zainstalowana i czy ma to konto. Wyloguj się i zaloguj ponownie, jeśli aplikacja sygnatariusza uległa zmianie. + Sygnatariusz działał nieprawidłowo + Sygnatariusz zewnętrzny zwrócił dane, które są niezgodne z żądaniem. Być może wystąpił błąd w Amethyst lub w samym sygnatariuszu. Zapy Liczba wyświetleń Powtórz - powtórzono + wpis powtórzono edytowano edytuj #%1$s oryginalny @@ -376,12 +378,16 @@ Domyślne zakładki Twoje domyślne zakładki obsługiwane przez większość klientów Projekty + Ankiety Prywatne Zakładki Publiczne zakładki Dodaj do prywatnych zakładek Dodaj do publicznych zakładek Usuń z prywatnych zakładek Usuń z publicznych zakładek + Przypięte notatki + Przypnij do profilu + Odepnij z profilu Listy zakładek Ikona listy zakładek Nowa lista zakładek @@ -486,6 +492,9 @@ Odbiorca i użytkownicy nie wiedzą, kto wysłał płatność Bez Zapów Brak śladu w Nostr, tylko w Lightning + Anonimowo + Opublikuj jako nowa, jednorazowa tożsamość. Twoje konto nie zostanie powiązane z tą odpowiedzią. + Ta odpowiedź zostanie wysłana z nowej anonimowej tożsamości Serwer Plików Wybierz serwer, aby przesłać ten plik LnAdres lub @Użytkownik @@ -519,8 +528,8 @@ Tak Nie Lista obserwowanych - Obserwowane - Wszystkie obserwowane + Wszystkie obserwacje + Obserwowane osoby Domyślna lista obserwowanych Obserwowani przez proxy W pobliżu @@ -711,6 +720,19 @@ Zasady i warunki użytkowania Nie dotyczy Błędy i powiadomienia z tego transmitera + Raporty z monitoringu transmitera + Otwórz + Odczyt + Zapis + RTT + Sieć + Typ + Obsługiwane Nipy + Wymagania + Ostatnie sprawdzenie + %1$d ms + Dopuszczalne typy + Lokalizacja Aktywne subskrypcje Wydarzenia oczekujące na wysłanie REQ Subskrypcje (%1$d) @@ -1328,6 +1350,7 @@ Hashtagi Społeczności Listy + Transmitery Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość @@ -1498,7 +1521,7 @@ Repozytorium Git Odpowiedź Git Cele Zap-a - Obserwacja hashtagów + Obserwowane hashtagi Najważniejsze informacje Autoryzacja http Indeks listy transmiterów @@ -1563,6 +1586,7 @@ Filmiki Wiadomość głosowa Odpowiedź głosowa + Zakładka Wiki Stwórz świetny kanał, obserwując te same osoby, które obserwuje zaufana osoba. Importuj listę obserwowanych @@ -1591,6 +1615,20 @@ Wybierz Wszystkie Czas działania %1$d%% Ustawienia Namecoin + Test połączenia + Testowanie serwerów… + Podłączony + Wyniki testu + Diagnostyka + Ostatni test + Informacje o urządzeniu + Informacje o TLS + Nie wykonano jeszcze testu + %dms + Zaufać certyfikatowi serwera? + Serwer %1$s przedstawił certyfikat, który nie znajduje się jeszcze w Twoim magazynie certyfikatów zaufanych. Sprawdź, czy poniższy Fingerprint odpowiada wartości opublikowanej przez operatora serwera, a następnie zdecyduj, czy chcesz zaufać temu certyfikatowi w przypadku przyszłych połączeń. + Zaufaj + Odrzuć 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. @@ -1629,8 +1667,11 @@ brak wydarzeń Eksplorator Bitcoin (OTS) wydarzeń + wydarzenia od Ciebie + wydarzenia dla Ciebie DMs profile + lista transmiterów wysyłających ustawienia transmiterów Ostatnio widziano %1$s temu <%1$s @@ -1678,4 +1719,128 @@ Cały czas Ostatnia synchronizacja %1$s Od ostatniej synchronizacji + Zakładki + Brak zakładek. Naciśnij + aby dodać nową zakładkę. + Dodaj zakładkę + Edytuj zakładkę + Adres URL + https://domena.pl + Tytuł + Tytuł zakładki + Opis + Krótki opis + Tagi (oddzielone przecinkami) + nostr, technika, blog + Zapisz + Usuń + Usunąć tę zakładkę? + Otwórz adres URL + + Ta zbiórka została zamknięta + Sfinansowano %1$s z %2$s satoszów + Kwota zbiorki (w satoszach) + 100000 + Opisz cel zbiórki + Na co zbierasz fundusze? + Krótkie podsumowanie + Krótki opis wyświetlany w podglądzie + Adres URL zdjęcia (opcjonalnie) + https://domena.pl/img.jpg + Adres strony (opcjonalnie) + https://domena.pl + Termin ukończenia (opcjonalnie) + Ustaw ostateczny termin + Nowy cel zbiórki + Utwórz zbiórkę + Prośba o usunięcie + Poproś transmitery o całkowite usunięcie wszystkich Twoich danych z okresu do wybranej daty. Działanie to opiera się na standardzie NIP-62 i w niektórych jurysdykcjach ma moc prawną. + Wybierz transmiter + Transmitery docelowe + WSZYSTKIE TRANSMITERY + Spowoduje to wysłanie żądania do WSZYSTKICH przekaźników o usunięcie wszystkich danych powiązanych z Twoim kluczem do wybranej daty. Informacja ta zostanie rozpowszechniona tak szeroko, jak to tylko możliwe. Czynności tej nie można cofnąć. + Usuń dane aż do + Wszystkie wydarzenia utworzone przed tą datą zostaną zgłoszone do usunięcia z wybranego transmitera. + Powód (Opcjonalne) + Uzasadnienie lub informacja prawna dla operatora transmitera + Wyślij prośbę o usunięcie + Potwierdź żądanie usunięcia + Zamierzasz poprosić %1$s o trwałe usunięcie wszystkich danych utworzonych przed wybraną datą. Czynności tej nie można cofnąć. + Za chwilę wyślesz żądanie do KAŻDEGO transmitera, aby trwale usunął wszystkie Twoje dane utworzone przed wybraną datą. Komunikat ten zostanie rozesłany wszędzie i nie będzie można go cofnąć. + Wysłano prośbę o usunięcie + Wybierz datę + Wybierz godzinę + Wyczyść historię + Odśwież + Oto Twoje wcześniejsze prośby o usunięcie danych znalezione na podłączonych transmiterach. Transmitery oznaczone w tych wydarzeniach nie powinny przechowywać żadnych Twoich danych sprzed daty wydarzenia. + Nie znaleziono wniosków o usunięcie + Nie wysłano jeszcze żadnych żądań do usunięcia wydarzeń. + Transmitery docelowe + To żądanie dotyczy wszystkich transmiterów. Aby sprawdzić zgodność poszczególnych transmiterów, skorzystaj z opcji \"Prośba o usunięcie\". + Test + Zgodny + Niezgodny + Błąd + Wyczyść historię + + Zarządzaj %1$s + Wczytywanie funkcji zarządzania transmiterem… + Nie można połączyć się z panelem zarządzania transmiterem + Brak dostępnych metod zarządzania + Ignoruj + Użytkownicy + Wydarzenia + Typy + Adresy IP + Ustawienia + Zbanowani użytkownicy + Brak zbanowanych użytkowników + Uprawnieni użytkownicy + Brak uprawnionych użytkowników + Kolejka do moderacji + Brak wydarzeń wymagających moderacji + Zbanowane wydarzenia + Brak zbanowanych wydarzeń + Dozwolone typy + Brak dozwolonych typów + Zablokowane adresy IP + Brak zablokowanych adresów IP + Dodaj + Usuń + Zezwól + Banuj + Banuj klucz publiczny + Aprobuj klucz publiczny + Zbanuj wydarzenie + Aprobuj typ + Blokuj IP + Klucz publiczny (hex) + ID wydarzenia (hex) + Numer typu + Adres IP + Powód (Opcjonalne) + Potwierdź + Anuluj + Zastosuj + Nazwa transmitera + Opis transmitera + Adres ikony transmitera + Zarządzaj Transmiterem + Zarządzaj + Członkowie + Członkowie %1$s + Członków: %1$d + Wczytywanie członków… + Nie znaleziono członków + Poproś o dołączenie + Żądanie opuszczenia + Wysłano prośbę o dołączenie + Wysłano prośbę o opuszczenie + Jesteś członkiem + Lista użytkowników transmitera + Użytkownik dodany do transmitera + %1$d użytkowników dodanych do transmitera + Użytkownik usunięty z transmitera + %1$d użytkowników usuniętych z transmitera + Prośba o dołączenie do transmitera + Żądanie opuszczenia transmitera diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index e22277af2..61625cac7 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -61,6 +61,8 @@ O assinador não autorizou a descriptografia necessária para realizar esta operação. Ative as descriptografias NIP-44 no seu aplicativo de assinatura e tente novamente Assinador não encontrado O aplicativo de assinatura foi desinstalado? Verifique se ele está instalado e com esta conta. Saia e entre novamente se ele foi alterado. + Assinador se comportou de forma inesperada + O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador. Zaps Contagem de visualizações Impulsionar @@ -377,12 +379,16 @@ Marcadores padrão Seus marcadores padrão que muitos clientes suportam Rascunhos + Enquetes Itens Salvos Privados Itens Salvos Públicos Adicionar aos Itens Salvos Privados Adicionar aos Itens Salvos Públicos Remover dos Itens Salvos Privados Remover dos Itens Salvos Públicos + Notas Fixadas + Fixar no Perfil + Desafixar do Perfil Listas de favoritos Ícone da lista de favoritos Nova lista de favoritos @@ -446,6 +452,8 @@ Zap máximo Consenso (0–100)% + Escolha única + Múltipla escolha Data e hora de encerramento da enquete Enquete encerra em %1$s Fechar depois @@ -485,6 +493,9 @@ Destinatário e o público não sabem quem enviou o pagamento Sem Zap Nenhum traço no Nostr, apenas na Lightning + Anônimo + Postar como uma nova identidade descartável. Sua conta não será vinculada a esta resposta. + Esta resposta será postada a partir de uma nova identidade anônima Servidor de arquivos Escolha um servidor para onde enviar este arquivo LnAddress ou @Usuário @@ -708,6 +719,17 @@ Termos & condições N/A Erros e Avisos deste Relé + Relatórios do Monitor de Relay + Abrir + Leitura + Escrita + RTT + Rede + Tipo + NIPs Suportados + Requisitos + Última verificação + %1$d ms Assinaturas Ativas Eventos Pendentes na Outbox Assinaturas REQ (%1$d) @@ -1324,6 +1346,7 @@ Hashtags Comunidades Listas + Relés Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1560,6 +1583,7 @@ Shorts Mensagem de voz Resposta de voz + Marcador Web Wiki Comece com um ótimo feed seguindo as mesmas pessoas que alguém em quem você confia. Importar lista de seguidos @@ -1588,6 +1612,21 @@ Selecionar tudo %1$d%% de disponibilidade Configurações do Namecoin + Testar Conexão + Testando servidores… + Conectado + Falhou + Resultados do Teste + Diagnóstico + Último teste + Informações do Dispositivo + Informações TLS + Nenhum teste realizado ainda + %dms + Confiar no Certificado do Servidor? + O servidor %1$s apresentou um certificado que ainda não está no seu armazenamento de confiança. Verifique se a impressão digital abaixo corresponde ao que o operador do servidor publicou e escolha se deseja confiar nele para conexões futuras. + Confiar + Rejeitar 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. @@ -1626,8 +1665,12 @@ nenhum evento Explorador Bitcoin (OTS) eventos + eventos de você + eventos para você + eventos pesquisáveis DMs perfils + listas de saída configurações de Relay Visto pela última vez há %1$s <%1$s @@ -1675,4 +1718,111 @@ Todo o período Última sincronização: %1$s Desde a última sincronização + Marcadores Web + Nenhum marcador web ainda. Toque em + para adicionar. + Adicionar Marcador Web + Editar Marcador Web + URL + https://exemplo.com + Título + Título do marcador + Descrição + Uma breve descrição + Tags (separadas por vírgula) + nostr, tech, blog + Salvar + Excluir + Excluir este marcador web? + Abrir URL + + Esta meta foi encerrada + %1$s financiado de %2$s sats da meta + Valor da meta (sats) + 100000 + Descreva sua meta + Para que você está arrecadando fundos? + Resumo curto + Breve descrição mostrada nas prévias + URL da imagem (opcional) + https://exemplo.com.br/imagem.jpg + URL do site (opcional) + https://exemplo.com + Prazo (opcional) + Definir um prazo + Nova Meta + Criar Meta + Pedido para Desaparecer + Solicite aos relays que excluam permanentemente todos os seus dados até a data selecionada. Esta ação é baseada no NIP-62 e é legalmente vinculante em algumas jurisdições. + Selecionar um relay + Relays Alvo + TODOS OS RELAYS + Isso solicitará a TODOS os relays que excluam tudo associado à sua chave até a data selecionada. Este evento será transmitido o mais amplamente possível. Esta ação não pode ser desfeita. + Excluir dados até + Todos os seus eventos criados antes desta data serão solicitados para exclusão do relay selecionado. + Motivo (opcional) + Motivo ou aviso legal para o operador do relay + Enviar Pedido para Desaparecer + Confirmar Pedido para Desaparecer + Você está prestes a solicitar a %1$s que exclua permanentemente todos os seus dados criados antes da data selecionada. Esta ação não pode ser desfeita. + Você está prestes a solicitar a TODOS os relays que excluam permanentemente todos os seus dados criados antes da data selecionada. Isso será transmitido em todos os lugares e não pode ser desfeito. + Pedido para desaparecer enviado + Selecionar data + Selecionar hora + Histórico de Desaparecimento + Atualizar + Estes são seus eventos passados de Pedido para Desaparecer encontrados nos relays conectados. Relays marcados nesses eventos não devem manter nenhum dos seus dados anteriores à data do evento. + Nenhum pedido de desaparecimento encontrado + Você ainda não enviou nenhum evento de Pedido para Desaparecer. + Relays Alvo + Este pedido é direcionado a todos os relays. Use a tela Pedido para Desaparecer para testar relays específicos quanto à conformidade. + Testar + Compatível + Não compatível + Erro + Histórico de Desaparecimento + + Gerenciar %1$s + Carregando recursos de gerenciamento do relay… + Não foi possível conectar ao gerenciamento do relay + Nenhum método de gerenciamento disponível + Dispensar + Usuários + Eventos + Tipos + IPs + Configurações + Usuários Banidos + Nenhum usuário banido + Usuários Permitidos + Nenhum usuário permitido + Fila de Moderação + Nenhum evento para moderar + Eventos Banidos + Nenhum evento banido + Tipos Permitidos + Nenhum tipo permitido + IPs Bloqueados + Nenhum IP bloqueado + Adicionar + Remover + Permitir + Banir + Banir Pubkey + Permitir Pubkey + Banir Evento + Permitir Tipo + Bloquear IP + Chave pública (hex) + ID do Evento (hex) + Número do tipo + Endereço IP + Motivo (opcional) + Confirmar + Cancelar + Aplicar + Nome do Relay + Descrição do Relay + URL do Ícone do Relay + Gerenciar Relay + Gerenciar diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 347df81d9..d06dfe9c8 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -449,4 +449,6 @@ Другое + + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index da5af6739..c9b1a3632 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -37,7 +37,7 @@ Zaprosi za izbris Blokiraj / Prijavi - Prijavi nazaželeno vsebino / prevaro + Prijavi nezaželeno vsebino / prevaro Prijavi oponašalca Prijavi eksplicitno vsebino Prijavi nedovoljeno obnašanje @@ -72,10 +72,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Podpisnik ni odobril dešifriranja, ki je potrebno za to operacijo. V aplikaciji za podpisovanje aktivirajte NIP-44 možnost dešifriranja in poskusite znova Ne najdem podpisnika 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. + Napačno delovanje podpisnika + Zunanji podpisnik je vrnil neobičajen odgovor. Morda gre za napako v aplikaciji Amethyst ali v podpisniku. Zapi Števec vpogledov Pošlji naprej - Poslano naprej + poslano naprej posodobljeno uredi #%1$s original @@ -118,12 +120,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nov kanal Ime kanala Moja vrhunska skupina - Url slike - URL slike (Neobvezno) + URL slike + URL slike (izbirno) Opis Ne najdem opisa - "O nas.. " - Kaj imaš v mislih? + "O nas… " + O čem razmišljaš? Napiši sporočilo… Pošlji Shrani @@ -193,7 +195,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Anonimiziraj Prilagodi višino tona svojega glasu: Opomba: osnovne spremembe višine tona lahko poslušalci potencialno razveljavijo. Uporabnik nima nastavljenega \"lightning\" naslova za sprejem satoshi-jev - "odgovori tukaj.. " + "odgovori tukaj… " Kopira ID zapiska v odložišče za deljenje v Nostr Kopiraj ID kanala (zapisek) v odložišče Uredi metapodatke kanala @@ -204,7 +206,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nove objave Razprave Vsebine - Moderatorska vrsta + Moderatorska čakalna vrsta Zapiski Pogovori Vaše @@ -248,7 +250,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vpiši se Ustvari račun Kako naj te kličemo? - Še nimaš Nostr računa? + Še nimate Nostr računa? Že imam Nostr račun? Ustvari nov račun Ustvari nov ključ @@ -285,7 +287,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Moderiranje je možno z brisanjem objav na njihovih relejih Releji Vnesi 1–3 releje, ki gostijo to skupino. - Nostr odjemalci uporabljajo to nastavitev, da vedo, od kod prenesti tvoja sporočila in kam jih poslati. + Nostr odjemalci uporabljajo to nastavitev, da vedo, od kod prenesti vaša sporočila in kam jih poslati. Plačljiv rele Vsili Tor pri povezovanju sprejete objave @@ -301,6 +303,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nostr naslov t. i. Nip-05 nikoli zdaj + sekunde h m d @@ -387,14 +390,18 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ročno razdeli zape Zaznamki Privzeti zaznamki - Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev. + Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev Osnutki + Glasovanja Privatni zaznamki Javni zaznamki Dodaj v privatne zaznamke Dodaj v javne zaznamke Odstrani iz privatnih zaznamkov Odstrani iz javnih zaznamkov + Pripeti zapiski + Pripni k profilu + Odpni od profila Seznam zaznamkov Ikona za seznam zaznamkov Nov seznam zaznamkov @@ -440,8 +447,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem 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 + Prikaže se ob pritisku na gumb za zape. Tapnite znesek, da ga odstranite. Če pustite prazno, se bo ob vsakem zapu odprlo okno za vnos poljubnega zneska. + Zasebnost zapov Določa, kako je prikazana vaša identiteta, ko pošljete zap. Poveži denarnico Pogled v vsebino releja @@ -458,6 +465,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zap maksimum Soglasje (0–100)% + Ena izbira + Več izbire Datum in čas konca glasovanja Anketa se zaključi %1$s Zapri po @@ -497,6 +506,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prejemnik in javnost ne vejo kdo je poslal plačilo Ni-Zap Brez sledi v Nostr, samo v Lightning + Anonimno + Objavite z novo začasno identiteto. Vaš račun ne bo povezan s tem odgovorom. + Ta odgovor bo objavljen z novo anonimno identiteto Datotečni strežnik Izberi strežnik za nalaganje te datoteke LnNaslov ali @Uporabnik @@ -530,14 +542,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Da Ne Seznam sledenih - Vse, čemur sledim + Vsa sledenja Vsi sledeni sledenih Privzeti seznam sledenih Sledi preko posrednika V moji okolici Globalno Šah - Spisek utišanih + Seznam utišanih Seznam sledenih To so seznami sledenih, namenjeni za vašo lastno uporabo. Uporabnike lahko sledite javno ali zasebno Označeni zaznamki @@ -582,7 +594,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nastavi novo ime/opis za kloniran seznam spodaj Ime seznama Novo ime seznama - Opis seznama (neobvezno) + Opis seznama (izbirno) Nov opis seznama Ustvari seznam Kopiraj/kloniraj seznam @@ -682,7 +694,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nov reakcijski simbol Za tega uporabnika niso predhodno izbrane nobene vrste reakcij. Dolgo pritisnite na gumb za srce, da jih spremenite Zapraiser - Doda ciljni znesek v sat, za zbiranje donacij v tej objavi. Podprti Nostr odjemalci lahko ta cilj prikažejo kot vrstico napredka za spodbujanje donacij + Dodaj ciljni znesek v satoshi-jih, za zbiranje donacij v tej objavi. Podprti Nostr odjemalci lahko ta cilj prikažejo kot vrstico napredka za spodbujanje donacij Ciljni znesek v sat Zapraiser na %1$s. %2$s satoshi-jev do cilja Beri iz releja @@ -722,6 +734,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Določila & Pogoji N/A Napake in obvestila s tega releja + Poročila o delovanju relejev + Odprto + Branje + Pisanje + RTT + Omrežje + Vrsta + Podprti NIP-i + Zahteve + Zadnje preverjanje + %1$d ms + Sprejeti tipi (Kinds) + Lokacija Aktivne naročnine Izhodni dogodki v teku REQ Naročnine (%1$d) @@ -753,7 +778,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nadzor dostopa Minimalen PoW Auth - Potrebna avtentikacija + Potrebna je avtentikacija Plačilo Potrebno je plačilo Največja dolžina sporočila @@ -826,7 +851,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Slog galerije profila Izberi slog galerije Naloži sliko - Pošiljatelji nezaželjenih vsebin + Pošiljatelji nezaželenih vsebin Utišano. Klikni za vklop zvoka Zvok je prižgan. Klikni da ga utišaš Skoči nazaj za %d sekund @@ -843,7 +868,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Posreduj Zape k: Podprti Nostr odjemalci bodo Zape preusmerili na spodnji LN-naslov ali uporabniški profil, namesto na vašega Prikaži lokacijo kot - Doda Geohash vaše lokacije k objavi. Javnost bo vedela, da se nahajate v radiju 5 km (3 milj) od trenutne lokacije + Doda Geohash vaše lokacije k objavi. Javnost bo vedela, da se nahajate v radiju 5km (3milj) od trenutne lokacije Lokacijsko-ekskluzivna objava To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli. Ekskluzivna objava ključnika @@ -885,10 +910,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Za vmesnik aplikacije Temna, svetla ali sistemska tema Samodejno naloži slike in GIF-e - Samodejno predvajaj videe in GIF-e + Samodejno predvajaj video in GIF-e Prikaži predogled URL-jev Kdaj naložiti slike - Kopiraj Stack + Kopiraj nabor Kopiraj v odložišče Kopiraj nprofil v odložišče Kopiraj npub v odložišče @@ -906,7 +931,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prijavi se z Amber Posodobi svoj status Napaka pri razčlembi sporočila o napaki - Glasovi so ovrednoteni glede na količino Zapov. Lahko nastavite minimalni znesek, da se izognete nezaželjenim glasovom, in največji znesek, da preprečite, da bi bogatejši volilci prevzeli glasovanje. V obeh poljih uporabite enak znesek, da zagotovite, da je vsak glas enako ovrednoten. Pustite prazno, če želite sprejeti poljubno količino. + Glasovi so ovrednoteni glede na količino zapov. Lahko nastavite minimalni znesek, da se izognete nezaželjenim glasovom, in največji znesek, da preprečite, da bi bogatejši volilci prevzeli glasovanje. V obeh poljih uporabite enak znesek, da zagotovite, da je vsak glas enako ovrednoten. Pustite prazno, če želite sprejeti poljubno količino. Zap ni uspel Pošlji sporočilo uporabniku Sporočilo %1$s @@ -1096,7 +1121,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Plačilo uspešno Pošiljam plačilo… Vsota (sats) - Opis (Neobvezno) + Opis (izbirno) Ustvari fakturo Ustvarjam fakturo… Kopiraj fakturo @@ -1117,7 +1142,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nova geo-ekskluzivna objava Nov članek Naslov - Povzetek (neobvezno) + Povzetek (izbirno) URL naslov naslovne slike (neobvezno) Napiši članek v obliki Markdown… Predogled @@ -1260,6 +1285,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. + Uredi članek Uredi objavo Prošnja za izboljšavo objave Povzetek sprememb @@ -1339,6 +1365,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ključniki Skupnosti Seznami + Releji Odjava ob zaklepu naprave Zasebno sporočilo Javno sporočilo @@ -1359,7 +1386,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$s → %2$s Išči jezike Dodaj jezik - Dodaj jazikovni par + Dodaj jezikovni par Izvorni jezik Ciljni jezik Prikaži najprej %1$s @@ -1553,7 +1580,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Javno sporočilo Odzivne ikone Kartica kontakta - Autorizacija releja + Avtorizacija releja Odkrivanje relejev Obvestilo nadzornika relejev Nabor relejev @@ -1575,12 +1602,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kratki videoposnetki Zvočno sporočilo Zvočni odgovori + Spletni zaznamek 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 + Iskanje, npub1…, janez@example.com Podpira npub, nprofile, NIP-05, hex in namecoin (.bit, d/, id/) Poišči seznam sledenih Daj napitnino @@ -1603,6 +1631,21 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Izberi vse %1$d%% časa delovanja Namecoin nastavitve + Test povezave + Testiranje strežnikov… + Povezano + Ni uspelo + Izidi testov + Diagnostika + Zadnji test + Info naprave + Info TLS + Test še ni bil zagnan + %dms + Zaupaš certifikatu strežnika? + Strežnik %1$s je poslal certifikat, ki ni med vašimi zaupanja vrednimi potrdili. Prepričajte se, da se spodnji digitalni prstni odtis ujema z uradnim odtisom upravitelja, in se odločite, ali boste strežniku zaupali. + Zaupaj + Zavrni 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. @@ -1641,10 +1684,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem ni dogodkov Bitcoin Raziskovalec (OTS) dogodki + vaši dogodki + dogodki za vas + iskalni dogodki ZS profili - Nastavitve releja - Nazadnje viden pred %1$s + izhodni seznami + nastavitve releja + Nazadnje viden pred %1$s <%1$s Povezujem Prenašam @@ -1681,8 +1728,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Priporočeno za tipe: Usposobljenost potrjevalca Izurjen za preverjanje tipov (Kinds): %1$s - Overja - Zahteva overitev za + Jamči za + Zahteva jamstvo za Časovno obdobje Od Do @@ -1690,4 +1737,128 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vse do zdaj Zadnja sinhronizacija: %1$s Od zadnje sinhronizacije + Spletni zaznamki + Seznam spletnih zaznamkov je prazen. Dodajte ga s pritiskom na +. + Dodaj spletni zaznamek + Uredi spletni zaznamek + URL + https://example.com + Naslov + Naslov zaznamka + Opis + Kratek opis + Oznake (Ločite z vejicami) + nostr, tehnika, blog + Shrani + Izbriši + Izbriši ta spletni zaznamek? + Odpri URL + + Ta cilj se je zaključil + Zbranih %1$s od ciljnih %2$s satov + Ciljni znesek (sat) + 100000 + Opiši svoj cilj + Namen zbiranja sredstev? + Kratek povzetek + Kratek opis za predogled + URL slike (izbirno) + https://example.com/image.jpg + URL spletišča (izbirno) + https://example.com + Končni rok (izbirno) + Izberi končni rok + Nov cilj + Ustvari cilj + Zahteva za izbris sledi + Zahtevajte od relejev, da trajno izbrišejo vse vaše podatke do izbranega datuma. To dejanje temelji na standardu NIP-62 in je v nekaterih regijah pravno zavezujoče. + Izberi rele + Naslovni releji + VSI RELEJI + S tem boste vsem relejem poslali zahtevo za izbris vse vsebine, povezane z vašim ključem, do izbranega datuma. Ta dogodek bo oddan v najširšem možnem obsegu. Tega dejanja ni mogoče razveljaviti. + Izbriši podatke do + Vsi vaši dogodki, ustvarjeni pred tem datumom, bodo na izbranem releju predlagani za izbris. + Razlog (izbirno) + Razlog ali pravni pouk za upravitelja releja + Oddaj zahtevo za izbris sledi + Potrdi zahtevo za izbris sledi + Od releja %1$s boste zahtevali trajen izbris vseh vaših podatkov, ustvarjenih pred izbranim datumom. Tega dejanja ni mogoče razveljaviti. + Od VSEH relejev boste zahtevali trajen izbris vseh vaših podatkov, ustvarjenih pred izbranim datumom. Ta zahteva bo oddana povsod in dejanja ni mogoče razveljaviti. + Zahteva za izbris sledi poslana + Izberi datum + Izberi čas + Pregled zahtev za izbris + Osveži + To so vaše pretekle zahteve za izbris sledi, najdene na povezanih relejih. Releji, ki so označeni v teh dogodkih, ne bi smeli vsebovati nobenih vaših podatkov, ustvarjenih pred datumom dogodka. + Ni najdenih zahtevkov za izbris + Doslej še niste poslali nobene zahteve za izbris sledi. + Ciljni releji + Ta zahteva je usmerjena na vse releje. Za preizkus skladnosti posameznih relejev uporabite zaslon Zahteva za izbris sledi. + Test + Skladen + Neskladen + Napaka + Pregled zahtev za izbris + + Upravljaj %1$s + Priprava orodij za upravljanje relejev… + Napaka pri povezovanju z upravljanjem relejev + Ni razpoložljivih metod za upravljanje + Opusti + Uporabniki + Dogodki + Tipi (kind) + IP-ji + Nastavitve + Blokirani uporabniki + Ni blokiranih uporabnikov + Dovoljeni uporabniki + Ni dovoljenih uporabnikov + Čakalna vrsta za moderiranje + Noben dogodek ni potreben moderiranja + Blokirani dogodki + Ni Blokiranih dogodkov + Dovoljeni tipi + Ni dovoljenih tipov + Blokirani IP-ji + Ni blokiranih IP-jev + Dodaj + Odstrani + Dovoli + Blokiraj + Blokiraj javni ključ + Dovoli javni ključ + Blokiraj dogodek + Dovoli tip (kind) + Blokiraj IP + Javni ključ (hex) + ID dogodka (hex) + Številka tipa (kind) + IP naslov + Razlog (izbirno) + Potrdi + Prekini + Uveljavi + Ime releja + Opis releja + URL ikone releja + Upravljanje releja + Upravljanje + Člani + Člani %1$s + %1$d člani + Nalagam člane… + Ne najdem članov + Prošnja za pridružitev + Prošnja za izstop + Prošnja za pridružitev je poslana + Prošnja za izstop je poslana + Vi ste član + Članski seznam releja + Član je dodan v rele + %1$d članov dodanih v rele + Član odstranjen iz releja + %1$d članov odstranjenih iz releja + Prošnja za dostop do releja + Prošnja za izstop z releja diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index c05bfb1e6..22c4191e4 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -35,4 +35,6 @@ Нема подешавања износа Зап. Дуго притисните да бисте променили + + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 777f8fa18..78c0d5f2f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -61,6 +61,8 @@ Signatären har inte godkänt den dekryptering som krävs. Aktivera NIP-44-dekryptering i din signeringsapp och försök igen Signatör saknas Har signeringsappen avinstallerats? Kontrollera om den är installerad och innehåller det här kontot. Logga ut och in igen om den har ändrats. + Signatären betedde sig oväntat + Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären. Zaps Antal visningar Boosta @@ -377,12 +379,16 @@ Standardbokmärken Dina standardbokmärken som många klienter stödjer Utkast + Omröstningar Privata Bokmärken Publika Bokmärken Lägg till i Privata Bokmärken Lägg till i Publika Bokmärken Ta bort från Privata Bokmärken Ta bort från Publika Bokmärken + Fästa anteckningar + Fäst på profil + Ta bort från profil Bokmärkeslistor Ikon för bokmärkeslista Ny bokmärkeslista @@ -446,6 +452,8 @@ Maximal Zap Konsensus (0–100)% + Enkelt val + Flerval Stängningsdatum och tid Omröstningen stänger om %1$s Avsluta efter @@ -485,6 +493,9 @@ Mottagaren och allmänheten vet inte vem som skickade betalningen Ingen Zap Inga spår i Nostr, bara i Lightning + Anonym + Publicera som en ny engångsidentitet. Ditt konto kommer inte att kopplas till detta svar. + Detta svar kommer att publiceras från en ny anonym identitet Fil Server Välj en server att ladda upp denna fil till LnAdress eller @Användare @@ -707,6 +718,17 @@ Villkor & bestämmelser N/A Fel och meddelanden från detta relä + Reläövervakningsrapporter + Öppna + Läs + Skriv + RTT + Nätverk + Typ + NIP som stöds + Krav + Senaste kontroll + %1$d ms Aktiva prenumerationer Väntande utgående händelser REQ-prenumerationer (%1$d) @@ -1323,6 +1345,7 @@ Hashtaggar Gemenskaper Listor + Reläer Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1559,6 +1582,7 @@ Shorts Röstmeddelande Röstsvar + Webbbokmärke Wiki Kom igång med ett bra flöde genom att följa samma personer som någon du litar på. Importera följarlista @@ -1587,6 +1611,21 @@ Välj alla %1$d%% drifttid Namecoin-inställningar + Testa anslutning + Testar servrar… + Ansluten + Misslyckades + Testresultat + Diagnostik + Senaste test + Enhetsinformation + TLS-information + Inget test har körts ännu + %dms + Lita på servercertifikat? + Servern %1$s presenterade ett certifikat som ännu inte finns i ditt förtroendelager. Verifiera att fingeravtrycket nedan matchar det som serveroperatören publicerat och välj sedan om du vill lita på det för framtida anslutningar. + Lita på + Avvisa 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. @@ -1625,8 +1664,12 @@ inga händelser Bitcoin Explorer (OTS) händelser + händelser från dig + händelser till dig + sökbara händelser DMs profiler + utgående listor relä inställningar Senast sedd för %1$s sedan <%1$s @@ -1674,4 +1717,111 @@ All tid Senaste synkronisering: %1$s Sedan senaste synkronisering + Webbbokmärken + Inga webbbokmärken ännu. Tryck på + för att lägga till. + Lägg till webbbokmärke + Redigera webbbokmärke + URL + https://exempel.se + Titel + Bokmärkets titel + Beskrivning + En kort beskrivning + Taggar (kommaseparerade) + nostr, tech, blog + Spara + Radera + Radera detta webbbokmärke? + Öppna URL + + Detta mål har avslutats + %1$s finansierat av %2$s sats mål + Målbelopp (sats) + 100000 + Beskriv ditt mål + Vad samlar du in pengar till? + Kort sammanfattning + Kort beskrivning som visas i förhandsvisningar + Bild-URL (valfritt) + https://example.com/image.jpg + Webbplats-URL (valfritt) + https://exempel.se + Tidsfrist (valfritt) + Sätt en tidsfrist + Nytt mål + Skapa mål + Begäran om försvinnande + Begär att reläer permanent raderar all din data fram till det valda datumet. Denna åtgärd bygger på NIP-62 och är juridiskt bindande i vissa jurisdiktioner. + Välj ett relä + Målreläer + ALLA RELÄER + Detta kommer att begära att ALLA reläer raderar allt kopplat till din nyckel fram till det valda datumet. Denna händelse kommer att sändas så brett som möjligt. Denna åtgärd kan inte ångras. + Radera data fram till + Alla dina händelser skapade före detta datum kommer att begäras för radering från det valda reläet. + Anledning (valfritt) + Anledning eller juridiskt meddelande till reläoperatören + Skicka begäran om försvinnande + Bekräfta begäran om försvinnande + Du är på väg att begära att %1$s permanent raderar all din data skapad före det valda datumet. Denna åtgärd kan inte ångras. + Du är på väg att begära att VARJE relä permanent raderar all din data skapad före det valda datumet. Detta kommer att sändas överallt och kan inte ångras. + Begäran om försvinnande skickad + Välj datum + Välj tid + Försvinnande historik + Uppdatera + Dessa är dina tidigare Begäran om försvinnande-händelser som hittats på anslutna reläer. Reläer som är taggade i dessa händelser bör inte ha kvar någon av din data från före händelsedatumet. + Inga försvinnandebegäranden hittades + Du har inte skickat några Begäran om försvinnande-händelser ännu. + Målreläer + Denna begäran riktar sig till alla reläer. Använd skärmen Begäran om försvinnande för att testa specifika reläer för efterlevnad. + Testa + Kompatibel + Ej kompatibel + Fel + Försvinnande historik + + Hantera %1$s + Laddar relähanteringsfunktioner… + Kunde inte ansluta till relähantering + Inga hanteringsmetoder tillgängliga + Stäng + Användare + Händelser + Typer + IP-adresser + Inställningar + Bannlysta användare + Inga bannlysta användare + Tillåtna användare + Inga tillåtna användare + Modereringskö + Inga händelser att moderera + Bannlysta händelser + Inga bannlysta händelser + Tillåtna typer + Inga tillåtna typer + Blockerade IP-adresser + Inga blockerade IP-adresser + Lägg till + Ta bort + Tillåt + Bannlys + Bannlys pubkey + Tillåt pubkey + Bannlys händelse + Tillåt typ + Blockera IP + Publik nyckel (hex) + Händelse-ID (hex) + Typnummer + IP-adress + Anledning (valfritt) + Bekräfta + Avbryt + Tillämpa + Relänamn + Relä beskrivning + URL för reläikon + Hantera relä + Hantera diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index 0e3f92eee..19c0dc4aa 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -445,4 +445,6 @@ Tafuta + + diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 090879e2a..bc2650f1a 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -371,4 +371,6 @@ இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 562618425..ce36eddf2 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -816,4 +816,6 @@ ออกจากระบบ + + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 307dff61d..a3d478e80 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -169,4 +169,6 @@ Hata + + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 14d221f28..38d9ac3c9 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -501,4 +501,6 @@ Не вдалося підготувати локальний файл для завантаження: %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 b2f187423..f5df0d484 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -70,4 +70,6 @@ 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 fc3443733..1c2f959fe 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -13,4 +13,6 @@ 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 a63640a6f..4d62b33c3 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -28,8 +28,8 @@ 中继器图标 未知作者 复制文本 - 复制作者@npub - 复制笔记ID + 复制作者公钥 ID + 复制笔记 ID 广播 获取公开时间戳 OpenTimestamps:待确认 @@ -61,6 +61,8 @@ 签名器没有授权解密操作,请在签名器中授予 NIP-44 解密权限并重试。 未找到签名器 签名器被卸载?请检查是否已经安装了签名器以及其中是否存在该账户。变更签名器需要注销后重新登录。 + 签名器行为异常 + 外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。 打闪 浏览次数 提升 @@ -70,7 +72,7 @@ 原版 引用 复刻 - 提议编辑 + 提出修改建议 新的聪金额 添加 "回复 " @@ -114,7 +116,7 @@ "关于我们.. " 你在想什么? 写一条消息… - 发布 + 贴文 保存 创建 重命名 @@ -183,7 +185,7 @@ 更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。 用户尚未设置闪电地址以接收聪 "🔏在此回复… " - 复制笔记ID到剪贴板,供分享 + 复制笔记ID到剪贴板以便于在 Nostr 中分享 复制频道ID(笔记)到剪贴板 修改频道元数据 加入 @@ -196,7 +198,7 @@ Mod 队列 笔记 回复 - 你的 + 互动 相册 "关注" "举报" @@ -326,7 +328,7 @@ 你收到了新的徽章奖励 徽章奖励授予 文本已复制到剪贴板 - 复制作者的 @npub 到剪贴板 + 已复制作者公钥 ID 到剪贴板 已复制笔记ID (@note1) 到剪贴板 选择文本 "<无法解密私密消息>\n\n;你被 %1$s 和 %2$s 之间的私人/加密会话引用。" @@ -379,12 +381,16 @@ 默认书签 许多客户端支持默认书签 草稿 + 投票 私人书签 公开书签 添加到私人书签 添加到公开书签 从私人书签中移除 从公开书签中移除 + 置顶的笔记 + 置顶到个人资料 + 从个人资料中取消置顶 书签列表 书签列表图标 新建书签列表 @@ -448,6 +454,8 @@ 打闪最高金额 共识 (0–100)% + 单选 + 多选 投票结束日期 & 时间 投票结束于 %1$s 后关闭 @@ -487,6 +495,9 @@ 接收方和公众不知道谁发送了付款 非打闪 Nostr 上没有痕迹,仅在闪电上 + 匿名 + 使用新的一次性身份发布。您的帐户将不会被链接到这个回复。 + 此回复将从新的匿名身份发布 文件服务器 选择上传文件时使用的服务器 闪电地址或 @User @@ -592,7 +603,7 @@ Orbot Socks 端口 启动 Tor - 使用内置的版本或者 Orbot + 使用内置 Tor 或者 Orbot Tor 和隐私预设 快速修改下方的设置 Onion 链接或中继地址 @@ -712,6 +723,19 @@ 使用条款 N/A 该中继的错误和通知 + 中继监视器报告 + 打开 + 读取 + 写入 + RTT + 网络 + 类型 + 支持的 NIPs + 要求 + 上次检查 + %1$d 毫秒 + 接受的类型 + 位置信息 活跃订阅 待处理发件箱事件 请求订阅(%1$d) @@ -786,7 +810,7 @@ 偏好设置 用户首选项 翻译 - 反应 + 回应 设置 账户设置 应用程序设置 @@ -1119,17 +1143,17 @@ 点赞 打闪 修改快速回应 - 反应设置 - 配置显示的反应按钮、它们的顺序以及是否显示计数器。 + 回应设置 + 配置显示的回应按钮、按钮顺序及是否显示回应计数。 已启用 显示计数 调整顺序 回复 回复此笔记 - Boost + 提升 转发或引用此笔记 点赞 - 用表情符号对此笔记进行反应 + 使用表情符号回应笔记 打闪 给作者发送 Lightning 网络付款 分享 @@ -1329,6 +1353,7 @@ 话题标签 社区 列表 + 中继 当设备锁定时注销 私信 公开消息 @@ -1541,7 +1566,7 @@ 私密中继 代理中继 公开消息 - 反应 + 回应 名片 中继认证 中继发现 @@ -1565,6 +1590,7 @@ 短篇 语音消息 语音回复 + 网络书签 维基 关注你信任的人所关注的人来开启优质的源。 导入关注列表 @@ -1593,6 +1619,21 @@ 全选 %1$d%% 运行时间 Namecoin 设置 + 测试连接 + 正在测试服务器… + 已连接 + 已失败 + 测试结果 + 诊断 + 上次测试 + 设备信息 + TLS 信息 + 尚未运行测试 + %d毫秒 + 信任服务器证书? + 服务器 %1$s 提供的证书尚未在您的信任存储中。 验证下面的指纹与服务器运营者发布的内容匹配,然后选择是否信任它来进行未来连接。 + 信任 + 拒绝 中继同步 中继同步 在所有已知的中继重新发布您的事件,以保持您的发件箱、收件箱和私信中继是最新的。 需要 Wi-Fi - 这可能使用大量数据。 @@ -1631,10 +1672,14 @@ 没有事件 比特币资源管理器 (OTS) 事件 + 来自您的事件 + 给您的事件 + 可搜索的事件 私信 个人资料 + 发件箱列表 中继设置 - 上次看见在 %1$s 秒前 + %1$s 前活跃 <%1$s 连接中 下载中 @@ -1680,4 +1725,111 @@ 全部时间 上次同步: %1$s 自上次同步后 + 网络书签 + 暂无网络书签。点击 + 添加一个。 + 添加网络书签 + 编辑网络书签 + URL + https://example.com + 标题 + 书签标题 + 描述 + 一段简短描述 + 标签(以逗号分隔) + nostr, tech, blog + 保存 + 删除 + 删除该网络书签? + 打开 URL + + 此目标已关闭 + 设定目标为 %2$s sats,筹集到 %1$s + 目标金额 (sats) + 100000 + 描述您的目标 + 你为什么筹资? + 简短摘要 + 预览中显示的简要描述 + 图片 URL (可选) + https://example.com/image.jpg + 网站 URL (可选) + https://example.com + 截止日期(可选) + 设置截止日期 + 新目标 + 创建目标 + 请求消失 + 请求中继永久删除直至所选日期的您的所有数据。此操作基于 NIP-62 在一些司法管辖区具备法律约束力。 + 选择中继 + 目标中继 + 所有中继 + 这会请求所有中继删除直至所选日期的与你的密钥所关联的一切。此事件将在尽可能广的范围内进行广播。此操作无法撤销。 + 删除直至下列时间点的数据 + 所有在此日期前创建的事件都将被请求从所选中继删除。 + 原因 (可选) + 给中继运营者的原因或法律通知 + 发送消失请求 + 确认消失请求 + 您将要请求 %1$s 永久删除在选定日期之前创建的所有数据。此操作无法撤消。 + 您将要请求所有中继永久删除选定日期之前创建的所有数据。此操作会在所有地方广播且无法撤消。 + 消失请求已发送 + 选择日期 + 选择时间 + 消失历史记录 + 刷新 + 这些是在连接的中继上发现的以往的“消失请求”事件。在这些事件中标记的中继不应持有任何事件日期前的你的数据。 + 没有发现消失请求 + 您尚未发送任何消失请求事件。 + 目标中继 + 此请求针对所有中继。使用 Request to Vanish 屏幕测试特定中继的合规性。 + 测试 + 合规 + 不符合要求 + 错误 + 消失历史记录 + + 管理 %1$s + 加载中继管理能力中… + 无法连接到中继管理 + 没有可用的管理方法 + 忽略 + 用户 + 事件 + 类型 + IP地址 + 设置 + 被禁止的用户 + 没有被封禁的用户 + 允许的用户 + 没有允许的用户 + 审核队列 + 没有需要审核的事件 + 禁止事件 + 没有禁止的事件 + 允许的类型 + 没有允许的类型 + 屏蔽的 IP + 没有屏蔽的 IP + 添加 + 移除 + 允许 + 封禁 + 封禁公钥 + 允许公钥 + 封禁事件 + 允许类型 + 屏蔽IP + 公钥(十六进制) + 事件 ID (十六进制) + 类型编号 + IP地址 + 原因 (可选) + 确认 + 取消 + 申请 + 中继名称 + 中继描述 + 中继图标 URL + 管理中继 + 管理 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 8dbf75e36..57adb165e 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -241,4 +241,6 @@ "“正在查找事件%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 88bf85950..73629bea4 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2,4 +2,6 @@ + + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 2e6017315..04decc514 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -764,4 +764,6 @@ 添加 NIP-96 服務器 + + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 16f6bfcdc..baa043fc9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -68,7 +68,8 @@ Signer not found Was the Signer app uninstalled? Check if the signer is installed and has this account. Log off and Log in again of the signer app has changed. - + Signer misbehaved + External signer returned a payload that is strange for the request. There might be a bug on either Amethyst or the Signer. Zaps View count @@ -410,6 +411,7 @@ Default Bookmarks Your default Bookmarks that many clients support Drafts + Polls Private Bookmarks Public Bookmarks Add to Private Bookmarks @@ -417,6 +419,10 @@ Remove from Private Bookmarks Remove from Public Bookmarks + Pinned Notes + Pin to Profile + Unpin from Profile + Bookmark Lists Icon for bookmark list New Bookmark List @@ -829,6 +835,19 @@ Terms & Conditions N/A Errors and Notices from this Relay + Relay Monitor Reports + Open + Read + Write + RTT + Network + Type + Supported NIPs + Requirements + Last check + %1$d ms + Accepted Kinds + Location Active Subscriptions Pending Outbox Events REQ Subscriptions (%1$d) @@ -1476,7 +1495,11 @@ Git Repository: %1$s Web: Clone: - OTS: %1$s + Static Website: %1$s + Root Site + Source: + Servers: +OTS: %1$s 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. @@ -1574,6 +1597,7 @@ Hashtags Communities Lists + Relays Log off on device lock Private Message @@ -1825,13 +1849,14 @@ Shorts Voice Msg Voice Reply + Web Bookmark Wiki Start with a great feed by following the same people as someone you trust. Import Follow List Select Users to Follow Profile to import from search, npub1…, alice@example.com - Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/) + Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit) Look Up Follow List Tip %1$d accounts found @@ -1853,6 +1878,21 @@ Select All %1$d%% uptime Namecoin Settings + Test Connection + Testing servers… + Connected + Failed + Test Results + Diagnostics + Last test + Device Info + TLS Info + No test run yet + %dms + Trust Server Certificate? + The server %1$s presented a certificate not yet in your trust store. Verify the fingerprint below matches what the server operator published, then choose whether to trust it for future connections. + Trust + Reject 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. @@ -1891,8 +1931,12 @@ no events Bitcoin Explorer (OTS) events + events from you + events to you + searchable events DMs profiles + outbox lists relay settings Last seen %1$s ago @@ -1943,4 +1987,129 @@ All time Last sync: %1$s Since Last Sync + Web Bookmarks + No web bookmarks yet. Tap + to add one. + Add Web Bookmark + Edit Web Bookmark + URL + https://example.com + Title + Bookmark title + Description + A short description + Tags (comma-separated) + nostr, tech, blog + Save + Delete + Delete this web bookmark? + Open URL + + + This goal has closed + %1$s funded of %2$s sats goal + Goal amount (sats) + 100000 + Describe your goal + What are you fundraising for? + Short summary + Brief description shown in previews + Image URL (optional) + https://example.com/image.jpg + Website URL (optional) + https://example.com + Deadline (optional) + Set a deadline + New Goal + Create Goal + Request to Vanish + Request relays to permanently delete all your data up to the selected date. This action is based on NIP-62 and is legally binding in some jurisdictions. + Select a relay + Target Relays + ALL RELAYS + This will request ALL relays to delete everything associated with your key up to the selected date. This event will be broadcast as widely as possible. This action cannot be undone. + Delete data up to + All your events created before this date will be requested for deletion from the selected relay. + Reason (optional) + Reason or legal notice for the relay operator + Send Vanish Request + Confirm Vanish Request + You are about to request %1$s to permanently delete all your data created before the selected date. This cannot be undone. + You are about to request EVERY relay to permanently delete all your data created before the selected date. This will be broadcast everywhere and cannot be undone. + Vanish request sent + Select date + Select time + Vanish History + Refresh + These are your past Request to Vanish events found on connected relays. Relays tagged in these events should not hold any of your data from before the event date. + No vanish requests found + You haven\'t sent any Request to Vanish events yet. + Target Relays + This request targets all relays. Use the Request to Vanish screen to test specific relays for compliance. + Test + Compliant + Non-compliant + Error + Vanish History + + Manage %1$s + Loading relay management capabilities… + Unable to connect to relay management + No management methods available + Dismiss + Users + Events + Kinds + IPs + Settings + Banned Users + No banned users + Allowed Users + No allowed users + Moderation Queue + No events needing moderation + Banned Events + No banned events + Allowed Kinds + No allowed kinds + Blocked IPs + No blocked IPs + Add + Remove + Allow + Ban + Ban Pubkey + Allow Pubkey + Ban Event + Allow Kind + Block IP + Pubkey (hex) + Event ID (hex) + Kind number + IP address + Reason (optional) + Confirm + Cancel + Apply + Relay Name + Relay Description + Relay Icon URL + Manage Relay + Manage + Members + Members of %1$s + %1$d members + Loading members… + No members found + Request to Join + Request to Leave + Join request sent + Leave request sent + You are a member + Relay membership list + Member added to relay + %1$d members added to relay + Member removed from relay + %1$d members removed from relay + Relay join request + Relay leave request diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 7ba915c3e..197dbd4d2 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -49,7 +49,7 @@ class PushNotificationReceiverService : FirebaseMessagingService() { // this is called when a message is received override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d("PushNotificationService", "Notification received $remoteMessage") + Log.d("PushNotificationService") { "Notification received $remoteMessage" } scope.launch(Dispatchers.IO) { parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt index 965f5ba0c..984f1daac 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import junit.framework.TestCase.assertEquals diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt index 4eb495af6..661caca6e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt @@ -56,7 +56,7 @@ class NamecoinSettingsTest { assertEquals("abc123def.onion", s!!.host) assertEquals(50001, s.port) assertFalse(s.useSsl) - assertTrue(s.trustAllCerts) + assertTrue(s.usePinnedTrustStore) } @Test @@ -131,7 +131,7 @@ class NamecoinSettingsTest { assertTrue(servers[0].useSsl) assertEquals("server2.onion", servers[1].host) assertFalse(servers[1].useSsl) - assertTrue(servers[1].trustAllCerts) + assertTrue(servers[1].usePinnedTrustStore) } @Test diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt index 296a8927f..c5b1d9c72 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt @@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.mac.FixedKey import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec @RunWith(AndroidJUnit4::class) class ChaCha20Benchmark { @@ -52,36 +55,50 @@ class ChaCha20Benchmark { } @Test - fun encryptLibSodium() { + fun encrypt() { benchmarkRule.measureRepeated { - chaCha.encryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + chaCha.encrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } + + @Test + fun decrypt() { + benchmarkRule.measureRepeated { + chaCha.decrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } - /* - Removed in the conversion to KMP @Test fun encryptNative() { benchmarkRule.measureRepeated { - chaCha.encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) - } - } - */ - - @Test - fun decryptLibSodium() { - benchmarkRule.measureRepeated { - chaCha.decryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } - /* - Removed in the conversion to KMP @Test fun decryptNative() { benchmarkRule.measureRepeated { - chaCha.decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } - */ + + fun encryptNative( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val cipher = Cipher.getInstance("ChaCha20") + cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) + return cipher.doFinal(message) + } + + fun decryptNative( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val cipher = Cipher.getInstance("ChaCha20") + cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) + return cipher.doFinal(message) + } } 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 6694997de..4350ecafe 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt @@ -57,7 +57,7 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { try { db.insert(event) } catch (e: SQLiteException) { - Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: ${event.toJson()}") + Log.w("LargeDBInsertBenchmark") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } } } runWithMeasurementDisabled { @@ -87,7 +87,7 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { try { db.insert(event) } catch (e: SQLiteException) { - Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: ${event.toJson()}") + Log.w("LargeDBInsertBenchmark") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } } } db @@ -97,7 +97,7 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { try { db.insert(event) } catch (e: SQLiteException) { - Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + Log.w("LargeDBInsertBenchmark") { "Error inserting event: ${e.message} for event: $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 2a1764ddb..70a605be7 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt @@ -61,7 +61,7 @@ class LargeDBQueryingBenchmark : BaseLargeCacheBenchmark() { try { db.insert(event) } catch (e: SQLiteException) { - Log.w("LargeDBQueryingBenchmark", "Error inserting event: ${e.message} for event: ${event.toJson()}") + Log.w("LargeDBQueryingBenchmark") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt new file mode 100644 index 000000000..b501e8399 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.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.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.utils.Rfc3986 +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Benchmark, which will execute on an Android device. + * + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. + */ +@RunWith(AndroidJUnit4::class) +class Rfc3986UrlNormalizerBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + @Test + fun normalize() { + benchmarkRule.measureRepeated { + Rfc3986.normalize("wss://relay.damus.io") + } + } + + @Test + fun parseUrlDetector() { + benchmarkRule.measureRepeated { + UrlDetector("wss://nostr.mom/").detect() + } + } +} diff --git a/build.gradle b/build.gradle index e9bc0cb9b..f065a2b44 100644 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,10 @@ plugins { } allprojects { + configurations.configureEach { + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' + } + apply plugin: 'com.diffplug.spotless' if (project === rootProject) { @@ -30,7 +34,7 @@ allprojects { target 'src/**/*.kt' ktlint() - licenseHeaderFile rootProject.file('.spotless/copyright.kt'), "package|import|class|object|sealed|open|interface|abstract " + licenseHeaderFile rootProject.file('.spotless/copyright.kt'), "@file:|package|import|class|object|sealed|open|interface|abstract " } groovyGradle { @@ -58,9 +62,18 @@ subprojects { } tasks.register('installGitHook', Copy) { + def dotGit = new File(rootProject.rootDir, '.git') + def hooksDir + if (dotGit.isFile()) { + // Git worktree: .git is a file with "gitdir: " + def gitDir = new File(dotGit.text.trim().replace('gitdir: ', '')) + hooksDir = new File(gitDir, 'hooks') + } else { + hooksDir = new File(dotGit, 'hooks') + } from new File(rootProject.rootDir, '.git-hooks/pre-commit') from new File(rootProject.rootDir, '.git-hooks/pre-push') - into { new File(rootProject.rootDir, '.git/hooks') } + into { hooksDir } filePermissions { unix(0777) } } tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index c55f9e409..72d905ec8 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -72,6 +72,11 @@ kotlin { // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) + + // Markdown rendering (richtext-commonmark) + implementation(libs.markdown.commonmark) + implementation(libs.markdown.ui) + implementation(libs.markdown.ui.material3) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt index 4a713d379..703ab3c98 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.chess 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.reqs.SubscriptionListener 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 @@ -49,7 +49,7 @@ enum class RelayFetchStatus { /** * One-shot relay fetch helper for chess events. * - * Follows the existing INostrClient + IRequestListener + Channel pattern + * Follows the existing INostrClient + SubscriptionListener + Channel pattern * from quartz (see NostrClientSingleDownloadExt.kt). * * Each fetch opens a subscription, collects events until EOSE from all relays, @@ -62,7 +62,7 @@ class ChessRelayFetchHelper( /** * Fetch events matching filters from relays, waiting for EOSE. * - * @param filters Map of relay → filter list (same format as INostrClient.openReqSubscription) + * @param filters Map of relay → filter list (same format as INostrClient.subscribe) * @param timeoutMs Max time to wait for relays to respond (default from ChessConfig) * @param onProgress Optional callback for progress updates per relay * @return Deduplicated list of events received before timeout/EOSE @@ -88,7 +88,7 @@ class ChessRelayFetchHelper( } val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -114,7 +114,7 @@ class ChessRelayFetchHelper( } } - client.openReqSubscription(subId, filters, listener) + client.subscribe(subId, filters, listener) val eoseResult = withTimeoutOrNull(timeoutMs) { allEose.await() } // Mark timed-out relays @@ -127,7 +127,7 @@ class ChessRelayFetchHelper( } } - client.close(subId) + client.unsubscribe(subId) return events.values.toList() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt new file mode 100644 index 000000000..7e978be46 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.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.amethyst.commons.compose.article + +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage + +@Composable +fun ArticleHeader( + title: String, + authorName: String?, + authorPicture: String?, + publishedAt: String?, + readingTimeMinutes: Int?, + bannerUrl: String?, + modifier: Modifier = Modifier, + onAuthorClick: (() -> Unit)? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + // Banner image + if (!bannerUrl.isNullOrBlank() && + (bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://")) + ) { + AsyncImage( + model = bannerUrl, + contentDescription = "Article banner", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(300.dp), + ) + Spacer(Modifier.height(24.dp)) + } + + // Title + Text( + text = title, + style = + MaterialTheme.typography.headlineLarge.copy( + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp, + ), + ) + + Spacer(Modifier.height(16.dp)) + + // Author + metadata row + Row(verticalAlignment = Alignment.CenterVertically) { + if (!authorPicture.isNullOrBlank() && + (authorPicture.startsWith("https://") || authorPicture.startsWith("http://")) + ) { + AsyncImage( + model = authorPicture, + contentDescription = "Author", + modifier = Modifier.size(40.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + Spacer(Modifier.width(12.dp)) + } + + Column { + if (!authorName.isNullOrBlank()) { + Text( + text = authorName, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + ) + } + + val metaParts = mutableListOf() + readingTimeMinutes?.let { metaParts.add("$it min read") } + publishedAt?.let { metaParts.add(it) } + + if (metaParts.isNotEmpty()) { + Text( + text = metaParts.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt new file mode 100644 index 000000000..292fb6729 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.compose.article + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)") +private val TRAILING_HASHES_REGEX = Regex("#+$") + +data class TocEntry( + val level: Int, + val text: String, + val index: Int, +) + +/** + * Extracts table of contents entries from markdown content. + * Parses ATX headings (# H1, ## H2, etc.), skipping code blocks. + */ +fun extractTableOfContents(markdown: String): List { + val entries = mutableListOf() + var inCodeBlock = false + var headingIndex = 0 + + markdown.lines().forEach { line -> + val trimmed = line.trim() + if (trimmed.startsWith("```")) { + inCodeBlock = !inCodeBlock + return@forEach + } + if (inCodeBlock) return@forEach + + val match = HEADING_REGEX.find(trimmed) + if (match != null) { + val level = match.groupValues[1].length + val text = + match.groupValues[2] + .trim() + .replace(TRAILING_HASHES_REGEX, "") + .trim() + if (text.isNotEmpty() && level <= 3) { + entries.add(TocEntry(level = level, text = text, index = headingIndex)) + } + headingIndex++ + } + } + return entries +} + +@Composable +fun TableOfContents( + entries: List, + activeEntryIndex: Int?, + onEntryClick: (TocEntry) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Column( + modifier = + modifier + .width(240.dp) + .verticalScroll(scrollState) + .padding(vertical = 16.dp), + ) { + entries.forEach { entry -> + val isActive = entry.index == activeEntryIndex + val accentColor = MaterialTheme.colorScheme.primary + + Text( + text = entry.text, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .clickable { onEntryClick(entry) } + .padding( + start = ((entry.level - 1) * 16).dp, + top = 4.dp, + bottom = 4.dp, + end = 8.dp, + ).then( + if (isActive) { + Modifier.drawBehind { + drawLine( + color = accentColor, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 3.dp.toPx(), + ) + } + } else { + Modifier + }, + ), + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt new file mode 100644 index 000000000..7c69d6fdb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.compose.editor + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +/** + * State holder for a markdown editor with selection-aware formatting. + * + * Solves the focus/selection bug: toolbar buttons steal focus from TextField, + * collapsing the selection. We cache the last known selection on every value + * change, and toolbar operations use the cached selection. + */ +class MarkdownEditorState( + initial: String = "", +) { + var value by mutableStateOf(TextFieldValue(initial)) + private set + + /** Cached selection — updated on every onValueChange, survives focus loss. */ + var lastSelection: TextRange = TextRange.Zero + private set + + fun onValueChange(newValue: TextFieldValue) { + value = newValue + // Only cache non-zero selections (focus loss sends collapsed range) + if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) { + lastSelection = newValue.selection + } + // Also cache cursor position when no selection + if (newValue.selection.collapsed) { + lastSelection = newValue.selection + } + } + + fun loadContent(content: String) { + value = TextFieldValue(content) + lastSelection = TextRange.Zero + } + + val text: String get() = value.text + + // --- Active state detection (uses current value.selection for display) --- + + val isBold: Boolean + get() = isWrapped("**", "**") + + val isItalic: Boolean + get() = isWrappedItalic() + + val isStrikethrough: Boolean + get() = isWrapped("~~", "~~") + + val isInlineCode: Boolean + get() = isWrapped("`", "`") + + val isBlockquote: Boolean + get() = isLinePrefix("> ") + + val isUnorderedList: Boolean + get() = isLinePrefix("- ") + + val isOrderedList: Boolean + get() { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + val line = text.substring(lineStart) + return line.matches(Regex("^\\d+\\.\\s.*")) + } + + val isTaskList: Boolean + get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ") + + val headingLevel: Int? + get() { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + val line = text.substring(lineStart) + return when { + line.startsWith("### ") -> 3 + line.startsWith("## ") -> 2 + line.startsWith("# ") -> 1 + else -> null + } + } + + // --- Formatting operations (use lastSelection to survive focus loss) --- + + fun toggleBold() { + applyToggleWrap("**", "**") + } + + fun toggleItalic() { + applyToggleWrapItalic() + } + + fun toggleStrikethrough() { + applyToggleWrap("~~", "~~") + } + + fun toggleInlineCode() { + applyToggleWrap("`", "`") + } + + fun setHeading(level: Int?) { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + val line = text.substring(lineStart) + + // Remove existing heading prefix + val stripped = + when { + line.startsWith("### ") -> line.removePrefix("### ") + line.startsWith("## ") -> line.removePrefix("## ") + line.startsWith("# ") -> line.removePrefix("# ") + else -> line + } + val oldPrefixLen = + when { + line.startsWith("### ") -> 4 + line.startsWith("## ") -> 3 + line.startsWith("# ") -> 2 + else -> 0 + } + + val newPrefix = + when (level) { + 1 -> "# " + 2 -> "## " + 3 -> "### " + else -> "" + } + + val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it } + val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd) + val shift = newPrefix.length - oldPrefixLen + + value = + TextFieldValue( + text = newText, + selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)), + ) + lastSelection = value.selection + } + + fun toggleBlockquote() { + applyToggleLinePrefix("> ") + } + + fun toggleUnorderedList() { + applyToggleLinePrefix("- ") + } + + fun toggleOrderedList() { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + val line = text.substring(lineStart) + + if (line.matches(Regex("^\\d+\\.\\s.*"))) { + // Remove ordered list prefix + val prefixEnd = line.indexOf(". ") + 2 + val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it }) + value = + TextFieldValue( + text = text.substring(0, lineStart) + line.substring(prefixEnd), + selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)), + ) + } else { + applyToggleLinePrefix("1. ") + } + lastSelection = value.selection + } + + fun toggleTaskList() { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + + when { + text.startsWith("- [ ] ", lineStart) -> { + // Remove task list prefix + val newText = text.substring(0, lineStart) + text.substring(lineStart + 6) + val shift = 6 + value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart))) + lastSelection = value.selection + } + + text.startsWith("- [x] ", lineStart) -> { + val newText = text.substring(0, lineStart) + text.substring(lineStart + 6) + val shift = 6 + value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart))) + lastSelection = value.selection + } + + else -> { + applyToggleLinePrefix("- [ ] ") + } + } + } + + fun toggleCodeBlock() { + applyToggleWrap("```\n", "\n```") + } + + fun insertHorizontalRule() { + val sel = lastSelection + val insert = "\n---\n" + val newText = text.substring(0, sel.min) + insert + text.substring(sel.max) + value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length)) + lastSelection = value.selection + } + + fun insertLink() { + val sel = lastSelection + val selected = text.substring(sel.min, sel.max) + + if (selected.isNotEmpty()) { + val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max) + val urlStart = sel.min + selected.length + 3 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } else { + val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min) + value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1)) + } + lastSelection = value.selection + } + + fun insertImage() { + val sel = lastSelection + val selected = text.substring(sel.min, sel.max) + + if (selected.isNotEmpty()) { + val newText = text.substring(0, sel.min) + "![$selected](url)" + text.substring(sel.max) + val urlStart = sel.min + selected.length + 4 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } else { + val newText = text.substring(0, sel.min) + "![alt](url)" + text.substring(sel.min) + val urlStart = sel.min + 7 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } + lastSelection = value.selection + } + + // --- Private helpers --- + + private fun isWrapped( + prefix: String, + suffix: String, + ): Boolean { + val sel = value.selection + val start = sel.min + val end = sel.max + return start >= prefix.length && + end + suffix.length <= text.length && + text.substring(start - prefix.length, start) == prefix && + text.substring(end, end + suffix.length) == suffix + } + + private fun isWrappedItalic(): Boolean { + val sel = value.selection + val start = sel.min + val end = sel.max + if (start < 1 || end + 1 > text.length) return false + if (text[start - 1] != '*' || text[end] != '*') return false + val hasBoldBefore = start >= 2 && text[start - 2] == '*' + val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*' + return !hasBoldBefore && !hasBoldAfter + } + + private fun isLinePrefix(prefix: String): Boolean { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + return text.startsWith(prefix, lineStart) + } + + private fun applyToggleWrap( + prefix: String, + suffix: String, + ) { + val sel = lastSelection + val start = sel.min + val end = sel.max + + val wrapped = + start >= prefix.length && + end + suffix.length <= text.length && + text.substring(start - prefix.length, start) == prefix && + text.substring(end, end + suffix.length) == suffix + + value = + if (wrapped) { + val newText = + text.substring(0, start - prefix.length) + + text.substring(start, end) + + text.substring(end + suffix.length) + TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length)) + } else if (start == end) { + val newText = text.substring(0, start) + prefix + suffix + text.substring(start) + TextFieldValue(newText, TextRange(start + prefix.length)) + } else { + val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end) + TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length)) + } + lastSelection = value.selection + } + + private fun applyToggleWrapItalic() { + val sel = lastSelection + val start = sel.min + val end = sel.max + + val isItalic = + start >= 1 && + end + 1 <= text.length && + text[start - 1] == '*' && + text[end] == '*' && + !(start >= 2 && text[start - 2] == '*') && + !(end + 1 < text.length && text[end + 1] == '*') + + if (isItalic) { + val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1) + value = TextFieldValue(newText, TextRange(start - 1, end - 1)) + } else { + applyToggleWrap("*", "*") + return + } + lastSelection = value.selection + } + + private fun applyToggleLinePrefix(prefix: String) { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + + value = + if (text.startsWith(prefix, lineStart)) { + val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length) + val shift = prefix.length + TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart))) + } else { + val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart) + TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length)) + } + lastSelection = value.selection + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt new file mode 100644 index 000000000..d6b977914 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.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.amethyst.commons.compose.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.FormatBold +import androidx.compose.material.icons.filled.FormatItalic +import androidx.compose.material.icons.filled.FormatListNumbered +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.FormatStrikethrough +import androidx.compose.material.icons.filled.HorizontalRule +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Markdown toolbar with Material icons, grouped formatting buttons, and active state. + * Uses [MarkdownEditorState] for selection-aware toggle behavior. + * + * Buttons use `focusProperties { canFocus = false }` to prevent stealing focus + * from the editor TextField, preserving the user's text selection. + */ +@Composable +fun MarkdownToolbar( + state: MarkdownEditorState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // --- Headings --- + ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) } + ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) } + ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) } + + Separator() + + // --- Inline formatting --- + ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() } + ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() } + ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() } + ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() } + + Separator() + + // --- Lists --- + ToolbarIconButton(Icons.AutoMirrored.Filled.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() } + ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() } + ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() } + + Separator() + + // --- Block elements --- + ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() } + ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() } + ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() } + + Separator() + + // --- Insert --- + ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() } + ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() } + } +} + +@Composable +private fun Separator() { + VerticalDivider( + modifier = Modifier.height(24.dp).padding(horizontal = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} + +@Composable +private fun ToolbarIconButton( + icon: ImageVector, + contentDescription: String, + active: Boolean, + onClick: () -> Unit, +) { + val containerColor = + if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (active) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + SmallFloatingActionButton( + onClick = onClick, + containerColor = containerColor, + contentColor = contentColor, + modifier = + Modifier + .size(32.dp) + .focusProperties { canFocus = false }, + ) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun ToolbarButton( + label: String, + active: Boolean, + onClick: () -> Unit, +) { + val containerColor = + if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (active) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + SmallFloatingActionButton( + onClick = onClick, + containerColor = containerColor, + contentColor = contentColor, + modifier = + Modifier + .size(32.dp) + .focusProperties { canFocus = false }, + ) { + Text( + text = label, + fontSize = 11.sp, + color = contentColor, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt new file mode 100644 index 000000000..28a22e080 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.compose.editor + +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.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +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.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.unit.dp + +/** + * Form fields for article metadata: title, summary, banner, tags, slug. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MetadataPanel( + title: String, + onTitleChange: (String) -> Unit, + summary: String, + onSummaryChange: (String) -> Unit, + bannerUrl: String, + onBannerUrlChange: (String) -> Unit, + tags: List, + onTagsChange: (List) -> Unit, + slug: String, + onSlugChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var tagInput by remember { mutableStateOf("") } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { if (it.length <= 256) onTitleChange(it) }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${title.length}/256") }, + ) + + OutlinedTextField( + value = summary, + onValueChange = { if (it.length <= 1024) onSummaryChange(it) }, + label = { Text("Summary") }, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${summary.length}/1024") }, + ) + + OutlinedTextField( + value = bannerUrl, + onValueChange = onBannerUrlChange, + label = { Text("Banner Image URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Tags chip input + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = tagInput, + onValueChange = { tagInput = it }, + label = { Text("Add tag (Enter to add)") }, + singleLine = true, + modifier = + Modifier.weight(1f).onKeyEvent { event -> + if (event.key == Key.Enter && tagInput.isNotBlank()) { + val newTag = tagInput.trim().lowercase() + if (newTag !in tags) { + onTagsChange(tags + newTag) + } + tagInput = "" + true + } else { + false + } + }, + ) + } + + if (tags.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + tags.forEach { tag -> + AssistChip( + onClick = { onTagsChange(tags - tag) }, + label = { Text(tag) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = "Remove $tag", + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } + } + } + + OutlinedTextField( + value = slug, + onValueChange = onSlugChange, + label = { Text("Slug (d-tag)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("Used as the unique identifier for this article") }, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt new file mode 100644 index 000000000..c31109d4a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.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.commons.compose.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.unit.Density +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions +import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser +import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.material3.RichText + +private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning", "highlight") + +/** + * Escapes markdown special characters inside highlighted text so it doesn't + * break the markdown parser when wrapped in a link. + */ +private fun escapeMarkdownInLink(text: String): String = + text + .replace("[", "\\[") + .replace("]", "\\]") + .replace("(", "\\(") + .replace(")", "\\)") + +@Composable +fun RenderMarkdown( + content: String, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier, + fontScale: Float = 1.0f, + highlightedTexts: List = emptyList(), +) { + val processedContent = + remember(content, highlightedTexts) { + if (highlightedTexts.isEmpty()) { + content + } else { + var result = content + highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text -> + val idx = result.indexOf(text) + if (idx >= 0) { + val escaped = escapeMarkdownInLink(text) + result = result.replaceFirst(text, "[$escaped](highlight://$index)") + } + } + result + } + } + + val astNode = + remember(processedContent) { + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent) + } + + val uriHandler = + remember(onLinkClick) { + object : UriHandler { + override fun openUri(uri: String) { + val scheme = uri.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { + onLinkClick(uri) + } + } + } + } + + val currentDensity = LocalDensity.current + val scaledDensity = + remember(fontScale, currentDensity) { + if (fontScale == 1.0f) { + currentDensity + } else { + Density( + density = currentDensity.density * fontScale, + fontScale = currentDensity.fontScale, + ) + } + } + + CompositionLocalProvider( + LocalUriHandler provides uriHandler, + LocalDensity provides scaledDensity, + ) { + RichText( + modifier = modifier, + style = RichTextStyle(), + ) { + BasicMarkdown(astNode) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt index 6725ef332..ef044ba8f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -122,14 +122,17 @@ object NostrConnectLoginUseCase { val deferred = CompletableDeferred() val subscription = - client.req( - relays = relays.toList(), - filter = - Filter( - kinds = listOf(NostrConnectEvent.KIND), - tags = mapOf("p" to listOf(ephemeralPubKey)), - since = TimeUtils.now() - 60, - ), + StaticSubscription( + client, + relays.associateWith { + listOf( + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(ephemeralPubKey)), + since = TimeUtils.now() - 60, + ), + ) + }, ) { event -> if (event is NostrConnectEvent && !deferred.isCompleted) { deferred.complete(event) @@ -207,7 +210,7 @@ object NostrConnectLoginUseCase { remoteKey = connectData.signerPubkey, signer = ephemeralSigner, ) - client.send(ackEvent, relays) + client.publish(ackEvent, relays) } private fun generateSecret(): String { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index 9fbd7c2fd..657922e00 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -52,7 +52,7 @@ class ThreadAssembler( ?.getOrNull(1) if (markedAsRoot != null) { // Check to see if there is an error in the tag and the root has replies - val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note + val rootNote = cache.getNoteIfExists(markedAsRoot) if (rootNote?.replyTo?.isEmpty() == true) { return cache.checkGetOrCreateNote(markedAsRoot) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index 2634196aa..e4b3ccd9a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -57,7 +57,7 @@ interface ICacheProvider { * @param pubkey The user's public key in hex format * @return The User if exists in cache, null otherwise */ - fun getUserIfExists(pubkey: HexKey): Any? + fun getUserIfExists(pubkey: HexKey): User? /** * Counts users matching a predicate. @@ -75,7 +75,7 @@ interface ICacheProvider { * @param hexKey The note's ID in hex format * @return The Note if exists in cache, null otherwise */ - fun getNoteIfExists(hexKey: HexKey): Any? + fun getNoteIfExists(hexKey: HexKey): Note? /** * Gets an existing Note or creates a new one if it doesn't exist. @@ -123,7 +123,7 @@ interface ICacheProvider { fun findUsersStartingWith( prefix: String, limit: Int = 50, - ): List = emptyList() + ): List = emptyList() /** * Gets or creates a User by public key hex. @@ -132,7 +132,7 @@ interface ICacheProvider { * @param pubkey The user's public key in hex format * @return The User (existing or newly created) */ - fun getOrCreateUser(pubkey: HexKey): Any? + fun getOrCreateUser(pubkey: HexKey): User? fun justConsumeMyOwnEvent(event: Event): Boolean } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt index 11f888e9a..e8cccecac 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt @@ -124,7 +124,7 @@ class EphemeralChatListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start") getEphemeralChatListFlow().collect { noteState -> - Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "EphemeralChatList List for ${signer.pubKey}" } (noteState.note.event as? EphemeralChatListEvent)?.let { settings.updateEphemeralChatListTo(it) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt new file mode 100644 index 000000000..4e1d86628 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.model.highlights + +data class HighlightData( + val id: String, + val text: String, + val note: String? = null, + val articleAddressTag: String, + val articleTitle: String? = null, + val createdAt: Long, + val published: Boolean = false, + val eventId: String? = null, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt new file mode 100644 index 000000000..da4c6e981 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.model.nip02FollowList + +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +/** + * Narrow repository interface for Kind3FollowListState's settings needs. + * Follows the established pattern of EphemeralChatRepository / PublicChatListRepository. + */ +interface Kind3FollowListRepository { + val backupContactList: ContactListEvent? + + fun updateContactListTo(event: ContactListEvent) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt new file mode 100644 index 000000000..509e2e1c3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.model.nip02FollowList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class Kind3FollowListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: Kind3FollowListRepository, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val user = cache.getOrCreateUser(signer.pubKey) + + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val note = cache.getOrCreateAddressableNote(getFollowListAddress()) + + fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey) + + fun getFollowListFlow(): StateFlow = note.flow().metadata.stateFlow + + fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent + + @OptIn(ExperimentalCoroutinesApi::class) + private val innerFlow: Flow = + getFollowListFlow().transformLatest { + emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList)) + } + + val flow = + innerFlow + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + // this has priority. + buildKind3Follows(getFollowListEvent() ?: settings.backupContactList), + ) + + // Creates a long-term reference for all follows of a user + val userList = + flow + .map { kind3Follows -> + kind3Follows.authors.mapNotNull { + runCatching { cache.getOrCreateUser(it) }.getOrNull() + } + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + // this has priority. + flow.value.authors.mapNotNull { + runCatching { cache.getOrCreateUser(it) }.getOrNull() + }, + ) + + /** + This contains a big OR of everything the user wants to see in the a single feed. + */ + @Immutable + class Kind3Follows( + val authors: Set = emptySet(), + val authorsPlusMe: Set, + ) + + fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows { + // makes sure the output include only valid p tags + val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() + + return Kind3Follows( + authors = verifiedFollowingUsers, + authorsPlusMe = verifiedFollowingUsers + signer.pubKey, + ) + } + + suspend fun follow(users: List): ContactListEvent { + val contactList = getFollowListEvent() + + val contacts = + users.map { + ContactTag(it.pubkeyHex, it.bestRelayHint(), null) + } + + return if (contactList != null) { + ContactListEvent.followUsers(contactList, contacts, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = contacts, + relayUse = emptyMap(), + signer = signer, + ) + } + } + + suspend fun follow(user: User): ContactListEvent { + val contactList = getFollowListEvent() + + return if (contactList != null) { + ContactListEvent.followUser(contactList, user.pubkeyHex, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), + relayUse = emptyMap(), + signer = signer, + ) + } + } + + suspend fun unfollow(user: User): ContactListEvent? { + val contactList = getFollowListEvent() + + return if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser( + contactList, + user.pubkeyHex, + signer, + ) + } else { + null + } + } + + init { + settings.backupContactList?.let { + Log.d("AccountRegisterObservers") { "Loading saved ${it.tags.size} contacts" } + + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Kind 3 Collector Start") + getFollowListFlow().collect { + Log.d("AccountRegisterObservers") { "Updating Kind 3 ${signer.pubKey}" } + (it.note.event as? ContactListEvent)?.let { + settings.updateContactListTo(it) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt index 0bef42b0b..245e7f7a5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt @@ -49,11 +49,11 @@ sealed interface Nip05State { fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted) - suspend fun checkAndUpdate(nip05Client: INip05Client) { + suspend fun checkAndUpdate(nip05ClientBuilder: () -> INip05Client) { if (verificationState.value.isExpired()) { markAsVerifying() try { - if (nip05Client.verify(nip05, hexKey)) { + if (nip05ClientBuilder().verify(nip05, hexKey)) { markAsVerified() } else { markAsInvalid() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt new file mode 100644 index 000000000..d9c151b01 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.model.nip23LongContent + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Shared action for publishing long-form content (NIP-23 kind 30023). + * Handles title, summary, image, tags, and d-tag for addressable events. + */ +object LongFormPublishAction { + private const val MAX_CONTENT_BYTES = 100_000 + + /** + * Publishes a long-form text note (NIP-23 kind 30023). + * + * @param title The article title + * @param content The markdown body content + * @param summary Optional article summary + * @param image Optional banner image URL + * @param tags List of hashtag topics + * @param dTag Unique identifier for this addressable event (slug) + * @param signer The NostrSigner to sign the event + * @return Signed LongTextNoteEvent ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun publish( + title: String, + content: String, + summary: String?, + image: String?, + tags: List, + dTag: String, + signer: NostrSigner, + ): LongTextNoteEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot publish: signer is not writeable") + } + + if (content.toByteArray().size > MAX_CONTENT_BYTES) { + throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes") + } + + val template = + LongTextNoteEvent.build( + description = content, + title = title, + summary = summary, + image = image, + publishedAt = TimeUtils.now(), + dTag = dTag, + ) { + tags.forEach { hashtag(it) } + } + + return signer.sign(template) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt new file mode 100644 index 000000000..b326186bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.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.amethyst.commons.model.nip23LongContent + +import kotlin.math.ceil +import kotlin.math.max + +/** + * Calculates estimated reading time for markdown content. + * Uses 238 WPM for prose (Brysbaert 2019), 80 WPM for code blocks, + * and Medium's image decay formula (12 sec first, -1 each, min 3). + */ +object ReadingTimeCalculator { + private const val PROSE_WPM = 238.0 + private const val CODE_WPM = 80.0 + + private val WHITESPACE_REGEX = "\\s+".toRegex() + private val IMAGE_REGEX = Regex("!\\[.*?]\\(.*?\\)") + private val IMAGE_STRIP_REGEX = Regex("!\\[.*?]\\(.*?\\)") + private val LINK_REGEX = Regex("\\[([^]]*)]\\([^)]*\\)") + private val FORMATTING_REGEX = Regex("[*_~`#>]") + private val LIST_MARKER_REGEX = Regex("^-\\s+|^\\d+\\.\\s+") + private val HORIZONTAL_RULE_REGEX = Regex("^---+$|^\\*\\*\\*+$") + + fun calculate(markdownContent: String): Int { + var proseWords = 0 + var codeWords = 0 + var imageCount = 0 + var inCodeBlock = false + + markdownContent.lines().forEach { line -> + val trimmed = line.trim() + + if (trimmed.startsWith("```")) { + inCodeBlock = !inCodeBlock + return@forEach + } + + if (inCodeBlock) { + codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() } + return@forEach + } + + // Count images + val imageMatches = IMAGE_REGEX.findAll(trimmed) + imageCount += imageMatches.count() + + // Strip markdown syntax for word counting + val stripped = + trimmed + .replace(IMAGE_STRIP_REGEX, "") // images + .replace(LINK_REGEX, "$1") // links -> text only + .replace(FORMATTING_REGEX, "") // formatting + .replace(LIST_MARKER_REGEX, "") // list markers + .replace(HORIZONTAL_RULE_REGEX, "") // horizontal rules + + proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() } + } + + // Medium's image time decay: 12 sec first, -1 each, min 3 + val imageSeconds = (0 until imageCount).sumOf { max(12 - it, 3) } + + val totalMinutes = (proseWords / PROSE_WPM) + (codeWords / CODE_WPM) + (imageSeconds / 60.0) + return max(1, ceil(totalMinutes).toInt()) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt index 3b981be8a..2a676de5f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt @@ -129,7 +129,7 @@ class PublicChatListState( init { settings.channelList()?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}") + Log.d("AccountRegisterObservers") { "Loading saved channel list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(event) @@ -139,7 +139,7 @@ class PublicChatListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Channel List Collector Start") getChannelListFlow().collect { - Log.d("AccountRegisterObservers", "Channel List for ${signer.pubKey}") + Log.d("AccountRegisterObservers") { "Channel List for ${signer.pubKey}" } (it.note.event as? ChannelListEvent)?.let { settings.updateChannelListTo(it) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt new file mode 100644 index 000000000..c3fdee824 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip51Lists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +@Stable +class BookmarkListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + class BookmarkList( + val public: List = emptyList(), + val private: List = emptyList(), + ) + + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress()) + + fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey) + + fun getBookmarkListFlow(): StateFlow = bookmarkList.flow().metadata.stateFlow + + fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent + + fun publicBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.publicBookmarks() ?: emptyList() + } + + suspend fun privateBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.privateBookmarks(signer) ?: emptyList() + } + + @OptIn(FlowPreview::class) + val publicBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + publicBookmarks(noteState.note) + }.onStart { + emit(publicBookmarks(bookmarkList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + @OptIn(FlowPreview::class) + val privateBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + privateBookmarks(noteState.note) + }.onStart { + emit(privateBookmarks(bookmarkList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkEventIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkAddressIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkEventIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkAddressIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun bookmarkList( + privateBookmarks: List, + publicBookmarks: List, + ): BookmarkList = + BookmarkList( + public = + publicBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + private = + privateBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + ) + + @OptIn(FlowPreview::class) + val bookmarks: StateFlow = + combineTransform(privateBookmarks, publicBookmarks) { private, public -> + emit(bookmarkList(private, public)) + }.onStart { + emit(bookmarkList(privateBookmarks.value, publicBookmarks.value)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + BookmarkList(), + ) + + fun isInPrivateBookmarks(note: Note): Boolean { + if (!signer.isWriteable()) return false + + return if (note is AddressableNote) { + privateBookmarkAddressIdSet.value.contains(note.address) + } else { + privateBookmarkEventIdSet.value.contains(note.idHex) + } + } + + fun isInPublicBookmarks(note: Note): Boolean = + if (note is AddressableNote) { + publicBookmarkAddressIdSet.value.contains(note.address) + } else { + publicBookmarkEventIdSet.value.contains(note.idHex) + } + + suspend fun addBookmark( + note: Note, + isPrivate: Boolean, + ): BookmarkListEvent { + val bookmarkList = getBookmarkList() + + return if (bookmarkList == null) { + if (note is AddressableNote) { + BookmarkListEvent.create( + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.create( + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } else { + if (note is AddressableNote) { + BookmarkListEvent.add( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.add( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } + } + + suspend fun removeBookmark( + note: Note, + isPrivate: Boolean, + ): BookmarkListEvent? { + val bookmarkList = getBookmarkList() + + return if (bookmarkList != null) { + if (note is AddressableNote) { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } else { + null + } + } + + suspend fun removeBookmark(note: Note): BookmarkListEvent? { + val bookmarkList = getBookmarkList() + + return if (bookmarkList != null) { + if (note is AddressableNote) { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + signer = signer, + ) + } else { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + signer = signer, + ) + } + } else { + null + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt new file mode 100644 index 000000000..6b5889074 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.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.amethyst.commons.model.nip65RelayList + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Narrow repository interface for Nip65RelayListState's settings needs. + * Follows the established pattern of EphemeralChatRepository / PublicChatListRepository. + */ +interface Nip65RelayListRepository { + val backupNIP65RelayList: AdvertisedRelayListEvent? + + fun updateNIP65RelayList(event: AdvertisedRelayListEvent) + + /** Default relay set when no NIP-65 list is available (write relays). */ + val defaultOutboxRelays: Set + + /** Default relay set when no NIP-65 list is available (read relays). */ + val defaultInboxRelays: Set +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt new file mode 100644 index 000000000..f21bf18a7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.model.nip65RelayList + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class Nip65RelayListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: Nip65RelayListRepository, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val nip65ListNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress()) + + fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey) + + fun getNIP65RelayListFlow(): StateFlow = nip65ListNote.flow().metadata.stateFlow + + fun getNIP65RelayList(): AdvertisedRelayListEvent? = nip65ListNote.event as? AdvertisedRelayListEvent + + fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: settings.defaultOutboxRelays + + fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set = nip65Event(note)?.readRelaysNorm()?.toSet() ?: settings.defaultInboxRelays + + fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet() + + fun normalizeNIP65ReadRelayListNoDefaults(note: Note): Set = nip65Event(note)?.readRelaysNorm()?.toSet() ?: emptySet() + + fun normalizeNIP65AllRelayListWithBackup(note: Note): Set = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: settings.defaultOutboxRelays + + fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet() + + val outboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65WriteRelayListWithBackup(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val outboxFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListNoDefaults(it.note) } + .onStart { emit(normalizeNIP65WriteRelayListNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListNoDefaults(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val allFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65AllRelayListWithBackupNoDefaults(it.note) } + .onStart { emit(normalizeNIP65AllRelayListWithBackupNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(relays: List): AdvertisedRelayListEvent { + val nip65RelayList = getNIP65RelayList() + + return if (nip65RelayList != null) { + AdvertisedRelayListEvent.replaceRelayListWith( + earlierVersion = nip65RelayList, + newRelays = relays, + signer = signer, + ) + } else { + AdvertisedRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + ) + } + } + + init { + settings.backupNIP65RelayList?.let { + Log.d("AccountRegisterObservers") { "Loading saved nip65 relay list ${it.toJson()}" } + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") + getNIP65RelayListFlow().collect { + Log.d("AccountRegisterObservers") { "Updating NIP-65 List for ${signer.pubKey}" } + (it.note.event as? AdvertisedRelayListEvent)?.let { + settings.updateNIP65RelayList(it) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt index 09e5b58d8..9117d13cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.model.observables +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import java.util.SortedSet @@ -31,18 +33,27 @@ import java.util.concurrent.ConcurrentSkipListSet * that is updated every time a new event that matches * the filter is received, including addressables. */ -class EventListMatchingFilter( +class EventListMatchingFilter( private val filter: Filter, private val atOnce: (filter: Filter) -> SortedSet, - private val update: (List) -> Unit, + private val update: (List) -> Unit, ) : Observable { // Keeping this here blocks it from being cleared from memory var currentResults: ConcurrentSkipListSet = ConcurrentSkipListSet(CreatedAtIdHexComparator) + @Suppress("UNCHECKED_CAST") override fun new( event: Event, note: Note, ) { + if (event is AddressableEvent && note !is AddressableNote) { + // event update + if (currentResults.contains(note)) { + update(currentResults.mapNotNull { it.event as? T }) + } + return + } + if (filter.match(event)) { currentResults.add(note) val limit = filter.limit @@ -50,18 +61,20 @@ class EventListMatchingFilter( currentResults.remove(currentResults.last()) } - update(currentResults.mapNotNull { it.event }) + update(currentResults.mapNotNull { it.event as? T }) } } + @Suppress("UNCHECKED_CAST") override fun remove(note: Note) { if (currentResults.remove(note)) { - update(currentResults.mapNotNull { it.event }) + update(currentResults.mapNotNull { it.event as? T }) } } + @Suppress("UNCHECKED_CAST") fun init() { currentResults = ConcurrentSkipListSet(atOnce(filter)) - update(currentResults.mapNotNull { it.event }) + update(currentResults.mapNotNull { it.event as? T }) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt index f94bed11c..e938fb288 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.model.observables +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import java.util.SortedSet @@ -43,6 +45,8 @@ class NoteListMatchingFilter( event: Event, note: Note, ) { + if (event is AddressableEvent && note !is AddressableNote) return + if (filter.match(event)) { if (currentResults.add(note)) { val limit = filter.limit diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/TrustProviderListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/TrustProviderListState.kt index 3c999c631..53b197aa3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/TrustProviderListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/TrustProviderListState.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.trustedAssertions -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import kotlinx.coroutines.flow.StateFlow /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt index dcd32f4ed..c8970683e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.UserDependencies import com.vitorpamplona.amethyst.commons.relays.EOSERelayList import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter -import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combineTransform diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt index 9fb20786d..228b71024 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.commons.preview import androidx.compose.runtime.Immutable -import java.net.URL +import java.net.URI @Immutable class UrlInfoItem( @@ -31,10 +31,16 @@ class UrlInfoItem( val image: String = "", val mimeType: String, ) { - val verifiedUrl = runCatching { URL(url) }.getOrNull() + val verifiedUrl = runCatching { URI(url).toURL() }.getOrNull() val imageUrlFullPath = if (image.startsWith("/")) { - URL(verifiedUrl, image).toString() + runCatching { + verifiedUrl + ?.toURI() + ?.resolve(image) + ?.toURL() + ?.toString() + }.getOrNull() ?: image } else { image } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index c3b836af9..4b62fc9ab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener 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 @@ -79,7 +79,7 @@ class FeedMetadataCoordinator( // Create listener to pass events to the callback val listener = if (onEvent != null) { - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -93,7 +93,7 @@ class FeedMetadataCoordinator( null } - client.openReqSubscription( + client.subscribe( subId = newSubId(), filters = filterMap, listener = listener, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index b7787ff5c..06851efea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -20,14 +20,15 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers +import androidx.compose.runtime.Stable import java.util.concurrent.ConcurrentHashMap -import kotlin.collections.forEach /** * This allows composables to directly register their queries * to relays. There may be multiple duplications in these * subscriptions since we do not control when screens are removed. */ +@Stable abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControls, Subscribable { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index eff5f8909..da8e82276 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers +import androidx.compose.runtime.Stable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -36,6 +37,7 @@ import java.util.concurrent.ConcurrentHashMap * also allows the subscription itself to change over time as a * flow, which trigger an update on the relay subscriptions */ +@Stable abstract class MutableComposeSubscriptionManager( val scope: CoroutineScope, ) : ComposeSubscriptionManagerControls { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 8d0393d38..427d34195 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.service.BundledUpdate import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController import kotlinx.coroutines.Dispatchers @@ -38,7 +38,7 @@ abstract class BaseEoseManager( fun getSubscription(subId: String) = orchestrator.getSub(subId) - fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubId(), listener) + fun requestNewSubscription(listener: SubscriptionListener) = orchestrator.requestNewSubscription(newSubId(), listener) fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt index 469227aa1..b609d52dc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -82,7 +82,7 @@ abstract class PerKeyEoseManager( */ open fun newSub(queryState: T): Subscription = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt index 6f31c7421..8c012cc94 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.TimeUtils @@ -66,7 +66,7 @@ abstract class SingleSubEoseManager( val sub = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index c1dd4975a..bfc0b30cf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists -import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -40,8 +39,8 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentList import java.net.MalformedURLException +import java.net.URI import java.net.URISyntaxException -import java.net.URL import kotlin.coroutines.cancellation.CancellationException class RichTextParser { @@ -424,12 +423,18 @@ class RichTextParser { fun isValidURL(url: String?): Boolean = try { - URL(url).toURI() - true + if (url != null) { + URI(url).toURL() + true + } else { + false + } } catch (e: MalformedURLException) { false } catch (e: URISyntaxException) { false + } catch (e: IllegalArgumentException) { + false } fun parseImageOrVideo(fullUrl: String): BaseMediaContent { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 6a493dd04..66edbae1e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -167,7 +167,7 @@ class RobohashAssembler { if (Hex.isHex(msg) && msg.length > 10) { Hex.decode(msg) } else { - Log.w("Robohash", "$msg is not a hex") + Log.w("Robohash") { "$msg is not a hex" } sha256(msg.toByteArray()) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.kt index 06b41ee58..875250fff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.kt @@ -39,7 +39,7 @@ inline fun logTime( if (isDebug) { val (result, elapsed) = measureTimedValue(block) if (elapsed.inWholeMilliseconds > minToReportMs) { - Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage") + Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage" } } result } else { @@ -54,7 +54,7 @@ inline fun logTime( if (isDebug) { val (result, elapsed) = measureTimedValue(block) if (elapsed.inWholeMilliseconds > minToReportMs) { - Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}") + Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}" } } result } else { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index f9b1086bb..f308bf5b8 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 @@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note -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 @@ -95,7 +94,7 @@ class ChatNewMessageState( if (currentRoom != null) { _recipientsMissingDmRelays.value = currentRoom.users.any { hexKey -> - val user = cache.getOrCreateUser(hexKey) as? User + val user = cache.getOrCreateUser(hexKey) user?.dmInboxRelays().isNullOrEmpty() } } else { @@ -141,7 +140,7 @@ class ChatNewMessageState( ) { val pTags = room.users.mapNotNull { hexKey -> - (cache.getOrCreateUser(hexKey) as? User)?.toPTag() + cache.getOrCreateUser(hexKey)?.toPTag() } val replyHint = _replyTo.value?.toEventHint() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt index 35c088b47..47ff0d18f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt @@ -49,24 +49,24 @@ abstract class FeedViewModel( override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) init { - Log.d("Init", "Starting new Model: ${this::class.simpleName}") + Log.d("Init") { "Starting new Model: ${this::class.simpleName}" } viewModelScope.launch(Dispatchers.IO) { cacheProvider.getEventStream().newEventBundles.collect { newNotes -> - Log.d("Rendering Metrics", "Update feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}") + Log.d("Rendering Metrics") { "Update feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}" } feedState.updateFeedWith(newNotes) } } viewModelScope.launch(Dispatchers.IO) { cacheProvider.getEventStream().deletedEventBundles.collect { newNotes -> - Log.d("Rendering Metrics", "Delete from feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}") + Log.d("Rendering Metrics") { "Delete from feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}" } feedState.deleteFromFeed(newNotes) } } } override fun onCleared() { - Log.d("Init", "OnCleared: ${this::class.simpleName}") + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } super.onCleared() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt index 4bce8f7a1..7e8f5ec8c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt @@ -46,14 +46,14 @@ abstract class ListChangeFeedViewModel( override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) init { - Log.d("Init", "Starting new Model: ${this::class.simpleName}") + Log.d("Init") { "Starting new Model: ${this::class.simpleName}" } // Trigger initial load so empty rooms show Empty instead of Loading viewModelScope.launch(Dispatchers.IO) { feedState.invalidateData(ignoreIfDoing = false) } viewModelScope.launch(Dispatchers.IO) { localFilter.changesFlow().collect { - Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}") + Log.d("Init") { "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}" } when (it) { is ListChange.Addition -> feedState.updateFeedWith(setOf(it.item)) is ListChange.Deletion -> feedState.deleteFromFeed(setOf(it.item)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt index cdbf2e0d3..657758c47 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt @@ -88,8 +88,7 @@ class SearchBarState( .debounce(debounceMs) .onEach { query -> if (query.length >= 2 && _bech32Results.value.isEmpty()) { - @Suppress("UNCHECKED_CAST") - _cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List + _cachedUserResults.value = cache.findUsersStartingWith(query, 20) } else { _cachedUserResults.value = emptyList() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt index 2a95a8445..9f9a48e8c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener 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 @@ -43,7 +43,7 @@ data class BroadcastResult( /** * Helper for broadcasting chess events to relays with reliable delivery. * - * Uses sendAndWaitForResponse to get actual OK confirmations from relays, + * Uses publishAndConfirm to get actual OK confirmations from relays, * ensuring the event was actually received and accepted. */ class ChessEventBroadcaster( @@ -89,7 +89,7 @@ class ChessEventBroadcaster( ) val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -105,22 +105,22 @@ class ChessEventBroadcaster( // Open subscription to all target relays (triggers connection) val filterMap = targetRelays.associateWith { listOf(dummyFilter) } - client.openReqSubscription(subId, filterMap, listener) + client.subscribe(subId, filterMap, listener) // Wait for relays to connect (poll with timeout) waitForRelays(targetRelays, 5000L) // Close the dummy subscription - client.close(subId) + client.unsubscribe(subId) } // Step 3: Send the event and wait for OK responses Log.d("chessdebug", "[Broadcaster] sending event ${event.id.take(8)} and waiting for OK (timeout=${timeoutSeconds}s)") - val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds) + val success = client.publishAndConfirm(event, targetRelays, timeoutSeconds) Log.d("chessdebug", "[Broadcaster] broadcast result: success=$success for event ${event.id.take(8)}") - // Note: sendAndWaitForResponse only returns aggregate success (any relay accepted) + // Note: publishAndConfirm only returns aggregate success (any relay accepted) // We don't have per-relay results, so relayResults is empty return BroadcastResult( success = success, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt index bc7c6cce3..71bd3ba9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.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.amethyst.model +package com.vitorpamplona.amethyst.commons.model.cache import com.vitorpamplona.quartz.utils.cache.CacheOperations import java.lang.ref.WeakReference @@ -59,7 +59,7 @@ class LargeSoftCache : CacheOperations { /** * Puts an object into the cache with a specified key. - * The object is stored as a SoftReference. + * The object is stored as a WeakReference. * * @param key The key to associate with the object. * @param value The object to cache. @@ -105,19 +105,16 @@ class LargeSoftCache : CacheOperations { /** * Proactively cleans up the cache by removing entries whose weakly referenced - * objects have been garbage collected. While `get` handles cleanup on access, - * this method can be called periodically or when memory pressure is high. + * objects have been garbage collected. Single-pass iterator for efficiency. */ fun cleanUp() { - val keysToRemove = mutableMapOf>() - cache.forEach { key, softRef -> - if (softRef.get() == null) { - keysToRemove.put(key, softRef) + val iter = cache.entries.iterator() + while (iter.hasNext()) { + val entry = iter.next() + if (entry.value.get() == null) { + iter.remove() } } - keysToRemove.forEach { key, value -> - cache.remove(key, value) - } } override fun forEach(consumer: BiConsumer) { diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index eab43327e..0ea2cde8f 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -80,6 +80,8 @@ compose.desktop { mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED" + jvmArgs += "-Xmx2g" + nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) @@ -117,5 +119,5 @@ vlcSetup { } tasks.named("spotlessKotlin") { - inputs.files(tasks.named("vlcSetup")) + mustRunAfter("vlcSetup") } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ClipboardExt.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ClipboardExt.kt new file mode 100644 index 000000000..6221c2d0d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ClipboardExt.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 + +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +suspend fun Clipboard.setText(text: String) { + setClipEntry(ClipEntry(StringSelection(text))) +} + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +suspend fun Clipboard.getText(): String? { + val entry = getClipEntry() ?: return null + val transferable = entry.nativeClipEntry as? Transferable ?: return null + return try { + transferable.getTransferData(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } +} 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 3d106a9e4..ee2123873 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -77,6 +77,7 @@ 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.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator @@ -100,6 +101,8 @@ 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 com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.LogLevel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -143,10 +146,21 @@ sealed class DesktopScreen { val noteId: String, ) : DesktopScreen() + data class Article( + val addressTag: String, + ) : DesktopScreen() + + data class Editor( + val draftSlug: String? = null, + ) : DesktopScreen() + + data object Drafts : DesktopScreen() + data object Settings : DesktopScreen() } fun main() { + Log.minLevel = LogLevel.DEBUG DesktopImageLoaderSetup.setup() Runtime.getRuntime().addShutdownHook( Thread { @@ -369,6 +383,8 @@ fun main() { Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) }) + Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) }) Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) @@ -444,9 +460,17 @@ fun App( RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), localCache = localCache, - ) + ).also { it.startCleanupLoop() } } + // Clear cache and subscriptions on logout + LaunchedEffect(accountState) { + if (accountState is AccountState.LoggedOut) { + subscriptionsCoordinator.clear() + localCache.clear() + } + } + // Try to load saved account on startup DisposableEffect(Unit) { relayManager.addDefaultRelays() @@ -597,6 +621,13 @@ fun MainContent( DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) } + val highlightStore = remember { DesktopHighlightStore(appScope) } + val draftStore = + remember { + com.vitorpamplona.amethyst.desktop.service.drafts + .DesktopDraftStore(appScope) + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } @@ -708,6 +739,7 @@ fun MainContent( Row(Modifier.fillMaxSize().weight(1f)) { when (layoutMode) { LayoutMode.SINGLE_PANE -> { + val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState() SinglePaneLayout( relayManager = relayManager, localCache = localCache, @@ -716,12 +748,15 @@ fun MainContent( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, + lastRelayEventAt = lastRelayEvent, modifier = Modifier.weight(1f), ) } @@ -753,6 +788,8 @@ fun MainContent( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 7fab08fbc..7dd9b9f8e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -30,8 +30,9 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command @@ -134,7 +135,7 @@ class AccountManager internal constructor( private val nip46ClientMutex = Mutex() private var nip46Client: NostrClient? = null - private suspend fun getOrCreateNip46Client(): NostrClient = + private suspend fun getOrCreateNip46Client(): INostrClient = nip46ClientMutex.withLock { nip46Client ?: NostrClient( BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), @@ -156,7 +157,7 @@ class AccountManager internal constructor( * but we must wait for the websocket to be ready before sending requests. */ private suspend fun awaitNip46RelayConnection( - client: NostrClient, + client: INostrClient, targetRelays: Set, ) { withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) { @@ -180,8 +181,8 @@ class AccountManager internal constructor( } } - private fun createLoginRelayListener(): IRelayClientListener = - object : IRelayClientListener { + private fun createLoginRelayListener(): RelayConnectionListener = + object : RelayConnectionListener { override fun onConnected( relay: IRelayClient, pingMillis: Int, @@ -303,7 +304,7 @@ class AccountManager internal constructor( suspend fun loginWithBunker(bunkerUri: String): Result { val listener = createLoginRelayListener() - var client: NostrClient? = null + var client: INostrClient? = null try { val ephemeralKeyPair = KeyPair() val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) @@ -316,7 +317,7 @@ class AccountManager internal constructor( LoginProgress.ConnectingToRelays( relaysFromUri.associateWith { RelayLoginStatus.CONNECTING }, ) - nip46Client.subscribe(listener) + nip46Client.addConnectionListener(listener) _loginProgress.value = LoginProgress.WaitingForSigner( @@ -356,7 +357,7 @@ class AccountManager internal constructor( return Result.failure(Exception("Connection failed: ${e.message}")) } finally { _loginProgress.value = null - client?.unsubscribe(listener) + client?.removeConnectionListener(listener) } } @@ -364,7 +365,7 @@ class AccountManager internal constructor( suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result { val listener = createLoginRelayListener() - var client: NostrClient? = null + var client: INostrClient? = null try { val ephemeralKeyPair = KeyPair() val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop") @@ -376,7 +377,7 @@ class AccountManager internal constructor( LoginProgress.ConnectingToRelays( uriData.relays.associateWith { RelayLoginStatus.CONNECTING }, ) - nip46Client.subscribe(listener) + nip46Client.addConnectionListener(listener) onUriGenerated(uriData.uri) @@ -415,7 +416,7 @@ class AccountManager internal constructor( return Result.failure(Exception("Connection failed: ${e.message}")) } finally { _loginProgress.value = null - client?.unsubscribe(listener) + client?.removeConnectionListener(listener) } } 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 b41bda8de..319ec94db 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 @@ -26,20 +26,34 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event 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.nip01Core.tags.aTag.taggedAddresses +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -50,28 +64,34 @@ import java.util.concurrent.ConcurrentHashMap * Supports searching users by name prefix for the search functionality. */ class DesktopLocalCache : ICacheProvider { - private val users = ConcurrentHashMap() - private val notes = ConcurrentHashMap() - private val addressableNotes = ConcurrentHashMap() + val users = LargeSoftCache() + val notes = LargeSoftCache() + val addressableNotes = LargeSoftCache() private val deletedEvents = ConcurrentHashMap.newKeySet() - private val eventStream = DesktopCacheEventStream() + val eventStream = DesktopCacheEventStream() + + /** Cached follow set for the logged-in user. Thread-safe + Compose-observable. */ + private val _followedUsers = MutableStateFlow>(emptySet()) + val followedUsers: StateFlow> = _followedUsers.asStateFlow() + + companion object { + } val paymentTracker = NwcPaymentTracker() // ----- User operations ----- - override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey) override fun getOrCreateUser(pubkey: HexKey): User = - users.getOrPut(pubkey) { - // Create placeholder notes for relay lists + users.getOrCreate(pubkey) { val nip65Note = getOrCreateNote("nip65:$pubkey") val dmNote = getOrCreateNote("dm:$pubkey") User(pubkey, nip65Note, dmNote) } - override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) } + override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) } override fun findUsersStartingWith( prefix: String, @@ -92,9 +112,10 @@ class DesktopLocalCache : ICacheProvider { ) // Search by name/displayName/nip05/lud16 - return users.values - .filter { user -> - val metadata = user.metadataOrNull() + val results = mutableListOf() + users.forEach { _, user -> + val metadata = user.metadataOrNull() + val matches = if (metadata == null) { user.pubkeyHex.startsWith(prefix, true) || user.pubkeyNpub().startsWith(prefix, true) @@ -103,7 +124,10 @@ class DesktopLocalCache : ICacheProvider { user.pubkeyHex.startsWith(prefix, true) || user.pubkeyNpub().startsWith(prefix, true) } - }.sortedWith( + if (matches) results.add(user) + } + return results + .sortedWith( compareBy( { it.metadataOrNull()?.anyNameStartsWith(dualCase) == false }, { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false }, @@ -128,6 +152,216 @@ class DesktopLocalCache : ICacheProvider { } } + // ----- Event consumption ----- + + /** + * Routes an event to the appropriate consume method. + * Returns true if the event was consumed (new), false if already seen. + */ + fun consume( + event: Event, + relay: NormalizedRelayUrl?, + ): Boolean = + when (event) { + is MetadataEvent -> { + consumeMetadata(event) + true + } + + is TextNoteEvent -> { + consumeTextNote(event, relay) + } + + is ReactionEvent -> { + consumeReaction(event, relay) + } + + is LnZapRequestEvent -> { + consumeZapRequest(event, relay) + } + + is LnZapEvent -> { + consumeZap(event, relay) + } + + is RepostEvent -> { + consumeRepost(event, relay) + } + + is ContactListEvent -> { + consumeContactList(event) + } + + is LongTextNoteEvent -> { + consumeLongTextNote(event, relay) + } + + is BookmarkListEvent -> { + consumeBookmarkList(event) + } + + else -> { + false + } + } + + /** + * Consumes a kind 1 text note event. + * Creates/updates Note in cache and links reply relationships. + */ + private fun consumeTextNote( + event: TextNoteEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) } + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + repliesTo.forEach { it.addReply(note) } + return true + } + + /** + * Consumes a kind 7 reaction event. + * Links reaction to target notes via e-tags and a-tags. + */ + private fun consumeReaction( + event: ReactionEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val reactedTo = + event.originalPost().mapNotNull { getNoteIfExists(it) } + + event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) } + note.loadEvent(event, author, reactedTo) + relay?.let { note.addRelay(it) } + reactedTo.forEach { it.addReaction(note) } + return true + } + + /** + * Consumes a kind 9734 zap request event. + * Must be consumed before the corresponding LnZapEvent (kind 9735). + */ + private fun consumeZapRequest( + event: LnZapRequestEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + return true + } + + /** + * Consumes a kind 9735 zap receipt event. + * Links zap to target notes via the embedded zap request. + */ + private fun consumeZap( + event: LnZapEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + + // Get or consume the embedded zap request + val zapRequestEvent = event.zapRequest + val zapRequestNote = + if (zapRequestEvent != null) { + consumeZapRequest(zapRequestEvent, relay) + getOrCreateNote(zapRequestEvent.id) + } else { + null + } + + val zappedNotes = + event.zappedPost().mapNotNull { getNoteIfExists(it) } + + event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) } + + note.loadEvent(event, author, zappedNotes) + relay?.let { note.addRelay(it) } + + // Link zap to target notes + if (zapRequestNote != null) { + zappedNotes.forEach { it.addZap(zapRequestNote, note) } + } + + return true + } + + /** + * Consumes a kind 6 repost event. + * Links repost to target note via e-tag. + */ + private fun consumeRepost( + event: RepostEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val boostedNote = event.boostedEventId()?.let { getNoteIfExists(it) } + val repliesTo = listOfNotNull(boostedNote) + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + boostedNote?.addBoost(note) + return true + } + + /** + * Consumes a kind 3 contact list event (replaceable). + * Updates the cached followedUsers set. + */ + private var lastContactListCreatedAt = 0L + + private fun consumeContactList(event: ContactListEvent): Boolean { + // Replaceable event — only accept newer contact lists + if (event.createdAt <= lastContactListCreatedAt) return false + lastContactListCreatedAt = event.createdAt + _followedUsers.value = event.verifiedFollowKeySet() + return true + } + + /** + * Consumes a kind 30023 long-form text note event. + * Creates Note in cache like TextNoteEvent. + */ + private fun consumeLongTextNote( + event: LongTextNoteEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + return true + } + + /** + * Consumes a kind 30001 bookmark list event (addressable/replaceable). + * Stores in addressableNotes cache. + */ + private fun consumeBookmarkList(event: BookmarkListEvent): Boolean { + val address = event.address() + val addressableNote = getOrCreateAddressableNote(address) + val author = getOrCreateUser(event.pubKey) + + // Only update if newer + val existingEvent = addressableNote.event + if (existingEvent != null && existingEvent.createdAt >= event.createdAt) return false + + addressableNote.loadEvent(event, author, emptyList()) + return true + } + // ----- NWC Payment operations ----- /** @@ -199,17 +433,17 @@ class DesktopLocalCache : ICacheProvider { // ----- Note operations ----- - override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey] + override fun getNoteIfExists(hexKey: HexKey): Note? = notes.get(hexKey) override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey) fun getOrCreateNote(hexKey: HexKey): Note = - notes.getOrPut(hexKey) { + notes.getOrCreate(hexKey) { Note(hexKey) } override fun getOrCreateAddressableNote(key: Address): AddressableNote = - addressableNotes.getOrPut(key.toValue()) { + addressableNotes.getOrCreate(key.toValue()) { AddressableNote(key) } @@ -258,17 +492,51 @@ class DesktopLocalCache : ICacheProvider { eventStream.emitDeletedNotes(notes) } + // ----- Profile count cache ----- + + private val followerCounts = ConcurrentHashMap() + private val followingCounts = ConcurrentHashMap() + + fun getCachedFollowerCount(pubkey: HexKey): Int = followerCounts[pubkey] ?: 0 + + fun getCachedFollowingCount(pubkey: HexKey): Int = followingCounts[pubkey] ?: 0 + + fun cacheFollowerCount( + pubkey: HexKey, + count: Int, + ) { + followerCounts[pubkey] = count + } + + fun cacheFollowingCount( + pubkey: HexKey, + count: Int, + ) { + followingCounts[pubkey] = count + } + + // ----- Memory Cleanup ----- + + fun cleanMemory() { + notes.cleanUp() + addressableNotes.cleanUp() + users.cleanUp() + } + // ----- Stats ----- - fun userCount(): Int = users.size + fun userCount(): Int = users.size() - fun noteCount(): Int = notes.size + fun noteCount(): Int = notes.size() fun clear() { users.clear() notes.clear() addressableNotes.clear() deletedEvents.clear() + _followedUsers.value = emptySet() + followerCounts.clear() + followingCounts.clear() } } @@ -276,8 +544,18 @@ class DesktopLocalCache : ICacheProvider { * Desktop implementation of ICacheEventStream. */ class DesktopCacheEventStream : ICacheEventStream { - private val _newEventBundles = MutableSharedFlow>(replay = 0) - private val _deletedEventBundles = MutableSharedFlow>(replay = 0) + private val _newEventBundles = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val _deletedEventBundles = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) override val newEventBundles: SharedFlow> = _newEventBundles override val deletedEventBundles: SharedFlow> = _deletedEventBundles diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt new file mode 100644 index 000000000..8f87d901a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.feeds + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +/** + * Global feed: all kind 1 text notes, sorted by createdAt desc. + */ +class DesktopGlobalFeedFilter( + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "global" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> note.event is TextNoteEvent } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is TextNoteEvent } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 2500 +} + +/** + * Following feed: kind 1 text notes from followed pubkeys. + */ +class DesktopFollowingFeedFilter( + private val cache: DesktopLocalCache, + private val followedPubkeys: () -> Set, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "following-${followedPubkeys().hashCode()}" + + override fun feed(): List { + val follows = followedPubkeys() + return cache.notes + .filterIntoSet { _, note -> + note.event is TextNoteEvent && note.author?.pubkeyHex in follows + }.sortedWith(DefaultFeedOrder) + .take(limit()) + } + + override fun applyFilter(newItems: Set): Set { + val follows = followedPubkeys() + return newItems.filterTo(HashSet()) { + it.event is TextNoteEvent && it.author?.pubkeyHex in follows + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 2500 +} + +/** + * Thread feed: root note + all replies (graph walk via Note.replies). + */ +class DesktopThreadFilter( + private val noteId: HexKey, + private val cache: DesktopLocalCache, +) : FeedFilter() { + override fun feedKey(): String = "thread-$noteId" + + override fun feed(): List { + val root = cache.getNoteIfExists(noteId) ?: return emptyList() + // Use LinkedHashSet for O(1) containment checks (was O(R) with MutableList) + val seen = LinkedHashSet() + seen.add(root) + collectReplies(root, seen) + return seen.sortedWith(compareBy { it.createdAt() ?: 0 }) + } + + private fun collectReplies( + note: Note, + seen: LinkedHashSet, + ) { + for (reply in note.replies) { + if (seen.add(reply)) { + collectReplies(reply, seen) + } + } + } + + override fun limit(): Int = Int.MAX_VALUE +} + +/** + * Profile feed: all kind 1 notes by a specific pubkey. + */ +class DesktopProfileFeedFilter( + private val pubkey: HexKey, + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "profile-$pubkey" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> + note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey + }.sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 1000 +} + +/** + * Bookmark feed: notes by ID set (from BookmarkListEvent). + */ +class DesktopBookmarkFeedFilter( + private val bookmarkedIds: () -> Set, + private val cache: DesktopLocalCache, +) : FeedFilter() { + override fun feedKey(): String = "bookmarks-${bookmarkedIds().hashCode()}" + + override fun feed(): List = + bookmarkedIds() + .mapNotNull { cache.getNoteIfExists(it) } + .filter { it.event != null } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun limit(): Int = 2500 +} + +/** + * Reads feed: kind 30023 long-form content. + */ +class DesktopReadsFeedFilter( + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "reads" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> note.event is LongTextNoteEvent } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 500 +} + +/** + * Notification feed: events that tag the logged-in user. + * Includes reactions, zaps, replies, reposts targeting the user's notes. + */ +class DesktopNotificationFeedFilter( + private val userPubKeyHex: HexKey, + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + companion object { + val NOTIFICATION_KINDS = + setOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + LnZapEvent.KIND, + ) + } + + override fun feedKey(): String = "notifications-$userPubKeyHex" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> isNotificationForUser(note) } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isNotificationForUser(it) } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 2500 + + private fun isNotificationForUser(note: Note): Boolean { + val event = note.event ?: return false + return event.kind in NOTIFICATION_KINDS && + event.pubKey != userPubKeyHex && + event.isTaggedUser(userPubKeyHex) + } +} + +/** + * Search feed: notes matching a text query (content search). + * Results are populated by relay search subscriptions that route through cache. + */ +class DesktopSearchFeedFilter( + private val query: String, + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "search-$query" + + override fun feed(): List { + val lowerQuery = query.lowercase() + return cache.notes + .filterIntoSet { _, note -> + val event = note.event ?: return@filterIntoSet false + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + }.sortedWith(DefaultFeedOrder) + .take(limit()) + } + + override fun applyFilter(newItems: Set): Set { + val lowerQuery = query.lowercase() + return newItems.filterTo(HashSet()) { + val event = it.event + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 500 +} 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 5e3108b2e..9efb98238 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 @@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.INwcSignerState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListRepository +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListState +import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository +import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -31,6 +36,7 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -42,6 +48,8 @@ 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 +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -62,10 +70,43 @@ class DesktopIAccount( val dmSendTracker: DmSendTracker, private val scope: CoroutineScope, ) : IAccount { - override val signer: NostrSigner = accountState.signer + override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME) override val pubKey: String = accountState.pubKeyHex + // ----- State Classes (pin important notes via strong refs for GC retention) ----- + + val bookmarkState = BookmarkListState(signer, localCache, scope) + + val kind3FollowList = + Kind3FollowListState( + signer, + localCache, + scope, + object : Kind3FollowListRepository { + override val backupContactList: ContactListEvent? = null + + override fun updateContactListTo(event: ContactListEvent) { /* no persistence yet */ } + }, + ) + + val nip65RelayList = + Nip65RelayListState( + signer, + localCache, + scope, + object : Nip65RelayListRepository { + override val backupNIP65RelayList: AdvertisedRelayListEvent? = null + + override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ } + + override val defaultOutboxRelays = relayManager.connectedRelays.value + override val defaultInboxRelays = relayManager.connectedRelays.value + }, + ) + + // --------------------------------------------------------------------------------- + override val showSensitiveContent: Boolean? = null override val hiddenWordsCase: List = emptyList() @@ -96,7 +137,7 @@ class DesktopIAccount( override fun isWriteable(): Boolean = !accountState.isReadOnly - override fun followingKeySet(): Set = emptySet() + override fun followingKeySet(): Set = kind3FollowList.flow.value.authors override fun isHidden(user: User): Boolean = false @@ -221,4 +262,8 @@ class DesktopIAccount( } chatroomList.addMessage(roomKey, note) } + + companion object { + const val CLIENT_TAG_NAME = "Amethyst" + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt index 1f98c52ef..c93ff1056 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.desktop.network import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command @@ -43,7 +43,7 @@ import kotlinx.coroutines.flow.asStateFlow */ open class RelayConnectionManager( websocketBuilder: WebsocketBuilder, -) : IRelayClientListener { +) : RelayConnectionListener { private val _client = NostrClient(websocketBuilder) /** Exposes the underlying INostrClient for subscription coordinators */ @@ -56,7 +56,7 @@ open class RelayConnectionManager( val availableRelays: StateFlow> = _client.availableRelaysFlow() init { - _client.subscribe(this) + _client.addConnectionListener(this) } fun connect() { @@ -85,21 +85,21 @@ open class RelayConnectionManager( subId: String, filters: List, relays: Set = availableRelays.value, - listener: IRequestListener? = null, + listener: SubscriptionListener? = null, ) { val filterMap = relays.associateWith { filters } - _client.openReqSubscription(subId, filterMap, listener) + _client.subscribe(subId, filterMap, listener) } fun unsubscribe(subId: String) { - _client.close(subId) + _client.unsubscribe(subId) } - fun send( + fun publish( event: Event, relays: Set = connectedRelays.value, ) { - _client.send(event, relays) + _client.publish(event, relays) } /** @@ -107,21 +107,21 @@ open class RelayConnectionManager( */ fun broadcastToAll(event: Event) { val connected = connectedRelays.value - send(event, connected) + publish(event, connected) } /** * Sends an event to a specific relay (for NWC). * Adds the relay if not already in the list. */ - fun sendToRelay( + fun publishToRelay( relay: NormalizedRelayUrl, event: Event, ) { if (relay !in availableRelays.value) { updateRelayStatus(relay) { it.copy(connected = false, error = null) } } - _client.send(event, setOf(relay)) + _client.publish(event, setOf(relay)) } /** @@ -138,11 +138,11 @@ open class RelayConnectionManager( updateRelayStatus(relay) { it.copy(connected = false, error = null) } } val filterMap = mapOf(relay to filters) - _client.openReqSubscription( + _client.subscribe( subId = subId, filters = filterMap, listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -162,7 +162,7 @@ open class RelayConnectionManager( relay: NormalizedRelayUrl, subId: String, ) { - _client.close(subId) + _client.unsubscribe(subId) } private fun updateRelayStatus( 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 d535cd7ae..e53804571 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 @@ -103,7 +103,7 @@ class NwcPaymentHandler( zappedNote?.addZapPayment(requestNote, null) // Send request to wallet's relay - relayManager.sendToRelay(nwcConnection.relayUri, requestEvent) + relayManager.publishToRelay(nwcConnection.relayUri, requestEvent) // Subscribe and wait for response with timeout return withTimeoutOrNull(timeoutMs) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt new file mode 100644 index 000000000..ac0dbaade --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.drafts + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant + +data class DraftMetadata( + val title: String = "", + val summary: String? = null, + val image: String? = null, + val tags: List = emptyList(), + val createdAt: String = Instant.now().toString(), + val updatedAt: String = Instant.now().toString(), + val published: Boolean = false, +) + +data class DraftEntry( + val slug: String, + val metadata: DraftMetadata, +) + +/** + * Local draft storage for long-form articles. + * Stores markdown content as .md files and metadata in index.json. + * Uses atomic writes and restrictive file permissions. + */ +class DesktopDraftStore( + private val scope: CoroutineScope, +) { + private val mapper = jacksonObjectMapper() + private val mutex = Mutex() + private var cachedIndex: MutableMap? = null + + private val _drafts = MutableStateFlow>(emptyList()) + val drafts: StateFlow> = _drafts.asStateFlow() + + private val draftsDir: File by lazy { + val dir = File(System.getProperty("user.home"), ".amethyst/drafts") + if (!dir.exists()) { + dir.mkdirs() + setDirPermissions(dir) + } + dir + } + + private val indexFile: File get() = File(draftsDir, "index.json") + + init { + scope.launch(Dispatchers.IO) { + cachedIndex = null + loadIndex() + } + } + + /** + * Sanitizes a slug to prevent path traversal and ensure safe filenames. + */ + private fun sanitizeSlug(slug: String): String { + val sanitized = + slug + .replace("/", "") + .replace("\\", "") + .replace("\u0000", "") + .trim() + .lowercase() + .replace(Regex("[^a-z0-9_-]"), "-") + .replace(Regex("-+"), "-") + .trimStart('-') + .trimEnd('-') + .take(128) + + require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" } + + // Validate canonical path stays within drafts dir + val resolved = File(draftsDir, "$sanitized.md").canonicalPath + require(resolved.startsWith(draftsDir.canonicalPath)) { + "Slug resolves outside drafts directory" + } + + return sanitized + } + + /** + * Generates a slug from a title. Falls back to timestamp if title is blank. + */ + fun slugFromTitle(title: String): String { + if (title.isBlank()) return "untitled-${Instant.now().epochSecond}" + return sanitizeSlug(title) + } + + /** + * Saves or updates a draft. Creates content file and updates index atomically. + */ + suspend fun saveDraft( + slug: String, + content: String, + metadata: DraftMetadata, + ) { + val safeSlug = sanitizeSlug(slug) + + mutex.withLock { + // Write content file atomically + val contentFile = File(draftsDir, "$safeSlug.md") + atomicWrite(contentFile, content) + + // Update index + val index = loadIndexMap() + index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString()) + atomicWriteIndex(index) + cachedIndex = index + + // Refresh state + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + /** + * Loads a draft's content by slug. + */ + suspend fun loadContent(slug: String): String? { + val safeSlug = sanitizeSlug(slug) + val file = File(draftsDir, "$safeSlug.md") + return if (file.exists()) file.readText() else null + } + + /** + * Loads a draft's metadata by slug. + */ + suspend fun loadMetadata(slug: String): DraftMetadata? { + val safeSlug = sanitizeSlug(slug) + return mutex.withLock { + loadIndexMap()[safeSlug] + } + } + + /** + * Deletes a draft by slug. + */ + suspend fun deleteDraft(slug: String) { + val safeSlug = sanitizeSlug(slug) + mutex.withLock { + File(draftsDir, "$safeSlug.md").delete() + + val index = loadIndexMap() + index.remove(safeSlug) + atomicWriteIndex(index) + cachedIndex = index + + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + /** + * Marks a draft as published. + */ + suspend fun markPublished(slug: String) { + val safeSlug = sanitizeSlug(slug) + mutex.withLock { + val index = loadIndexMap() + val existing = index[safeSlug] ?: return + index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString()) + atomicWriteIndex(index) + cachedIndex = index + + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + private fun loadIndexMap(): MutableMap { + cachedIndex?.let { return it } + val loaded: MutableMap = + if (!indexFile.exists()) { + mutableMapOf() + } else { + try { + mapper.readValue>(indexFile) + } catch (e: Exception) { + System.err.println("Failed to read drafts index: ${e.message}") + mutableMapOf() + } + } + cachedIndex = loaded + return loaded + } + + private suspend fun loadIndex() { + mutex.withLock { + _drafts.value = + loadIndexMap() + .entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + private fun atomicWrite( + file: File, + content: String, + ) { + val tempFile = File(file.parentFile, "${file.name}.tmp") + try { + tempFile.writeText(content) + setFilePermissions(tempFile) + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } finally { + if (tempFile.exists()) tempFile.delete() + } + } + + private fun atomicWriteIndex(index: Map) { + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index) + atomicWrite(indexFile, json) + } + + private fun setDirPermissions(dir: File) { + try { + Files.setPosixFilePermissions( + dir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } + + private fun setFilePermissions(file: File) { + try { + Files.setPosixFilePermissions( + file.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt new file mode 100644 index 000000000..16f238ffd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.highlights + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.time.Instant +import java.util.UUID + +/** + * Local highlight storage for article annotations. + * Stores highlights as JSON in ~/.amethyst/highlights/index.json. + * Uses atomic writes following the same pattern as DesktopDraftStore. + */ +class DesktopHighlightStore( + private val scope: CoroutineScope, +) { + private val mapper = jacksonObjectMapper() + private val mutex = Mutex() + + private val _highlights = MutableStateFlow>>(emptyMap()) + val highlights: StateFlow>> = _highlights.asStateFlow() + + private val highlightsDir: File by lazy { + val dir = File(System.getProperty("user.home"), ".amethyst/highlights") + if (!dir.exists()) { + dir.mkdirs() + } + dir + } + + private val indexFile: File get() = File(highlightsDir, "index.json") + + init { + scope.launch(Dispatchers.IO) { + loadIndex() + } + } + + suspend fun addHighlight( + articleAddressTag: String, + text: String, + note: String?, + articleTitle: String?, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + val articleHighlights = current.getOrDefault(articleAddressTag, emptyList()).toMutableList() + + // Avoid duplicate highlights of the same text + if (articleHighlights.any { it.text == text }) return + + articleHighlights.add( + HighlightData( + id = UUID.randomUUID().toString(), + text = text, + note = note, + articleAddressTag = articleAddressTag, + articleTitle = articleTitle, + createdAt = Instant.now().epochSecond, + ), + ) + current[articleAddressTag] = articleHighlights + _highlights.value = current + saveIndex(current) + } + } + + suspend fun updateNote( + highlightId: String, + note: String, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val idx = list.indexOfFirst { it.id == highlightId } + if (idx >= 0) { + current[key] = + list.toMutableList().apply { + set(idx, get(idx).copy(note = note)) + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + suspend fun removeHighlight(highlightId: String) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val filtered = list.filter { it.id != highlightId } + if (filtered.size != list.size) { + if (filtered.isEmpty()) { + current.remove(key) + } else { + current[key] = filtered + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + suspend fun markPublished( + highlightId: String, + eventId: String, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val idx = list.indexOfFirst { it.id == highlightId } + if (idx >= 0) { + current[key] = + list.toMutableList().apply { + set(idx, get(idx).copy(published = true, eventId = eventId)) + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + fun getHighlightsForArticle(addressTag: String): List = _highlights.value[addressTag] ?: emptyList() + + fun getAllHighlights(): Map> = _highlights.value + + private suspend fun loadIndex() { + mutex.withLock { + if (indexFile.exists()) { + try { + val data: Map> = mapper.readValue(indexFile) + _highlights.value = data + } catch (e: Exception) { + // Corrupted file — start fresh + _highlights.value = emptyMap() + } + } + } + } + + private fun saveIndex(data: Map>) { + try { + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(data) + val tempFile = File(highlightsDir, "index.json.tmp") + tempFile.writeText(json) + Files.move( + tempFile.toPath(), + indexFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } catch (_: Exception) { + // Best effort — don't crash on write failure + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt index 44edd4446..8027f1ecf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.desktop.chess.DesktopChessEventCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent @@ -95,7 +95,7 @@ class DesktopChessSubscriptionController( filters = allFilters, relays = state.relays, listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index e47b44169..474f3a163 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -24,16 +24,29 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter +import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.lang.management.ManagementFactory +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * Desktop-specific relay subscriptions coordinator. @@ -86,6 +99,116 @@ class DesktopRelaySubscriptionsCoordinator( }, ) + // Event bundler: batches consumed notes before emitting to SharedFlow + // 250ms for desktop (Android uses 1000ms to save battery) + private val eventBundler = + BasicBundledInsert( + delay = 250, + dispatcher = Dispatchers.IO, + scope = scope, + ) + + // Screen-triggered subscription Jobs — keyed by subId for proper cancellation + private val screenSubscriptions = ConcurrentHashMap() + + // Last event received from any subscription — drives RelayHealthIndicator + private val _lastEventAt = MutableStateFlow(null) + val lastEventAt: StateFlow = _lastEventAt.asStateFlow() + + /** + * Central event router — consumes an event into the cache and emits to event stream. + * Called from relay onEvent callbacks. Non-blocking (launches on IO dispatcher). + * Try-catch per event ensures one bad event doesn't kill the pipeline. + */ + fun consumeEvent( + event: Event, + relay: NormalizedRelayUrl?, + ) { + scope.launch(Dispatchers.IO) { + try { + val consumed = localCache.consume(event, relay) + if (consumed) { + _lastEventAt.value = System.currentTimeMillis() + val note = localCache.getNoteIfExists(event.id) ?: return@launch + eventBundler.invalidateList(note) { batch -> + localCache.eventStream.emitNewNotes(batch) + } + } + } catch (e: Exception) { + println("Coordinator: failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}") + } + } + } + + /** + * Request a consolidated interaction subscription for the given note IDs. + * Subscribes to kinds 7 (reactions), 9735 (zaps), 6 (reposts), and 1 (replies) + * targeting these notes. Returns a subId for cleanup via [releaseInteractions]. + */ + fun requestInteractions( + noteIds: List, + relays: Set, + ): String { + val subId = generateSubId("interactions-${noteIds.hashCode()}") + + // Cancel any existing subscription with this ID + screenSubscriptions.remove(subId)?.cancel() + client.unsubscribe(subId) + + if (noteIds.isEmpty() || relays.isEmpty()) return subId + + val filters = + listOf( + // Reactions (kind 7) targeting these notes + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip25Reactions.ReactionEvent.KIND), + tags = mapOf("e" to noteIds), + ), + // Zap receipts (kind 9735) targeting these notes + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip57Zaps.LnZapEvent.KIND), + tags = mapOf("e" to noteIds), + ), + // Reposts (kind 6) targeting these notes + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip18Reposts.RepostEvent.KIND), + tags = mapOf("e" to noteIds), + ), + ) + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + consumeEvent(event, relay) + } + } + + val job = + scope.launch { + client.subscribe( + subId = subId, + filters = relays.associateWith { filters }, + listener = listener, + ) + } + screenSubscriptions[subId] = job + + return subId + } + + /** + * Release a screen-triggered interaction subscription. + */ + fun releaseInteractions(subId: String) { + screenSubscriptions.remove(subId)?.cancel() + client.unsubscribe(subId) + } + /** * Start the coordinator. * Call once when app starts or user logs in. @@ -94,7 +217,7 @@ class DesktopRelaySubscriptionsCoordinator( // Start rate limiter to process queued metadata requests rateLimiter.start { pubkey -> // When rate limiter dequeues a pubkey, subscribe to its metadata - client.openReqSubscription( + client.subscribe( filters = indexRelays.associateWith { listOf( @@ -127,13 +250,6 @@ class DesktopRelaySubscriptionsCoordinator( feedMetadata.loadMetadataForPubkeys(pubkeys) } - /** - * Load reactions for specific notes. - */ - fun loadReactionsForNotes(noteIds: List) { - feedMetadata.loadReactionsForNotes(noteIds) - } - // -- DM Subscription Support -- /** Active DM subscription IDs for cleanup */ @@ -165,7 +281,7 @@ class DesktopRelaySubscriptionsCoordinator( if (inboxRelays.isEmpty() && outboxRelays.isEmpty()) return val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -180,7 +296,7 @@ class DesktopRelaySubscriptionsCoordinator( if (inboxRelays.isNotEmpty()) { val inboxSubId = generateSubId("dm-inbox-${userPubKeyHex.take(8)}") activeDmSubIds.add(inboxSubId) - client.openReqSubscription( + client.subscribe( subId = inboxSubId, filters = inboxRelays.associateWith { @@ -194,7 +310,7 @@ class DesktopRelaySubscriptionsCoordinator( if (outboxRelays.isNotEmpty()) { val outboxSubId = generateSubId("dm-outbox-${userPubKeyHex.take(8)}") activeDmSubIds.add(outboxSubId) - client.openReqSubscription( + client.subscribe( subId = outboxSubId, filters = outboxRelays.associateWith { @@ -208,7 +324,7 @@ class DesktopRelaySubscriptionsCoordinator( if (inboxRelays.isNotEmpty()) { val giftWrapSubId = generateSubId("giftwrap-${userPubKeyHex.take(8)}") activeDmSubIds.add(giftWrapSubId) - client.openReqSubscription( + client.subscribe( subId = giftWrapSubId, filters = inboxRelays.associateWith { @@ -224,7 +340,7 @@ class DesktopRelaySubscriptionsCoordinator( */ fun unsubscribeFromDms() { activeDmSubIds.forEach { subId -> - client.close(subId) + client.unsubscribe(subId) } activeDmSubIds.clear() } @@ -234,8 +350,68 @@ class DesktopRelaySubscriptionsCoordinator( * Call when switching accounts or during cleanup. */ fun clear() { + // Clean up screen-triggered subscriptions + screenSubscriptions.forEach { (subId, job) -> + job.cancel() + client.unsubscribe(subId) + } + screenSubscriptions.clear() + _lastEventAt.value = null + unsubscribeFromDms() feedMetadata.clear() rateLimiter.reset() + cleanupJob?.cancel() + } + + // ----- Memory Cleanup ----- + + private val memoryBean = ManagementFactory.getMemoryMXBean() + private var lastCleanupTime = 0L + private var cleanupJob: Job? = null + + /** + * Starts a periodic memory cleanup coroutine. + * Checks heap usage every 30s, runs cleanup at >75% heap or every 5 minutes. + */ + fun startCleanupLoop() { + cleanupJob = + scope.launch(Dispatchers.Default) { + delay(2.minutes) + while (isActive) { + delay(30.seconds) + val heapPct = heapUsagePercent() + val elapsed = System.currentTimeMillis() - lastCleanupTime + if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) { + runCleanup() + } + } + } + } + + private suspend fun runCleanup() { + val ops = + listOf Unit>>( + "cleanMemory" to { localCache.cleanMemory() }, + "cleanObservers" to { cleanObservers() }, + ) + ops.forEach { (name, op) -> + try { + op() + } catch (e: Exception) { + println("Cleanup $name failed: ${e.message}") + } + } + lastCleanupTime = System.currentTimeMillis() + } + + private fun cleanObservers() { + localCache.notes.forEach { _, note -> note.clearFlow() } + localCache.addressableNotes.forEach { _, note -> note.clearFlow() } + } + + private fun heapUsagePercent(): Double { + val heap = memoryBean.heapMemoryUsage + return heap.used.toDouble() / heap.max.toDouble() } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 04fb56ac2..fee6d8c3b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -576,6 +576,24 @@ object FilterBuilders { until = until, ) + /** + * Creates a filter for a specific long-form article by author pubkey and d-tag. + * + * @param pubkey Author public key (hex-encoded, 64 chars) + * @param dTag The d-tag (slug) identifier for the addressable event + * @return Filter for a specific long-form article + */ + fun longFormByAddress( + pubkey: String, + dTag: String, + ): Filter = + Filter( + kinds = listOf(30023), // LongTextNoteEvent.KIND + authors = listOf(pubkey), + tags = mapOf("d" to listOf(dTag)), + limit = 1, + ) + /** * Creates a filter for long-form content (kind 30023) from specific authors. * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index a6bdbc908..da9f93413 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -104,7 +104,11 @@ fun createUserPostsSubscription( ): SubscriptionConfig = SubscriptionConfig( subId = generateSubId("posts-${pubKeyHex.take(8)}"), - filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)), + filters = + listOf( + FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit), + FilterBuilders.longFormFromAuthors(listOf(pubKeyHex), limit = 50), + ), relays = relays, onEvent = onEvent, onEose = onEose, 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 index c918ea775..06fdc1235 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory 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 @@ -48,6 +47,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent object SearchFilterFactory { // Default kind groups (ported from Android SearchPostsByText) 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 f7ce8727d..b9fa38e75 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 @@ -25,7 +25,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -80,7 +80,7 @@ fun rememberSubscription( filters = cfg.filters, relays = cfg.relays, listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt new file mode 100644 index 000000000..965b25821 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 + +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.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.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.VerticalDivider +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isMetaPressed +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.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState +import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar +import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel +import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown +import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.net.URI + +private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning") + +@Composable +fun ArticleEditorScreen( + draftSlug: String?, + draftStore: DesktopDraftStore, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + onBack: () -> Unit, + onPublished: () -> Unit, +) { + val scope = rememberCoroutineScope() + + var title by remember { mutableStateOf("") } + var summary by remember { mutableStateOf("") } + var bannerUrl by remember { mutableStateOf("") } + var tags by remember { mutableStateOf>(emptyList()) } + var slug by remember { mutableStateOf(draftSlug ?: "") } + val editorState = remember { MarkdownEditorState() } + var publishing by remember { mutableStateOf(false) } + var saveMessage by remember { mutableStateOf(null) } + var debouncedContent by remember { mutableStateOf("") } + + LaunchedEffect(editorState.text) { + delay(300) + debouncedContent = editorState.text + } + + // Load existing draft + LaunchedEffect(draftSlug) { + if (draftSlug != null) { + val meta = draftStore.loadMetadata(draftSlug) + val body = draftStore.loadContent(draftSlug) + if (meta != null) { + title = meta.title + summary = meta.summary ?: "" + bannerUrl = meta.image ?: "" + tags = meta.tags + slug = draftSlug + } + if (body != null) { + editorState.loadContent(body) + } + } + } + + // Auto-generate slug from title if creating a new draft + LaunchedEffect(title) { + if (draftSlug == null && title.isNotBlank()) { + slug = draftStore.slugFromTitle(title) + } + } + + val onLinkClick: (String) -> Unit = + remember { + { url: String -> + val scheme = url.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { + try { + Desktop.getDesktop().browse(URI(url)) + } catch (_: Exception) { + // Ignore unsupported or malformed URLs + } + } + } + } + + fun saveDraft() { + if (slug.isBlank()) return + scope.launch { + draftStore.saveDraft( + slug = slug, + content = editorState.text, + metadata = + DraftMetadata( + title = title, + summary = summary.ifBlank { null }, + image = bannerUrl.ifBlank { null }, + tags = tags, + ), + ) + saveMessage = "Saved" + } + } + + fun publishArticle() { + if (publishing) return + publishing = true + scope.launch { + try { + val event = + LongFormPublishAction.publish( + title = title, + content = editorState.text, + summary = summary.ifBlank { null }, + image = bannerUrl.ifBlank { null }, + tags = tags, + dTag = slug.ifBlank { draftStore.slugFromTitle(title) }, + signer = account.signer, + ) + // TODO: send() is fire-and-forget; markPublished runs before relay ack. + // Consider waiting for relay OK response before marking as published. + relayManager.publish(event) + draftStore.markPublished(slug) + onPublished() + } catch (e: Exception) { + saveMessage = "Publish failed: ${e.message}" + } finally { + publishing = false + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.isMetaPressed) { + when (event.key) { + Key.S -> { + saveDraft() + true + } + + Key.B -> { + editorState.toggleBold() + true + } + + Key.I -> { + editorState.toggleItalic() + true + } + + Key.E -> { + editorState.toggleInlineCode() + true + } + + Key.K -> { + editorState.insertLink() + true + } + + else -> { + false + } + } + } else { + false + } + }, + ) { + // Top bar + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onBack) { + Text("Back") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + saveMessage?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + OutlinedButton(onClick = { saveDraft() }) { + Text("Save") + } + Button( + onClick = { publishArticle() }, + enabled = !publishing && title.isNotBlank() && editorState.text.isNotBlank(), + ) { + Text(if (publishing) "Publishing..." else "Publish") + } + } + } + + // Metadata panel (collapsible) + MetadataPanel( + title = title, + onTitleChange = { title = it }, + summary = summary, + onSummaryChange = { summary = it }, + bannerUrl = bannerUrl, + onBannerUrlChange = { bannerUrl = it }, + tags = tags, + onTagsChange = { tags = it }, + slug = slug, + onSlugChange = { slug = it }, + ) + + Spacer(Modifier.height(8.dp)) + + // Markdown toolbar — selection-aware toggle behavior + MarkdownToolbar(state = editorState) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Split pane: source left, preview right + Row( + modifier = Modifier.fillMaxSize().weight(1f), + ) { + // Source editor + TextField( + value = editorState.value, + onValueChange = { + editorState.onValueChange(it) + saveMessage = null + }, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(end = 4.dp), + textStyle = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + placeholder = { Text("Write your article in markdown...") }, + ) + + VerticalDivider() + + // Preview + SelectionContainer { + Column( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column(modifier = Modifier.widthIn(max = 680.dp)) { + if (debouncedContent.isNotBlank()) { + RenderMarkdown( + content = debouncedContent, + onLinkClick = onLinkClick, + ) + } else { + Text( + "Preview will appear here...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt new file mode 100644 index 000000000..7a99df01b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -0,0 +1,737 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 + +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.ContextMenuRepresentation +import androidx.compose.foundation.ContextMenuState +import androidx.compose.foundation.LocalContextMenuRepresentation +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +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.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.ui.Alignment +import androidx.compose.ui.Modifier +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.isMetaPressed +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.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader +import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents +import com.vitorpamplona.amethyst.commons.compose.article.extractTableOfContents +import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown +import com.vitorpamplona.amethyst.commons.model.nip23LongContent.ReadingTimeCalculator +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.getText +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +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.highlights.ArticleHighlightsPanel +import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) + +/** + * Parses a NIP-23 address tag in the format "30023:pubkey:d-tag". + * Returns a Triple of (kind, pubkey, dTag) or null if invalid. + */ +private fun parseAddressTag(addressTag: String): Triple? { + val parts = addressTag.split(":", limit = 3) + if (parts.size < 3) return null + val kind = parts[0].toIntOrNull() ?: return null + return Triple(kind, parts[1], parts[2]) +} + +/** + * Desktop Article Reader Screen - renders long-form NIP-23 content with + * a Medium-style layout: optional ToC sidebar, centered content column, + * article header with hero image, markdown body, and reaction row. + */ +@Composable +fun ArticleReaderScreen( + addressTag: String, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn?, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + highlightStore: DesktopHighlightStore? = null, + onBack: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scrollState = rememberScrollState() + + // Parse address tag + val parsed = remember(addressTag) { parseAddressTag(addressTag) } + val pubkey = parsed?.second + val dTag = parsed?.third + + // Article state + var article by remember(addressTag) { mutableStateOf(null) } + var eoseReceived by remember(addressTag) { mutableStateOf(false) } + + // Zoom level for article text + var zoomLevel by remember { mutableStateOf(1.0f) } + + // Coroutine scope for highlight operations + val scope = rememberCoroutineScope() + + // Highlight state — collect outside let to ensure proper Compose subscription + val allHighlights by (highlightStore?.highlights ?: kotlinx.coroutines.flow.MutableStateFlow(emptyMap())) + .collectAsState() + val articleHighlights = allHighlights[addressTag] ?: emptyList() + + var showAnnotationDialog by remember { mutableStateOf(null) } + var showHighlightsPanel by remember { mutableStateOf(false) } + val focusRequester = + remember { + androidx.compose.ui.focus + .FocusRequester() + } + + // Active ToC entry tracking (placeholder — no scroll-position-based tracking yet) + var activeTocIndex by remember { mutableStateOf(null) } + + // Link click handler for markdown + val onLinkClick: (String) -> Unit = + remember(articleHighlights) { + { url: String -> + when { + url.startsWith("highlight://") -> { + showHighlightsPanel = true + } + + url.startsWith("nostr:") -> { + // TODO: Parse nostr: URI and navigate + } + + else -> { + try { + java.awt.Desktop + .getDesktop() + .browse(java.net.URI(url)) + } catch (_: Exception) { + } + } + } + } + } + + // Load author metadata via coordinator + LaunchedEffect(article, subscriptionsCoordinator) { + val art = article ?: return@LaunchedEffect + subscriptionsCoordinator?.loadMetadataForPubkeys(listOf(art.pubKey)) + } + + // Subscribe to the article by address components + rememberSubscription(relayStatuses, addressTag, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || pubkey == null || dTag == null) { + return@rememberSubscription null + } + + SubscriptionConfig( + subId = "article-${addressTag.hashCode()}", + filters = listOf(FilterBuilders.longFormByAddress(pubkey, dTag)), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent) { + // Keep the most recent version + val current = article + if (current == null || event.createdAt > current.createdAt) { + article = event + } + } + }, + onEose = { _, _ -> + eoseReceived = true + }, + ) + } + + // Interaction state + val articleEventId = article?.id + val eventIds = listOfNotNull(articleEventId) + + var zapReceipts by remember { mutableStateOf>(emptyList()) } + var reactionCount by remember { mutableStateOf(0) } + var replyCount by remember { mutableStateOf(0) } + var repostCount by remember { mutableStateOf(0) } + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Subscribe to zaps + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + createZapsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + if (zapReceipts.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + zapReceipts = zapReceipts + receipt + } + } + }, + ) + } + + // Subscribe to reactions + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val reactionIds = mutableSetOf() + createReactionsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent && reactionIds.add(event.id)) { + reactionCount = reactionIds.size + } + }, + ) + } + + // Subscribe to replies + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val replyIds = mutableSetOf() + createRepliesSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (replyIds.add(event.id)) { + replyCount = replyIds.size + } + }, + ) + } + + // Subscribe to reposts + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val repostIds = mutableSetOf() + createRepostsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent && repostIds.add(event.id)) { + repostCount = repostIds.size + } + }, + ) + } + + // Subscribe to bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "article-bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + bookmarkedEventIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Derived data from article + val title = article?.title() ?: "Untitled" + val content = article?.content ?: "" + val tocEntries = remember(content) { extractTableOfContents(content) } + val readingTime = + remember(content) { + if (content.isNotBlank()) ReadingTimeCalculator.calculate(content) else null + } + val bannerUrl = article?.image() + val publishedAt = + article?.let { art -> + val ts = art.publishedAt() ?: art.createdAt + Instant + .ofEpochSecond(ts) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(articleDateFormat) + } + + // Author info from local cache + val authorUser = article?.let { localCache.getOrCreateUser(it.pubKey) } + val authorName = authorUser?.toBestDisplayName() + val authorPicture = authorUser?.profilePicture() + + val clipboardManager = LocalClipboard.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.isMetaPressed) { + when (event.key) { + Key.Equals -> { + zoomLevel = (zoomLevel + 0.1f).coerceAtMost(2.0f) + true + } + + Key.Minus -> { + zoomLevel = (zoomLevel - 0.1f).coerceAtLeast(0.5f) + true + } + + Key.Zero -> { + zoomLevel = 1.0f + true + } + + else -> { + false + } + } + } else { + false + } + }, + ) { + // Top bar: back + bookmark placeholder + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Article", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + if (zoomLevel != 1.0f) { + Spacer(Modifier.width(8.dp)) + Text( + "${(zoomLevel * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Loading / error / content states + when { + parsed == null -> { + EmptyState( + title = "Invalid article address", + description = "Could not parse address: $addressTag", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } + + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + + article == null && !eoseReceived -> { + LoadingState("Loading article...") + } + + article == null && eoseReceived -> { + EmptyState( + title = "Article not found", + description = "This article may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } + + else -> { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val showToc = maxWidth > 1100.dp && tocEntries.isNotEmpty() + + Row(modifier = Modifier.fillMaxSize()) { + // ToC sidebar + if (showToc) { + TableOfContents( + entries = tocEntries, + activeEntryIndex = activeTocIndex, + onEntryClick = { entry -> + activeTocIndex = entry.index + // TODO: scroll to heading position + }, + modifier = Modifier.padding(top = 16.dp, start = 8.dp), + ) + VerticalDivider() + } + + // Main content column + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp), + ) { + Column( + modifier = + Modifier + .widthIn(max = 680.dp) + .align(Alignment.CenterHorizontally), + ) { + Spacer(Modifier.height(16.dp)) + + ArticleHeader( + title = title, + authorName = authorName, + authorPicture = authorPicture, + publishedAt = publishedAt, + readingTimeMinutes = readingTime, + bannerUrl = bannerUrl, + onAuthorClick = + article?.let { + { onNavigateToProfile(it.pubKey) } + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = 1.dp, + ) + + // Collapsible highlights section + if (articleHighlights.isNotEmpty()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + showHighlightsPanel = !showHighlightsPanel + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (showHighlightsPanel) { + Icons.Default.KeyboardArrowDown + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = "Toggle highlights", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "${articleHighlights.size} highlight${if (articleHighlights.size != 1) "s" else ""}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (showHighlightsPanel && highlightStore != null) { + ArticleHighlightsPanel( + highlights = articleHighlights, + highlightStore = highlightStore, + articleContent = content, + signer = account?.signer, + relayManager = relayManager, + modifier = Modifier.padding(bottom = 16.dp), + ) + HorizontalDivider( + modifier = Modifier.padding(bottom = 16.dp), + thickness = 1.dp, + ) + } + } + + // Markdown body with right-click highlight via context menu + val defaultRepresentation = LocalContextMenuRepresentation.current + val highlightRepresentation = + remember( + defaultRepresentation, + highlightStore, + addressTag, + title, + ) { + HighlightContextMenuRepresentation( + delegate = defaultRepresentation, + onHighlight = { + scope.launch { + val text = clipboardManager.getText() + if (!text.isNullOrBlank()) { + highlightStore?.addHighlight( + articleAddressTag = addressTag, + text = text, + note = null, + articleTitle = title, + ) + } + } + }, + onHighlightWithNote = { + scope.launch { + val text = clipboardManager.getText() + if (!text.isNullOrBlank()) { + showAnnotationDialog = text + } + } + }, + ) + } + + CompositionLocalProvider( + LocalContextMenuRepresentation provides highlightRepresentation, + ) { + SelectionContainer { + RenderMarkdown( + content = content, + onLinkClick = onLinkClick, + fontScale = zoomLevel, + highlightedTexts = articleHighlights.map { it.text }, + ) + } + } + + Spacer(Modifier.height(32.dp)) + + // Topics / hashtags + val topics = article?.topics() ?: emptyList() + if (topics.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 16.dp), + ) { + topics.forEach { topic -> + Text( + text = "#$topic", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + + HorizontalDivider(thickness = 1.dp) + + // Reaction actions + val art = article + if (art != null && account != null) { + Spacer(Modifier.height(16.dp)) + NoteActionsRow( + event = art, + relayManager = relayManager, + localCache = localCache, + account = account, + onReplyClick = { onNavigateToThread(art.id) }, + onZapFeedback = onZapFeedback, + zapCount = zapReceipts.size, + zapAmountSats = zapReceipts.sumOf { it.amountSats }, + zapReceipts = zapReceipts, + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + nwcConnection = nwcConnection, + isBookmarked = articleEventId in bookmarkedEventIds, + bookmarkList = bookmarkList, + onBookmarkChanged = { newList -> + bookmarkList = newList + bookmarkedEventIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(Modifier.height(48.dp)) + } + } + } + } + } + } + } + + showAnnotationDialog?.let { selectedText -> + HighlightAnnotationDialog( + selectedText = selectedText, + onConfirm = { note -> + scope.launch { + highlightStore?.addHighlight( + articleAddressTag = addressTag, + text = selectedText, + note = note, + articleTitle = title, + ) + } + showAnnotationDialog = null + }, + onDismiss = { showAnnotationDialog = null }, + ) + } +} + +/** + * Custom context menu representation that adds "Highlight" and "Highlight with Note" + * items to the right-click menu inside a SelectionContainer. + * + * How it works: The SelectionContainer provides a "Copy" item that has access to the + * selected text. Our items piggyback on Copy's onClick — calling it first to put the + * selected text on the clipboard, then reading the clipboard to get the text. + */ +private class HighlightContextMenuRepresentation( + private val delegate: ContextMenuRepresentation, + private val onHighlight: () -> Unit, + private val onHighlightWithNote: () -> Unit, +) : ContextMenuRepresentation { + @Composable + override fun Representation( + state: ContextMenuState, + items: () -> List, + ) { + val extendedItems = { + val original = items() + val copyItem = original.find { it.label == "Copy" } + + if (copyItem != null) { + original + + listOf( + ContextMenuItem("Highlight") { + copyItem.onClick() + onHighlight() + }, + ContextMenuItem("Highlight with Note") { + copyItem.onClick() + onHighlightWithNote() + }, + ) + } else { + original + } + } + + delegate.Representation(state, extendedItems) + } +} 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 f08682e56..e692b6470 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 @@ -76,7 +76,8 @@ fun BookmarksScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val scope = rememberCoroutineScope() // Tab state @@ -120,6 +121,27 @@ fun BookmarksScreen( } } + // Seed from cache — bookmark list event may already be in addressableNotes + LaunchedEffect(account.pubKeyHex) { + val address = BookmarkListEvent.createBookmarkAddress(account.pubKeyHex) + val cachedNote = localCache.getOrCreateAddressableNote(address) + val cachedEvent = cachedNote.event as? BookmarkListEvent + if (cachedEvent != null) { + bookmarkList = cachedEvent + publicBookmarkIds = + cachedEvent + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + // Seed public events from cache + publicBookmarkIds.forEach { id -> + val note = localCache.getNoteIfExists(id) + val event = note?.event + if (event != null) publicEventState.addItem(event) + } + } + } + // Subscribe to user's bookmark list (kind 30001) rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { @@ -188,7 +210,8 @@ fun BookmarksScreen( FilterBuilders.byIds(publicBookmarkIds), ), relays = connectedRelays, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) publicEventState.addItem(event) }, onEose = { _, _ -> }, @@ -209,7 +232,8 @@ fun BookmarksScreen( FilterBuilders.byIds(privateBookmarkIds), ), relays = connectedRelays, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) privateEventState.addItem(event) }, onEose = { _, _ -> }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt new file mode 100644 index 000000000..64d383860 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry +import kotlinx.coroutines.launch + +@Composable +fun DraftsScreen( + draftStore: DesktopDraftStore, + onOpenEditor: (slug: String?) -> Unit, +) { + val drafts by draftStore.drafts.collectAsState() + val scope = rememberCoroutineScope() + var deleteTarget by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Drafts", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Button(onClick = { onOpenEditor(null) }) { + Icon(Icons.Default.Add, contentDescription = null) + Text("New Draft", modifier = Modifier.padding(start = 4.dp)) + } + } + + Spacer(Modifier.height(16.dp)) + + if (drafts.isEmpty()) { + Text( + "No drafts yet. Click \"New Draft\" to start writing.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(drafts, key = { it.slug }) { entry -> + DraftCard( + entry = entry, + onClick = { onOpenEditor(entry.slug) }, + onDelete = { deleteTarget = entry }, + ) + } + } + } + } + + // Delete confirmation dialog + deleteTarget?.let { entry -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete Draft") }, + text = { + Text( + "Delete \"${entry.metadata.title.ifBlank { entry.slug }}\"? This cannot be undone.", + ) + }, + confirmButton = { + TextButton(onClick = { + scope.launch { draftStore.deleteDraft(entry.slug) } + deleteTarget = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun DraftCard( + entry: DraftEntry, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.metadata.title.ifBlank { "Untitled" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = entry.metadata.updatedAt.take(10), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (entry.metadata.published) { + Text( + text = "Published", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete draft", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 1f9628423..26650f514 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData import com.vitorpamplona.quartz.nip01Core.core.Event @@ -31,19 +30,22 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub * Extension to convert Event to NoteDisplayData for the shared NoteCard. */ fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData { - val npub = - try { - pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." - } catch (e: Exception) { - pubKey.take(16) + "..." - } + val user = (cache?.getUserIfExists(pubKey)) - val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture() + val displayName = + user?.toBestDisplayName() + ?: try { + pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." + } catch (e: Exception) { + pubKey.take(16) + "..." + } + + val pictureUrl = user?.profilePicture() return NoteDisplayData( id = id, pubKeyHex = pubKey, - pubKeyDisplay = npub, + pubKeyDisplay = displayName, profilePictureUrl = pictureUrl, content = content, createdAt = createdAt, 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 b8fee1344..654da5d7c 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 @@ -45,49 +45,36 @@ import androidx.compose.material3.IconButton 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.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.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.model.Note import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode -import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders -import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig -import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription -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.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map data class LightboxState( val urls: List, @@ -97,11 +84,12 @@ data class LightboxState( ) /** - * Note card with action buttons. + * Note card that reads counts from the Note model (cache-backed). + * Event is extracted from Note for signing operations in NoteActionsRow. */ @Composable fun FeedNoteCard( - event: Event, + note: Note, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, @@ -112,15 +100,25 @@ fun FeedNoteCard( onNavigateToThread: (String) -> Unit = {}, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, - zapReceipts: List = emptyList(), - reactionCount: Int = 0, - replyCount: Int = 0, - repostCount: Int = 0, - bookmarkList: BookmarkListEvent? = null, - isBookmarked: Boolean = false, - onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, ) { - val zapAmountSats = zapReceipts.sumOf { it.amountSats } + val event = note.event ?: return + + // Observe Note.flowSet for live count updates + val flowSet = remember(note) { note.flow() } + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() + + // Read counts from Note model (re-read on each stateFlow emission) + val reactionCount = note.countReactions() + val replyCount = note.replies.size + val repostCount = note.boosts.size + val zapAmount = note.zapsAmount + + // Clean up flowSet when card leaves composition + DisposableEffect(note) { + onDispose { note.clearFlow() } + } Column { NoteCard( @@ -144,15 +142,12 @@ fun FeedNoteCard( onReplyClick = onReply, onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = zapReceipts.size, - zapAmountSats = zapAmountSats, - zapReceipts = zapReceipts, + zapCount = note.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps reactionCount = reactionCount, replyCount = replyCount, repostCount = repostCount, - bookmarkList = bookmarkList, - isBookmarked = isBookmarked, - onBookmarkChanged = onBookmarkChanged, ) } } @@ -171,57 +166,25 @@ fun FeedScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { + val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() - // Configured relay URLs only — stabilized with distinctUntilChanged() to prevent - // subscription churn from relay status changes (pings, connect/disconnect). - // openReqSubscription connects relays on demand; no need to wait for connectedRelays. - val configuredRelays by remember { - relayManager.relayStatuses - .map { it.keys } - .distinctUntilChanged() - }.collectAsState(emptySet()) - val scope = rememberCoroutineScope() - val eventState = - remember { - EventCollectionState( - getId = { it.id }, - sortComparator = compareByDescending { it.createdAt }, - maxSize = 200, - scope = scope, - ) - } - val events by eventState.items.collectAsState() + val followedUsers by localCache.followedUsers.collectAsState() + + // Available relay URLs — subscribe triggers connection on-demand + val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + 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()) } - // Track reaction event IDs per target event to deduplicate - var reactionIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } - // Track reply/repost event IDs per target event to deduplicate - var replyIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } - var repostIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } - var bookmarkList by remember { mutableStateOf(null) } - var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } - // Track EOSE to know when initial load is complete - var eoseReceivedCount by remember { mutableStateOf(0) } - val initialLoadComplete = eoseReceivedCount > 0 - - // Load followed users for Following feed mode - rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) { - if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { + // Subscribe to contact list (kind 3) — populates localCache.followedUsers + rememberSubscription(allRelayUrls, account, relayManager = relayManager) { + if (allRelayUrls.isNotEmpty() && account != null) { createContactListSubscription( - relays = configuredRelays, + relays = allRelayUrls, pubKeyHex = account.pubKeyHex, onEvent = { event, _, relay, _ -> - if (event is ContactListEvent) { - val follows = event.verifiedFollowKeySet() - followedUsers = follows - } + subscriptionsCoordinator?.consumeEvent(event, relay) }, ) } else { @@ -229,83 +192,28 @@ fun FeedScreen( } } - // Load user's bookmark list - rememberSubscription(configuredRelays, account, relayManager = relayManager) { - if (configuredRelays.isNotEmpty() && account != null) { - SubscriptionConfig( - subId = "bookmarks-${account.pubKeyHex.take(8)}", - filters = - listOf( - FilterBuilders.byAuthors( - authors = listOf(account.pubKeyHex), - kinds = listOf(BookmarkListEvent.KIND), - limit = 1, - ), - ), - relays = configuredRelays, - onEvent = { event, _, _, _ -> - if (event is BookmarkListEvent) { - bookmarkList = event - // Extract public bookmarked event IDs - val pubIds = - event - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds - } - }, - onEose = { _, _ -> }, - ) - } else { - null - } - } - - // Clear events and reset EOSE when feed mode changes - remember(feedMode) { - eventState.clear() - eoseReceivedCount = 0 - } - - // Subscribe to feed based on mode - rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { - if (configuredRelays.isEmpty()) { - return@rememberSubscription null - } + // Subscribe to feed events (kind 1) — populates cache via coordinator + rememberSubscription(allRelayUrls, feedMode, followedUsers, relayManager = relayManager) { + if (allRelayUrls.isEmpty()) return@rememberSubscription null when (feedMode) { FeedMode.GLOBAL -> { createGlobalFeedSubscription( - relays = configuredRelays, - onEvent = { event, _, _, _ -> - // Store metadata events in cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - eventState.addItem(event) - }, - onEose = { _, _ -> - eoseReceivedCount++ + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) }, ) } FeedMode.FOLLOWING -> { - if (followedUsers.isNotEmpty()) { + val follows = followedUsers.toList() + if (follows.isNotEmpty()) { createFollowingFeedSubscription( - relays = configuredRelays, - followedUsers = followedUsers.toList(), - onEvent = { event, _, _, _ -> - // Store metadata events in cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - eventState.addItem(event) - }, - onEose = { _, _ -> - eoseReceivedCount++ + relays = allRelayUrls, + followedUsers = follows, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) }, ) } else { @@ -315,344 +223,142 @@ fun FeedScreen( } } - // Subscribe to zaps for visible events - val eventIds = events.map { it.id } - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null + // DesktopFeedViewModel keyed on feedMode — recreated on mode switch + val viewModel = + remember(feedMode) { + val filter = + when (feedMode) { + FeedMode.GLOBAL -> { + DesktopGlobalFeedFilter(localCache) + } + + FeedMode.FOLLOWING -> { + DesktopFollowingFeedFilter(localCache) { + localCache.followedUsers.value + } + } + } + DesktopFeedViewModel(filter, localCache) } - createZapsSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - if (event is LnZapEvent) { - val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription - val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription - zapsByEvent = - zapsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptyList() - if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { - this[targetEventId] = existing + receipt - } - } - } - }, - ) + // Cancel old ViewModel's viewModelScope on recreation + DisposableEffect(viewModel) { + onDispose { viewModel.destroy() } } - // Subscribe to metadata for zap senders (to show display names) - val zapSenderPubkeys = - zapsByEvent.values - .flatten() - .map { it.senderPubKey } - .distinct() - rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) { - if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { - return@rememberSubscription null - } + val feedState by viewModel.feedState.feedContent.collectAsState() - // Only fetch metadata for users we don't have yet - val missingPubkeys = - zapSenderPubkeys.filter { pubkey -> - localCache - .getUserIfExists(pubkey) - ?.metadataOrNull() - ?.flow - ?.value == null + // Load metadata for visible notes via Coordinator (rate-limited) + LaunchedEffect(feedState, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { + val notes = viewModel.feedState.visibleNotes() + if (notes.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForNotes(notes) } - if (missingPubkeys.isEmpty()) { - return@rememberSubscription null - } - - createBatchMetadataSubscription( - relays = configuredRelays, - pubKeyHexList = missingPubkeys, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - }, - ) - } - - // Subscribe to reactions for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createReactionsSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - if (event is ReactionEvent) { - val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription - reactionIdsByEvent = - reactionIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to replies for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepliesSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - // Find the event this is replying to - val replyToId = - event.tags - .filter { it.size >= 2 && it[0] == "e" } - .lastOrNull() - ?.get(1) ?: return@createRepliesSubscription - if (replyToId in eventIds) { - replyIdsByEvent = - replyIdsByEvent.toMutableMap().apply { - val existing = this[replyToId] ?: emptySet() - this[replyToId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to reposts for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepostsSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - if (event is RepostEvent) { - val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription - repostIdsByEvent = - repostIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) - } - - // 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(allPubkeys, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys) } } - // Fallback subscription if coordinator not available - rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) { - // Skip if using coordinator - if (subscriptionsCoordinator != null) { - return@rememberSubscription null - } - - if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) { - return@rememberSubscription null - } - - // Only fetch metadata for users we don't have yet - val missingPubkeys = - allPubkeys.filter { pubkey -> - localCache - .getUserIfExists(pubkey) - ?.metadataOrNull() - ?.flow - ?.value == null + // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms) + DisposableEffect(feedMode, subscriptionsCoordinator) { + val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} + val relays = relayManager.relayStatuses.value.keys + // Initial subscription with whatever notes are visible now + val noteIds = viewModel.feedState.visibleNotes().mapNotNull { it.event?.id } + val subId = + if (noteIds.isNotEmpty()) { + coordinator.requestInteractions(noteIds, relays) + } else { + null } - if (missingPubkeys.isEmpty()) { - return@rememberSubscription null - } - - createBatchMetadataSubscription( - relays = configuredRelays, - pubKeyHexList = missingPubkeys, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - }, - ) + onDispose { subId?.let { coordinator.releaseInteractions(it) } } } @OptIn(ExperimentalLayoutApi::class) 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") }, - ) - 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, - ) - } - 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), - ) - } - } - } - - // 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") - } - } + // Header with compose button + FeedHeader( + feedMode = feedMode, + account = account, + connectedRelays = connectedRelays, + followedUsersCount = followedUsers.size, + onFeedModeChange = { mode -> + feedMode = mode + DesktopPreferences.feedMode = mode + }, + onRefresh = { relayManager.connect() }, + onCompose = onCompose, + ) 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() + // Feed content based on FeedState + when (val state = feedState) { + is FeedState.Loading -> { + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else { + LoadingState("Loading notes...") + } + } + + is FeedState.Empty -> { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" }, - 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 + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" }, - ) + onRefresh = { relayManager.connect() }, + ) + } + + is FeedState.FeedError -> { + EmptyState( + title = "Error loading feed", + description = state.errorMessage, + onRefresh = { relayManager.connect() }, + ) + } + + is FeedState.Loaded -> { + val loadedState by state.feed.collectAsState() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(loadedState.list, key = { it.idHex }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { replyToEvent = note.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() + }, + ) + } } } } - } // end Column + } // Reply dialog if (replyToEvent != null && account != null) { @@ -674,5 +380,91 @@ fun FeedScreen( onDismiss = { lightboxState = null }, ) } - } // end Box + } +} + +/** + * Feed header with title, mode selector, relay count, and compose button. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FeedHeader( + feedMode: FeedMode, + account: AccountState.LoggedIn?, + connectedRelays: Set, + followedUsersCount: Int, + onFeedModeChange: (FeedMode) -> Unit, + onRefresh: () -> Unit, + onCompose: () -> Unit, +) { + 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, + ) + + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { onFeedModeChange(FeedMode.GLOBAL) }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { onFeedModeChange(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( + " \u2022 $followedUsersCount followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = onRefresh, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } + + 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") + } + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 4e940173d..d14ae4b50 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -72,7 +72,7 @@ import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -436,7 +436,7 @@ private suspend fun fetchMetadataForUsers( filters = filters, relays = relays, listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -1058,7 +1058,7 @@ private suspend fun fetchUserLightningAddress( filters = filters, relays = relays, listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index e1be49646..edb2e43d8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.account.AccountState +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.createNotificationsSubscription @@ -109,10 +110,12 @@ sealed class NotificationItem( @Composable fun NotificationsScreen( relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val scope = rememberCoroutineScope() val notificationState = remember { @@ -125,6 +128,38 @@ fun NotificationsScreen( } val notifications by notificationState.items.collectAsState() + // Seed from cache — reactions, zaps already consumed are in cache + LaunchedEffect(Unit) { + val myPubKey = account.pubKeyHex + val cached = + localCache.notes.filterIntoSet { _, note -> + val event = note.event ?: return@filterIntoSet false + when (event) { + is ReactionEvent -> event.pubKey != myPubKey + is LnZapEvent -> true + else -> false + } + } + cached.forEach { note -> + val event = note.event ?: return@forEach + val notification = + when (event) { + is ReactionEvent -> { + NotificationItem.Reaction(event, event.createdAt, event.content) + } + + is LnZapEvent -> { + NotificationItem.Zap(event, event.createdAt, event.amount?.toLong()) + } + + else -> { + null + } + } + notification?.let { notificationState.addItem(it) } + } + } + // Load metadata for notification authors via coordinator LaunchedEffect(notifications, subscriptionsCoordinator) { if (subscriptionsCoordinator != null && notifications.isNotEmpty()) { @@ -143,7 +178,8 @@ fun NotificationsScreen( createNotificationsSubscription( relays = connectedRelays, pubKeyHex = account.pubKeyHex, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) // Skip events from the user themselves (except zaps) if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) { return@createNotificationsSubscription diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 3853e4b6e..80438b7b0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -40,11 +40,13 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.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.mutableStateOf @@ -61,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.desktop.account.AccountState 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.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingLongFormFeedSubscription @@ -68,13 +71,20 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscr import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import java.text.SimpleDateFormat -import java.util.Date +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale -private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) +private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) -private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000)) +private fun formatDate(timestamp: Long): String = + Instant + .ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(dateFormat) /** * Card displaying long-form content (NIP-23) with title, summary, and image. @@ -171,10 +181,15 @@ fun ReadsScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onNavigateToProfile: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val scope = rememberCoroutineScope() val eventState = @@ -193,6 +208,18 @@ fun ReadsScreen( var eoseReceivedCount by remember { mutableStateOf(0) } val initialLoadComplete = eoseReceivedCount > 0 + // Seed from cache — long-form notes already consumed are in cache + LaunchedEffect(Unit) { + val cached = + localCache.notes.filterIntoSet { _, note -> + note.event is LongTextNoteEvent + } + cached.forEach { note -> + (note.event as? LongTextNoteEvent)?.let { eventState.addItem(it) } + } + if (cached.isNotEmpty()) eoseReceivedCount++ + } + // Load followed users for Following feed mode rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) { val connectedRelays = connectedRelays @@ -228,7 +255,8 @@ fun ReadsScreen( FeedMode.GLOBAL -> { createLongFormFeedSubscription( relays = connectedRelays, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) if (event is LongTextNoteEvent) { eventState.addItem(event) } @@ -357,12 +385,27 @@ fun ReadsScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(events, key = { it.id }) { event -> - LongFormCard( - event = event, - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onClick = { onNavigateToArticle(event.id) }, - ) + Column { + LongFormCard( + event = event, + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onClick = { onNavigateToArticle(event.addressTag()) }, + ) + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onNavigateToThread(event.id) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } + HorizontalDivider(thickness = 1.dp) } } } 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 c866b0ba5..06daa983a 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 @@ -83,7 +83,6 @@ 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 @@ -172,7 +171,7 @@ fun SearchScreen( } } - // NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect) + // NIP-50 people search subscription (use allRelayUrls — subscribe will connect) rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { return@rememberSubscription null @@ -195,7 +194,7 @@ fun SearchScreen( if (event is MetadataEvent) { localCache.consumeMetadata(event) @Suppress("UNCHECKED_CAST") - val user = localCache.getUserIfExists(event.pubKey) as? User + val user = localCache.getUserIfExists(event.pubKey) if (user != null) { state.addPeopleResult(user) } 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 d4368f984..ff54ca15b 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 @@ -42,48 +42,38 @@ import androidx.compose.material3.IconButton 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.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.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.model.Note import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders -import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription 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.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** * Desktop Thread Screen - displays a note and all its replies in a thread view. * - * Uses the shared drawReplyLevel modifier from commons to display reply nesting. + * Uses DesktopFeedViewModel + DesktopThreadFilter for cache-backed display. + * Keeps relay subscriptions to populate cache with thread data. */ @Composable fun ThreadScreen( @@ -99,115 +89,47 @@ fun ThreadScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, onReply: (Event) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() - val scope = rememberCoroutineScope() - - // State for the root note - var rootNote by remember { mutableStateOf(null) } - - // State for reply events - val replyEventState = - remember(noteId) { - EventCollectionState( - getId = { it.id }, - sortComparator = compareBy { it.createdAt }, - maxSize = 500, - scope = scope, - ) - } - val replyEvents by replyEventState.items.collectAsState() - - // Cache for calculating reply levels - val levelCache = remember(noteId) { mutableMapOf() } - - // Track EOSE to know when initial load is complete - var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } - var repliesEoseReceived by remember(noteId) { mutableStateOf(false) } - - // Track zaps per event - var zapsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - // Track reaction event IDs per target event to deduplicate - var reactionIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } - // Track reply/repost event IDs per target event to deduplicate - var replyIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } - var repostIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } - - // Bookmark state - var bookmarkList by remember { mutableStateOf(null) } - var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } // 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 }) + // Track EOSE for root note subscription + var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } - // 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()) - } - } - } - - // Subscribe to user's bookmark list - rememberSubscription(connectedRelays, account, relayManager = relayManager) { - if (connectedRelays.isNotEmpty() && account != null) { - SubscriptionConfig( - subId = "thread-bookmarks-${account.pubKeyHex.take(8)}", - filters = - listOf( - FilterBuilders.byAuthors( - authors = listOf(account.pubKeyHex), - kinds = listOf(BookmarkListEvent.KIND), - limit = 1, - ), - ), - relays = connectedRelays, - onEvent = { event, _, _, _ -> - if (event is BookmarkListEvent) { - bookmarkList = event - val pubIds = - event - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds - } - }, - onEose = { _, _ -> }, + // DesktopFeedViewModel reads thread from cache (root + replies via graph walk) + val threadViewModel = + remember(noteId) { + DesktopFeedViewModel( + DesktopThreadFilter(noteId, localCache), + localCache, ) - } else { - null } + DisposableEffect(threadViewModel) { + onDispose { threadViewModel.destroy() } } + val feedState by threadViewModel.feedState.feedContent.collectAsState() + val threadNotes = + if (feedState is FeedState.Loaded) { + val loaded by (feedState as FeedState.Loaded).feed.collectAsState() + loaded.list + } else { + kotlinx.collections.immutable.persistentListOf() + } - // Subscribe to the root note + // Level cache for reply nesting + val levelCache = remember(noteId) { mutableMapOf() } + + // Keep relay subscriptions to populate cache — root note rememberSubscription(connectedRelays, noteId, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { createNoteSubscription( relays = connectedRelays, noteId = noteId, - onEvent = { event, _, _, _ -> - if (event.id == noteId) { - rootNote = event - levelCache[event.id] = 0 - } + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + levelCache[event.id] = 0 }, onEose = { _, _ -> rootNoteEoseReceived = true @@ -218,129 +140,52 @@ fun ThreadScreen( } } - // Subscribe to replies + // Keep relay subscription for replies rememberSubscription(connectedRelays, noteId, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { createThreadRepliesSubscription( relays = connectedRelays, noteId = noteId, - onEvent = { event, _, _, _ -> - replyEventState.addItem(event) - }, - onEose = { _, _ -> - repliesEoseReceived = true + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) }, + onEose = { _, _ -> }, ) } else { null } } - // Subscribe to zaps for thread events - val allEventIds = listOf(noteId) + replyEvents.map { it.id } - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null - } - - createZapsSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - if (event is LnZapEvent) { - val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription - val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription - zapsByEvent = - zapsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptyList() - if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { - this[targetEventId] = existing + receipt - } - } - } - }, - ) + // Request interaction data — keyed on noteId (stable), not threadNotes (changes on every bundle) + DisposableEffect(noteId, subscriptionsCoordinator) { + val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} + val noteIds = threadNotes.mapNotNull { it.event?.id } + val relays = relayManager.relayStatuses.value.keys + val subId = + if (noteIds.isNotEmpty()) { + coordinator.requestInteractions(noteIds, relays) + } else { + null + } + onDispose { subId?.let { coordinator.releaseInteractions(it) } } } - // Subscribe to reactions for thread events - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null + // Load metadata for thread authors via coordinator + LaunchedEffect(threadNotes, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForNotes(threadNotes) } - - createReactionsSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - if (event is ReactionEvent) { - val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription - reactionIdsByEvent = - reactionIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) } - // Subscribe to replies for thread events (for counts) - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepliesSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - val replyToId = - event.tags - .filter { it.size >= 2 && it[0] == "e" } - .lastOrNull() - ?.get(1) ?: return@createRepliesSubscription - if (replyToId in allEventIds) { - replyIdsByEvent = - replyIdsByEvent.toMutableMap().apply { - val existing = this[replyToId] ?: emptySet() - this[replyToId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to reposts for thread events - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepostsSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - if (event is RepostEvent) { - val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription - repostIdsByEvent = - repostIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) - } - - // Calculate reply level for an event based on e-tags - fun calculateLevel(event: Event): Int { + // Calculate reply level for a note based on e-tags + fun calculateLevel(note: Note): Int { + val event = note.event ?: return 1 levelCache[event.id]?.let { return it } - // Find the event this is replying to (last e-tag or marked reply/root) val replyToId = findReplyToId(event) val level = if (replyToId == null || replyToId == noteId) { - 1 // Direct reply to root + 1 } else { (levelCache[replyToId] ?: 0) + 1 } @@ -348,6 +193,9 @@ fun ThreadScreen( return level } + val rootNote = threadNotes.firstOrNull { it.idHex == noteId } + val replyNotes = threadNotes.filter { it.idHex != noteId } + Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { // Header with back button @@ -370,165 +218,111 @@ fun ThreadScreen( ) } - 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, - 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!!, + when { + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + + feedState is FeedState.Loading && !rootNoteEoseReceived -> { + LoadingState("Loading thread...") + } + + 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 + if (rootNote != null) { + item(key = noteId) { + Column { + FeedNoteCard( + note = rootNote, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { rootNote.event?.let { onReply(it) } }, + 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() + }, + ) + } + HorizontalDivider(thickness = 1.dp) + } + } + + // Reply notes with level indicators + items(replyNotes, key = { it.idHex }) { note -> + val level = calculateLevel(note) + Column( + modifier = + Modifier + .drawReplyLevel( + level = level, + color = MaterialTheme.colorScheme.outlineVariant, + selected = MaterialTheme.colorScheme.outlineVariant, + ).clickable { + note.event?.let { onNavigateToThread(it.id) } + }, + ) { + FeedNoteCard( + note = note, relayManager = relayManager, localCache = localCache, account = account, nwcConnection = nwcConnection, - onReplyClick = { onReply(rootNote!!) }, + onReply = { note.event?.let { onReply(it) } }, 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 + 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() }, ) } + 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) - - 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, - 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 - }, + // Empty/loading state for replies + if (replyNotes.isEmpty()) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "No replies yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.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), - ) - } } } } - } // end Column + } // Lightbox overlay val lb = lightboxState @@ -541,7 +335,7 @@ fun ThreadScreen( onDismiss = { lightboxState = null }, ) } - } // end Box + } } /** @@ -552,13 +346,11 @@ private fun findReplyToId(event: Event): String? { val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" } if (eTags.isEmpty()) return null - // Check for NIP-10 marked tags first val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" } if (replyTag != null) return replyTag[1] val rootTag = eTags.find { it.size >= 4 && it[3] == "root" } if (rootTag != null && eTags.size == 1) return rootTag[1] - // Fall back to positional (last e-tag is the reply-to) return eTags.lastOrNull()?.get(1) } 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 15f0a0de6..0c9582151 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 @@ -59,6 +59,7 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 @@ -74,29 +75,31 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus -import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders 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.amethyst.desktop.viewmodels.DesktopFeedViewModel 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.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -118,16 +121,28 @@ fun UserProfileScreen( onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onNavigateToArticle: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } - // User metadata - var displayName by remember { mutableStateOf(null) } - var about by remember { mutableStateOf(null) } - var picture by remember { mutableStateOf(null) } - var followersCount by remember { mutableStateOf(0) } - var followingCount by remember { mutableStateOf(0) } + // User metadata — seed from cache so returning to profile is instant + val cachedUser = remember(pubKeyHex) { localCache.getUserIfExists(pubKeyHex) } + val cachedMetadata = remember(pubKeyHex) { cachedUser?.metadataOrNull() } + var displayName by remember { mutableStateOf(cachedMetadata?.bestName()) } + var about by remember { + mutableStateOf( + cachedMetadata + ?.flow + ?.value + ?.info + ?.about, + ) + } + var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) } + var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) } + var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) } // Profile editing state (only for own profile) val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex @@ -138,25 +153,56 @@ fun UserProfileScreen( val scope = rememberCoroutineScope() - // User's posts - val eventState = - remember { - EventCollectionState( - getId = { it.id }, - sortComparator = compareByDescending { it.createdAt }, - maxSize = 200, - scope = scope, + // User's posts — cache-backed via DesktopFeedViewModel + val profileViewModel = + remember(pubKeyHex) { + DesktopFeedViewModel( + DesktopProfileFeedFilter(pubKeyHex, localCache), + localCache, ) } - val events by eventState.items.collectAsState() - var postsLoading by remember { mutableStateOf(true) } - var postsError by remember { mutableStateOf(null) } + DisposableEffect(profileViewModel) { + onDispose { profileViewModel.destroy() } + } + val profileFeedState by profileViewModel.feedState.feedContent.collectAsState() + val profileLoadedNotes = + if (profileFeedState is FeedState.Loaded) { + val loaded by (profileFeedState as FeedState.Loaded).feed.collectAsState() + loaded.list + } else { + kotlinx.collections.immutable.persistentListOf() + } var retryTrigger by remember { mutableStateOf(0) } + // Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + SubscriptionConfig( + subId = generateSubId("profile-notes-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.textNotesFromAuthors( + authors = listOf(pubKeyHex), + limit = 200, + ), + ), + relays = connectedRelays, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Tab and gallery state var selectedTab by remember { mutableStateOf(0) } var lightboxState by remember { mutableStateOf(null) } val pictureEvents = remember { mutableStateListOf() } + val articleEvents = remember { mutableStateListOf() } + val highlightEvents = remember { mutableStateListOf() } // Follow state val followState = @@ -201,13 +247,6 @@ fun UserProfileScreen( } } - // Clear posts when profile changes - remember(pubKeyHex, retryTrigger) { - eventState.clear() - postsLoading = true - postsError = null - } - // Subscribe to user metadata rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { @@ -250,8 +289,9 @@ fun UserProfileScreen( pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> if (event is ContactListEvent) { - // Count the number of people this user follows - followingCount = event.verifiedFollowKeySet().size + val count = event.verifiedFollowKeySet().size + followingCount = count + localCache.cacheFollowingCount(pubKeyHex, count) } }, onEose = { _, _ -> }, @@ -267,9 +307,8 @@ fun UserProfileScreen( // Subscribe to followers (contact lists that tag this user) rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { - // Clear previous followers when subscription restarts + // Clear dedup set but keep cached followersCount visible until new data arrives followerAuthors.clear() - followersCount = 0 SubscriptionConfig( subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}", @@ -285,7 +324,9 @@ fun UserProfileScreen( onEvent = { event, _, _, _ -> // Count unique authors who follow this user if (followerAuthors.add(event.pubKey)) { - followersCount = followerAuthors.size + val count = followerAuthors.size + followersCount = count + localCache.cacheFollowerCount(pubKeyHex, count) } }, onEose = { _, _ -> }, @@ -295,28 +336,6 @@ fun UserProfileScreen( } } - // Subscribe to user posts - rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { - if (connectedRelays.isNotEmpty()) { - postsLoading = true - postsError = null - createUserPostsSubscription( - relays = connectedRelays, - pubKeyHex = pubKeyHex, - onEvent = { event, _, _, _ -> - eventState.addItem(event) - }, - onEose = { _, _ -> - postsLoading = false - }, - ) - } else { - postsLoading = false - postsError = "No relays configured" - null - } - } - // Subscribe to picture events (kind 20) for gallery tab rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { @@ -344,6 +363,60 @@ fun UserProfileScreen( } } + // Subscribe to long-form articles (kind 30023) for reads tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + articleEvents.clear() + SubscriptionConfig( + subId = generateSubId("articles-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 50, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent && articleEvents.none { it.id == event.id }) { + articleEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Subscribe to highlight events (kind 9802) for highlights tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + highlightEvents.clear() + SubscriptionConfig( + subId = generateSubId("hl-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(HighlightEvent.KIND), + limit = 100, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is HighlightEvent && highlightEvents.none { it.id == event.id }) { + highlightEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Scroll state for detecting scroll direction val listState = rememberLazyListState() var showFloatingHeader by remember { mutableStateOf(false) } @@ -629,43 +702,28 @@ fun UserProfileScreen( Text("Notes", modifier = Modifier.padding(12.dp)) } Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text( + "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { Text("Gallery", modifier = Modifier.padding(12.dp)) } + Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { + Text( + "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", + 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, - ) { - 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 -> { + when (profileFeedState) { + is FeedState.Loading -> { item(key = "loading") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), @@ -684,7 +742,7 @@ fun UserProfileScreen( } } - events.isEmpty() -> { + is FeedState.Empty -> { item(key = "empty") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), @@ -699,10 +757,38 @@ fun UserProfileScreen( } } - else -> { - items(events.distinctBy { it.id }, key = { it.id }) { event -> + is FeedState.FeedError -> { + item(key = "error") { + 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( + (profileFeedState as FeedState.FeedError).errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } + } + } + } + + is FeedState.Loaded -> { + // loadedNotes collected outside LazyColumn in profileLoadedNotes + items(profileLoadedNotes, key = { it.idHex }) { note -> FeedNoteCard( - event = event, + note = note, relayManager = relayManager, localCache = localCache, account = account, @@ -726,6 +812,38 @@ fun UserProfileScreen( } 1 -> { + if (articleEvents.isEmpty()) { + item(key = "no-articles") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No long-form articles", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + items( + articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt }, + key = { "art-${it.id}" }, + ) { article -> + LongFormCard( + event = article, + localCache = localCache, + onAuthorClick = { onNavigateToProfile(article.pubKey) }, + onClick = { + val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" + onNavigateToArticle(addressTag) + }, + ) + } + } + } + + 2 -> { item(key = "gallery") { GalleryTab( pictureEvents = pictureEvents, @@ -734,6 +852,33 @@ fun UserProfileScreen( ) } } + + 3 -> { + if (highlightEvents.isEmpty()) { + item(key = "no-highlights") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No published highlights", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + items( + highlightEvents.sortedByDescending { it.createdAt }, + key = { "hl-${it.id}" }, + ) { highlight -> + PublishedHighlightCard( + highlight = highlight, + localCache = localCache, + ) + } + } + } } } } @@ -940,3 +1085,64 @@ private suspend fun updateProfileDisplayName( onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error")) } } + +@Composable +private fun PublishedHighlightCard( + highlight: HighlightEvent, + localCache: DesktopLocalCache, +) { + val articleAddress = highlight.inPostAddress() + val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source" + + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Quoted highlight text + Text( + text = "\u201C${highlight.quote()}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + + // Note/comment + val comment = highlight.comment() + if (!comment.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = comment, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Context (surrounding paragraph) + val context = highlight.context() + if (!context.isNullOrBlank() && context != highlight.quote()) { + Spacer(Modifier.height(8.dp)) + Text( + text = context.take(200) + if (context.length > 200) "\u2026" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + + Spacer(Modifier.height(8.dp)) + + // Source article reference + if (articleAddress != null) { + Text( + text = "from ${articleAddress.dTag.ifBlank { "article" }}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} 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 ea6c00113..9ce7b7167 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 @@ -49,8 +49,7 @@ 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.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -63,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.resources.login_card_title import com.vitorpamplona.amethyst.commons.resources.login_generate_button import com.vitorpamplona.amethyst.desktop.account.LoginProgress import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri +import com.vitorpamplona.amethyst.desktop.setText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -251,7 +251,7 @@ private fun NostrConnectContent( val scope = rememberCoroutineScope() @Suppress("DEPRECATION") - val clipboardManager = LocalClipboardManager.current + val clipboardManager = LocalClipboard.current if (errorMessage != null) { Text( @@ -318,7 +318,11 @@ private fun NostrConnectContent( Spacer(Modifier.height(8.dp)) OutlinedButton( - onClick = { clipboardManager.setText(AnnotatedString(uri)) }, + onClick = { + scope.launch { + clipboardManager.setText(uri) + } + }, modifier = Modifier.fillMaxWidth(), ) { Text("Copy URI") 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 index 8c9993d67..f8d45cd56 100644 --- 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 @@ -33,9 +33,9 @@ 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.Forward +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile 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 @@ -92,7 +92,7 @@ fun ChatFileAttachment( // Auto-decrypt images LaunchedEffect(url) { - if (keyBytes != null && nonce != null && url != null) { + if (keyBytes != null && nonce != null) { isLoading = true try { val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce) @@ -191,7 +191,7 @@ fun ChatFileAttachment( // Non-image file Row(verticalAlignment = Alignment.CenterVertically) { Icon( - Icons.Default.InsertDriveFile, + Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = "File", modifier = Modifier.size(32.dp), ) @@ -237,7 +237,7 @@ fun ChatFileAttachment( DropdownMenuItem( text = { Text("Forward") }, leadingIcon = { - Icon(Icons.Default.Forward, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(Icons.AutoMirrored.Filled.Forward, contentDescription = null, modifier = Modifier.size(18.dp)) }, onClick = { showContextMenu = false 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 da3745002..8b3dd7956 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 @@ -85,7 +85,6 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader @@ -196,7 +195,7 @@ fun ChatPane( } // Resolve users for the header - val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } + val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) } val isGroup = users.size > 1 // Load room into message state @@ -804,7 +803,7 @@ private suspend fun sendEncryptedFiles( ) { val orchestrator = DesktopUploadOrchestrator() val server = DesktopPreferences.preferredBlossomServer - val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() } + val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) }.map { it.toPTag() } for (file in files) { val cipher = AESGCM() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index 26e0cbd81..18afcbc7b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -161,7 +161,7 @@ class ChatroomListState( ) val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -199,11 +199,11 @@ class ChatroomListState( // Skip rooms with no messages if (chatroom.messages.isEmpty()) continue - val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } + val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) } // Collect pubkeys without profile info for (pubkey in key.users) { - val user = cacheProvider.getUserIfExists(pubkey) as? User + val user = cacheProvider.getUserIfExists(pubkey) if (user == null || user.metadataOrNull() == null) { pubkeysNeedingMetadata.add(pubkey) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt index 216d3ab43..3399e8e4d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponseDetailed +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -56,7 +56,7 @@ class DmSendTracker( val allSuccessful = mutableSetOf() for ((event, relays) in events) { - val results = client.sendAndWaitForResponseDetailed(event, relays, 10) + val results = client.publishAndConfirmDetailed(event, relays, 10) allSuccessful.addAll(results.filter { it.value }.keys) _status.value = DmBroadcastStatus.Sending(allSuccessful.size, totalRelays) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index dd220a06b..aadf364f3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.search.SearchResult import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard @@ -80,7 +79,8 @@ fun NewDmDialog( val cachedUsers by searchState.cachedUserResults.collectAsState() val relaySearchResults by searchState.relaySearchResults.collectAsState() val isSearchingRelays by searchState.isSearchingRelays.collectAsState() - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val focusRequester = remember { FocusRequester() } // NIP-50 relay search when local cache has few/no results @@ -182,7 +182,7 @@ fun NewDmDialog( bech32Results.filterIsInstance() items(userResults) { result -> val user = - cacheProvider.getUserIfExists(result.pubKeyHex) as? User + cacheProvider.getUserIfExists(result.pubKeyHex) if (user != null) { UserSearchCard( user = user, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/RelayHealthIndicator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/RelayHealthIndicator.kt new file mode 100644 index 000000000..16249abb2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/RelayHealthIndicator.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.components + +import androidx.compose.foundation.layout.padding +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.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +/** + * Displays relay health as elapsed time since last event. + * Hidden when < 30s (healthy). Shows "45s" or "3m" when stale. + */ +@Composable +fun RelayHealthIndicator( + lastEventReceivedAt: Long?, + modifier: Modifier = Modifier, +) { + if (lastEventReceivedAt == null) return + + // Tick every 5s to update elapsed display + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + delay(5_000) + now = System.currentTimeMillis() + } + } + + val elapsedMs = now - lastEventReceivedAt + if (elapsedMs < 30_000) return // healthy, don't show + + val text = formatElapsed(elapsedMs) + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = 8.dp), + ) +} + +private fun formatElapsed(elapsedMs: Long): String { + val seconds = elapsedMs / 1000 + return when { + seconds < 60 -> "${seconds}s ago" + seconds < 3600 -> "${seconds / 60}m ago" + else -> "${seconds / 3600}h ago" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt index f7ef606dd..f85a4707c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -53,6 +53,8 @@ private val COLUMN_OPTIONS = DeckColumnType.Messages, DeckColumnType.Search, DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.MyHighlights, DeckColumnType.Bookmarks, DeckColumnType.GlobalFeed, DeckColumnType.MyProfile, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 3deb62b25..3bd9fe2ca 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -132,6 +132,10 @@ fun DeckColumnType.icon(): ImageVector = DeckColumnType.MyProfile -> Icons.Default.Person DeckColumnType.Chess -> Icons.Default.Extension DeckColumnType.Settings -> Icons.Default.Settings + is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article + is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article + DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article + DeckColumnType.MyHighlights -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Profile -> Icons.Default.Person is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Hashtag -> Icons.Default.Tag 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 4e3ff3016..8d8f01805 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 @@ -44,9 +44,14 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen +import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen +import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen @@ -92,6 +97,8 @@ fun DeckColumnContainer( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -132,6 +139,8 @@ fun DeckColumnContainer( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, compactMode = true, onShowComposeDialog = onShowComposeDialog, @@ -139,6 +148,8 @@ fun DeckColumnContainer( onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -152,11 +163,14 @@ fun DeckColumnContainer( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } @@ -175,6 +189,8 @@ internal fun RootContent( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, appScope: CoroutineScope, compactMode: Boolean = false, onShowComposeDialog: () -> Unit, @@ -182,6 +198,8 @@ internal fun RootContent( onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, + onNavigateToEditor: (String?) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -202,7 +220,7 @@ internal fun RootContent( } DeckColumnType.Notifications -> { - NotificationsScreen(relayManager, account, subscriptionsCoordinator) + NotificationsScreen(relayManager, localCache, account, subscriptionsCoordinator) } DeckColumnType.Messages -> { @@ -231,8 +249,11 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + nwcConnection = nwcConnection, onNavigateToProfile = onNavigateToProfile, - onNavigateToArticle = onNavigateToThread, + onNavigateToArticle = onNavigateToArticle, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, ) } @@ -275,6 +296,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -323,6 +345,44 @@ internal fun RootContent( ) } + is DeckColumnType.Article -> { + ArticleReaderScreen( + addressTag = columnType.addressTag, + relayManager = relayManager, + localCache = localCache, + account = account, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + onBack = {}, + onNavigateToProfile = onNavigateToProfile, + ) + } + + is DeckColumnType.Editor -> { + ArticleEditorScreen( + draftSlug = columnType.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + account = account, + relayManager = relayManager, + onBack = {}, + onPublished = {}, + ) + } + + DeckColumnType.Drafts -> { + DraftsScreen( + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + onOpenEditor = { slug -> onNavigateToEditor(slug) }, + ) + } + + DeckColumnType.MyHighlights -> { + com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen( + highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) }, + onNavigateToArticle = onNavigateToArticle, + ) + } + is DeckColumnType.Hashtag -> { SearchScreen( localCache = localCache, @@ -344,11 +404,14 @@ internal fun OverlayContent( account: AccountState.LoggedIn, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, onBack: () -> Unit, ) { when (screen) { @@ -363,6 +426,7 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -383,6 +447,31 @@ internal fun OverlayContent( ) } + is DesktopScreen.Article -> { + ArticleReaderScreen( + addressTag = screen.addressTag, + relayManager = relayManager, + localCache = localCache, + account = account, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + onBack = onBack, + onNavigateToProfile = onNavigateToProfile, + ) + } + + is DesktopScreen.Editor -> { + val overlayScope = androidx.compose.runtime.rememberCoroutineScope() + ArticleEditorScreen( + draftSlug = screen.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) }, + account = account, + relayManager = relayManager, + onBack = onBack, + onPublished = onBack, + ) + } + else -> { androidx.compose.material3.Text( "Unsupported screen type", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index 2b1642244..3f5ea27b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -51,6 +51,18 @@ sealed class DeckColumnType { val noteId: String, ) : DeckColumnType() + data class Article( + val addressTag: String, + ) : DeckColumnType() + + data class Editor( + val draftSlug: String? = null, + ) : DeckColumnType() + + object Drafts : DeckColumnType() + + object MyHighlights : DeckColumnType() + data class Hashtag( val tag: String, ) : DeckColumnType() @@ -67,6 +79,10 @@ sealed class DeckColumnType { MyProfile -> "Profile" Chess -> "Chess" Settings -> "Settings" + is Article -> "Article" + is Editor -> "New Article" + Drafts -> "Drafts" + MyHighlights -> "Highlights" is Profile -> "Profile" is Thread -> "Thread" is Hashtag -> "#$tag" @@ -84,6 +100,10 @@ sealed class DeckColumnType { MyProfile -> "my_profile" Chess -> "chess" Settings -> "settings" + is Article -> "article" + is Editor -> "editor" + Drafts -> "drafts" + MyHighlights -> "highlights" is Profile -> "profile" is Thread -> "thread" is Hashtag -> "hashtag" 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 519d93217..e9f23f353 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 @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm @@ -61,6 +62,8 @@ fun DeckLayout( iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -113,6 +116,8 @@ fun DeckLayout( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, 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 ea458daa2..63adf3f56 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 @@ -63,8 +63,10 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope @@ -79,6 +81,8 @@ private val navItems = listOf( NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), + NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"), + NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"), NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), @@ -97,12 +101,15 @@ fun SinglePaneLayout( iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, + lastRelayEventAt: Long? = null, modifier: Modifier = Modifier, ) { var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } @@ -145,6 +152,12 @@ fun SinglePaneLayout( Spacer(Modifier.weight(1f)) + // Relay health — shows elapsed time since last event (hidden when <30s) + RelayHealthIndicator( + lastEventReceivedAt = lastRelayEventAt, + modifier = Modifier.padding(bottom = 4.dp), + ) + BunkerHeartbeatIndicator( signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, @@ -171,12 +184,16 @@ fun SinglePaneLayout( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -190,11 +207,14 @@ fun SinglePaneLayout( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt new file mode 100644 index 000000000..3fec86bff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.highlights + +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.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.text.font.FontStyle +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.launch + +@Composable +fun ArticleHighlightsPanel( + highlights: List, + highlightStore: DesktopHighlightStore, + articleContent: String, + signer: NostrSigner?, + relayManager: DesktopRelayConnectionManager?, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var editTarget by remember { mutableStateOf(null) } + + Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + highlights.forEach { highlight -> + HighlightPanelCard( + highlight = highlight, + onDelete = { + scope.launch { highlightStore.removeHighlight(highlight.id) } + }, + onEditNote = { editTarget = highlight }, + onPublish = + if (!highlight.published && signer != null && relayManager != null) { + { + scope.launch { + val context = + HighlightPublishAction.extractContext( + articleContent, + highlight.text, + ) + val event = + HighlightPublishAction.publish( + highlightText = highlight.text, + articleAddressTag = highlight.articleAddressTag, + note = highlight.note, + context = context, + signer = signer, + ) + relayManager.broadcastToAll(event) + highlightStore.markPublished(highlight.id, event.id) + } + } + } else { + null + }, + ) + } + } + } + + editTarget?.let { highlight -> + HighlightAnnotationDialog( + selectedText = highlight.text, + onConfirm = { note -> + scope.launch { highlightStore.updateNote(highlight.id, note) } + editTarget = null + }, + onDismiss = { editTarget = null }, + ) + } +} + +@Composable +private fun HighlightPanelCard( + highlight: HighlightData, + onDelete: () -> Unit, + onEditNote: () -> Unit, + onPublish: (() -> Unit)?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "\u201C${highlight.text}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface, + ) + + val noteText = highlight.note + if (!noteText.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = noteText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // Published status + Icon( + imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock, + contentDescription = if (highlight.published) "Published" else "Private", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (highlight.published) "Published" else "Private", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp), + ) + + Spacer(Modifier.weight(1f)) + + // Publish button + if (onPublish != null) { + IconButton(onClick = onPublish, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Public, + contentDescription = "Publish to relays", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + IconButton(onClick = onEditNote, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit note", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt new file mode 100644 index 000000000..d43b51c13 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.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.amethyst.desktop.ui.highlights + +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.material3.AlertDialog +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun HighlightAnnotationDialog( + selectedText: String, + onConfirm: (note: String) -> Unit, + onDismiss: () -> Unit, +) { + var note by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Highlight Note") }, + text = { + Column { + Text( + text = "\"$selectedText\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = note, + onValueChange = { note = it }, + label = { Text("Note (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 5, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(note) }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt new file mode 100644 index 000000000..992640b02 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.highlights + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag +import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag +import com.vitorpamplona.quartz.utils.TimeUtils + +object HighlightPublishAction { + suspend fun publish( + highlightText: String, + articleAddressTag: String, + note: String?, + context: String?, + signer: NostrSigner, + ): HighlightEvent { + val tags = mutableListOf>() + + tags.add(AltTag.assemble(HighlightEvent.ALT)) + tags.add(ATag.assemble(articleAddressTag, null)) + + // Tag the article author + val parts = articleAddressTag.split(":", limit = 3) + val pubkey = parts.getOrNull(1) + if (!pubkey.isNullOrBlank()) { + tags.add(PTag.assemble(pubkey, null)) + } + + if (!note.isNullOrBlank()) { + tags.add(CommentTag.assemble(note)) + } + + if (!context.isNullOrBlank()) { + tags.add(ContextTag.assemble(context)) + } + + return signer.sign( + createdAt = TimeUtils.now(), + kind = HighlightEvent.KIND, + tags = tags.toTypedArray(), + content = highlightText, + ) + } + + fun extractContext( + content: String, + highlightText: String, + ): String? { + val paragraphs = content.split("\n\n") + return paragraphs.find { it.contains(highlightText) } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt new file mode 100644 index 000000000..ff417478e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.highlights + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun MyHighlightsScreen( + highlightStore: DesktopHighlightStore, + onNavigateToArticle: (addressTag: String) -> Unit, +) { + val allHighlights by highlightStore.highlights.collectAsState() + val scope = rememberCoroutineScope() + var deleteTarget by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Text( + "Highlights", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(16.dp)) + + if (allHighlights.isEmpty()) { + EmptyState( + title = "No highlights yet", + description = "Select text in an article and choose \"Highlight\" to save passages.", + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + allHighlights.forEach { (addressTag, highlights) -> + val articleTitle = highlights.firstOrNull()?.articleTitle ?: addressTag + + stickyHeader(key = addressTag) { + ArticleGroupHeader( + title = articleTitle, + onClick = { onNavigateToArticle(addressTag) }, + ) + } + + items(highlights, key = { it.id }) { highlight -> + HighlightCard( + highlight = highlight, + onDelete = { deleteTarget = highlight }, + ) + } + } + } + } + } + + deleteTarget?.let { highlight -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete Highlight") }, + text = { + Text( + "Delete this highlight? This cannot be undone.", + ) + }, + confirmButton = { + TextButton(onClick = { + scope.launch { highlightStore.removeHighlight(highlight.id) } + deleteTarget = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun ArticleGroupHeader( + title: String, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun HighlightCard( + highlight: HighlightData, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "\u201C${highlight.text}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface, + ) + + val noteText = highlight.note + if (!noteText.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = noteText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatTimestamp(highlight.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock, + contentDescription = if (highlight.published) "Published" else "Private", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete highlight", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +private fun formatTimestamp(epochSeconds: Long): String = + Instant + .ofEpochSecond(epochSeconds) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) 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 index 194bad9f7..aa8f267ce 100644 --- 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 @@ -78,7 +78,6 @@ 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 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 index 978ab591a..3cb34c20d 100644 --- 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 @@ -29,7 +29,6 @@ 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 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 index 31f7031e4..d05d3ff04 100644 --- 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 @@ -47,6 +47,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState @@ -243,7 +244,7 @@ private fun ServerRow( ) { // Status indicator with tooltip TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above, 4.dp), tooltip = { PlainTooltip { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt new file mode 100644 index 000000000..b094bcc79 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.viewmodels + +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Desktop-specific FeedViewModel that loads existing cache data on creation. + * + * The base FeedViewModel only sets up event stream collectors — it doesn't + * load data already in cache. This means navigating back to a screen would + * show Loading forever until a new relay event arrives. This subclass fixes + * that by calling refreshSuspended() on init. + */ +class DesktopFeedViewModel( + filter: FeedFilter, + cacheProvider: ICacheProvider, +) : FeedViewModel(filter, cacheProvider) { + init { + viewModelScope.launch(Dispatchers.IO) { + feedState.refreshSuspended() + } + } + + /** + * Cancel viewModelScope. ViewModel.clear() is internal in lifecycle KMP, + * so Desktop composables use this for cleanup via DisposableEffect. + */ + fun destroy() { + viewModelScope.cancel() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt new file mode 100644 index 000000000..36dbc87f8 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -0,0 +1,404 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +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.runBlocking +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Integration tests for the Coordinator → Cache → ViewModel pipeline. + * + * These tests use a stub INostrClient (no real relay connections) and exercise + * the full event consumption path through DesktopRelaySubscriptionsCoordinator. + * + * Key invariant being tested: when coordinator.consumeEvent() is called, + * the event should flow through cache → eventStream → ViewModel.feedState. + */ +class CoordinatorPipelineTest { + private val userPubKey = "a".repeat(64) + private val followedPubKey = "b".repeat(64) + private val dummySig = "0".repeat(128) + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private suspend fun waitForBundler() = delay(600) + + /** + * Stub INostrClient — records subscription calls but doesn't connect to any relay. + * This lets us test the coordinator's event routing without network dependencies. + */ + private class StubNostrClient : INostrClient { + val openedSubs = mutableMapOf>, SubscriptionListener?>>() + + override fun connectedRelaysFlow(): StateFlow> = MutableStateFlow(emptySet()) + + override fun availableRelaysFlow(): StateFlow> = MutableStateFlow(emptySet()) + + override fun connect() {} + + override fun disconnect() {} + + override fun close() {} + + override fun reconnect( + onlyIfChanged: Boolean, + ignoreRetryDelays: Boolean, + ) {} + + override fun isActive() = false + + override fun syncFilters(relay: IRelayClient) {} + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + openedSubs[subId] = filters to listener + } + + override fun count( + subId: String, + filters: Map>, + ) {} + + override fun unsubscribe(subId: String) { + openedSubs.remove(subId) + } + + override fun publish( + event: Event, + relayList: Set, + ) {} + + override fun addConnectionListener(listener: RelayConnectionListener) {} + + override fun removeConnectionListener(listener: RelayConnectionListener) {} + + override fun getReqFiltersOrNull(subId: String): Map>? = null + + override fun getCountFiltersOrNull(subId: String): Map>? = null + + override fun activeRequests(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + } + + private fun createCoordinator( + cache: DesktopLocalCache, + scope: CoroutineScope, + ): Pair { + val client = StubNostrClient() + val coordinator = + DesktopRelaySubscriptionsCoordinator( + client = client, + scope = scope, + indexRelays = setOf(relayUrl), + localCache = cache, + ) + return coordinator to client + } + + // ----------------------------------------------------------------------- + // 1. Coordinator → Cache → ViewModel flow + // ----------------------------------------------------------------------- + + @Test + fun `consumeEvent routes text note into cache and triggers ViewModel update`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Simulate relay event arriving through coordinator + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Hello from relay", + sig = dummySig, + ) + coordinator.consumeEvent(event, relayUrl) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs( + state, + "ViewModel should be Loaded after coordinator.consumeEvent()", + ) + assertTrue( + vm.feedState.visibleNotes().any { it.idHex == event.id }, + "Note should appear in feed", + ) + + vm.destroy() + scope.cancel() + } + + @Test + fun `consumeEvent updates lastEventAt timestamp`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + assertTrue(coordinator.lastEventAt.value == null, "lastEventAt should be null initially") + + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "test", + sig = dummySig, + ) + coordinator.consumeEvent(event, relayUrl) + waitForBundler() + + assertTrue(coordinator.lastEventAt.value != null, "lastEventAt should be set after consumeEvent") + + scope.cancel() + } + + @Test + fun `contact list consumed via coordinator updates followedUsers`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val contactEvent = + ContactListEvent( + id = "cl1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = arrayOf(arrayOf("p", followedPubKey)), + content = "", + sig = dummySig, + ) + coordinator.consumeEvent(contactEvent, relayUrl) + waitForBundler() + + assertTrue( + cache.followedUsers.value.contains(followedPubKey), + "followedUsers should contain the followed pubkey after contact list consumption", + ) + + scope.cancel() + } + + @Test + fun `following feed shows notes after contact list and text notes arrive via coordinator`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + // Step 1: Contact list arrives + val contactEvent = + ContactListEvent( + id = "cl1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = arrayOf(arrayOf("p", followedPubKey)), + content = "", + sig = dummySig, + ) + coordinator.consumeEvent(contactEvent, relayUrl) + waitForBundler() + + // Step 2: Create following feed ViewModel + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Step 3: Text note from followed user arrives + val textEvent = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = followedPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Note from followed user", + sig = dummySig, + ) + coordinator.consumeEvent(textEvent, relayUrl) + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs( + state, + "Following feed should show notes from followed users", + ) + assertTrue(vm.feedState.visibleNotes().size == 1) + + vm.destroy() + scope.cancel() + } + + @Test + fun `following feed remains empty when no contact list has been consumed`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + // No contact list consumed — followedUsers is empty + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + // Text note arrives but not from a followed user (because no follows) + val textEvent = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = followedPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Note that won't show", + sig = dummySig, + ) + coordinator.consumeEvent(textEvent, relayUrl) + waitForBundler() + + assertIs( + vm.feedState.feedContent.value, + "Following feed should be empty when no contact list loaded — " + + "this is the bug the user sees (0 notes, 0 followed)", + ) + + vm.destroy() + scope.cancel() + } + + // ----------------------------------------------------------------------- + // 2. Duplicate event handling + // ----------------------------------------------------------------------- + + @Test + fun `duplicate events are not double-counted in feed`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + waitForBundler() + + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "test", + sig = dummySig, + ) + + // Consume same event twice (can happen with multiple relays) + coordinator.consumeEvent(event, relayUrl) + coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/")) + waitForBundler() + + assertTrue( + vm.feedState.visibleNotes().size == 1, + "Same event from multiple relays should appear only once", + ) + + vm.destroy() + scope.cancel() + } + + // ----------------------------------------------------------------------- + // 3. Interaction subscriptions + // ----------------------------------------------------------------------- + + @Test + fun `requestInteractions opens subscription on client`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, client) = createCoordinator(cache, scope) + + val noteIds = listOf("n1".padEnd(64, '0')) + val subId = coordinator.requestInteractions(noteIds, setOf(relayUrl)) + + // subscribe is launched in scope — wait for it + delay(200) + + assertTrue(client.openedSubs.containsKey(subId), "Interaction subscription should be opened") + + coordinator.releaseInteractions(subId) + assertTrue(!client.openedSubs.containsKey(subId), "Subscription should be closed after release") + + scope.cancel() + } + + @Test + fun `requestInteractions with empty noteIds returns without opening subscription`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, client) = createCoordinator(cache, scope) + + coordinator.requestInteractions(emptyList(), setOf(relayUrl)) + delay(200) + + assertTrue(client.openedSubs.isEmpty(), "Should not open subscription for empty noteIds") + + scope.cancel() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt new file mode 100644 index 000000000..20c1b2bcd --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -0,0 +1,608 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.cache + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopNotificationFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Integration tests for the Desktop cache → filter → ViewModel pipeline. + * + * These tests verify that events consumed into DesktopLocalCache flow through + * feed filters and into DesktopFeedViewModel's FeedState correctly. + * + * The test structure mirrors how the app works: + * 1. Events arrive from relays + * 2. DesktopLocalCache.consume() stores them + emits via eventStream + * 3. DesktopFeedViewModel collects eventStream and updates FeedState + * 4. FeedFilter determines which notes appear in which feed + */ +class DesktopCachePipelineTest { + // Deterministic test keys + private val userPubKey = "a".repeat(64) + private val followedPubKey = "b".repeat(64) + private val unfollowedPubKey = "c".repeat(64) + private val dummySig = "0".repeat(128) + private val relayUrl = + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl("wss://relay.test/") + + /** Wait for async bundling (250ms bundler + margin) */ + private suspend fun waitForBundler() = delay(500) + + private fun textNote( + id: String, + pubKey: String, + content: String = "Hello world", + createdAt: Long = System.currentTimeMillis() / 1000, + replyToId: String? = null, + ): TextNoteEvent { + val tags = + if (replyToId != null) { + arrayOf(arrayOf("e", replyToId, "", "reply")) + } else { + emptyArray() + } + return TextNoteEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = tags, + content = content, + sig = dummySig, + ) + } + + private fun contactList( + id: String, + pubKey: String, + follows: List, + createdAt: Long = System.currentTimeMillis() / 1000, + ): ContactListEvent = + ContactListEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = follows.map { arrayOf("p", it) }.toTypedArray(), + content = "", + sig = dummySig, + ) + + private fun reaction( + id: String, + pubKey: String, + targetNoteId: String, + createdAt: Long = System.currentTimeMillis() / 1000, + ): ReactionEvent = + ReactionEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = arrayOf(arrayOf("e", targetNoteId)), + content = "+", + sig = dummySig, + ) + + // ----------------------------------------------------------------------- + // 1. Cache consumption basics + // ----------------------------------------------------------------------- + + @Test + fun `consume text note creates Note in cache`() { + val cache = DesktopLocalCache() + val event = textNote("note1".padEnd(64, '0'), userPubKey) + + val consumed = cache.consume(event, relayUrl) + + assertTrue(consumed, "First consume should return true") + val note = cache.getNoteIfExists("note1".padEnd(64, '0')) + assertTrue(note != null, "Note should exist in cache after consume") + assertEquals(event.id, note.event?.id) + } + + @Test + fun `consume same note twice returns false`() { + val cache = DesktopLocalCache() + val event = textNote("note1".padEnd(64, '0'), userPubKey) + + cache.consume(event, relayUrl) + val secondConsume = cache.consume(event, relayUrl) + + assertTrue(!secondConsume, "Second consume of same event should return false") + } + + @Test + fun `consume contact list updates followedUsers`() { + val cache = DesktopLocalCache() + val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey)) + + cache.consume(event, relayUrl) + + assertEquals(setOf(followedPubKey), cache.followedUsers.value) + } + + @Test + fun `newer contact list replaces older`() { + val cache = DesktopLocalCache() + val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) + val newer = + contactList( + "cl2".padEnd(64, '0'), + userPubKey, + listOf(followedPubKey, unfollowedPubKey), + createdAt = 200, + ) + + cache.consume(old, relayUrl) + cache.consume(newer, relayUrl) + + assertEquals(setOf(followedPubKey, unfollowedPubKey), cache.followedUsers.value) + } + + @Test + fun `older contact list is rejected`() { + val cache = DesktopLocalCache() + val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200) + val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) + + cache.consume(newer, relayUrl) + cache.consume(old, relayUrl) + + assertEquals( + setOf(followedPubKey, unfollowedPubKey), + cache.followedUsers.value, + "Older contact list should not overwrite newer", + ) + } + + @Test + fun `consume reaction links to target note`() { + val cache = DesktopLocalCache() + val noteId = "note1".padEnd(64, '0') + val note = textNote(noteId, userPubKey) + val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId) + + cache.consume(note, relayUrl) + cache.consume(react, relayUrl) + + val cachedNote = cache.getNoteIfExists(noteId)!! + assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event") + } + + // ----------------------------------------------------------------------- + // 2. Event stream emission + // ----------------------------------------------------------------------- + + @Test + fun `consume emits to eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + val collected = mutableListOf>() + + val job = + launch(Dispatchers.IO) { + cache.eventStream.newEventBundles.collect { collected.add(it) } + } + + // Give collector time to start + delay(50) + + val event = textNote("note1".padEnd(64, '0'), userPubKey) + cache.consume(event, relayUrl) + val note = cache.getNoteIfExists(event.id)!! + cache.emitNewNotes(setOf(note)) + + delay(100) + job.cancel() + + assertTrue(collected.isNotEmpty(), "EventStream should emit after consume + emitNewNotes") + assertTrue(collected.any { batch -> batch.any { it.idHex == event.id } }) + } + + // ----------------------------------------------------------------------- + // 3. Filter logic + // ----------------------------------------------------------------------- + + @Test + fun `GlobalFeedFilter includes all text notes`() { + val cache = DesktopLocalCache() + val filter = DesktopGlobalFeedFilter(cache) + + // Add notes from different authors + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl) + cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl) + + val feed = filter.feed() + assertEquals(3, feed.size, "Global feed should contain all text notes") + } + + @Test + fun `FollowingFeedFilter only includes notes from followed users`() { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val feed = filter.feed() + + assertEquals(1, feed.size, "Following feed should only contain notes from followed users") + assertEquals("n1".padEnd(64, '0'), feed[0].idHex) + } + + @Test + fun `FollowingFeedFilter returns empty when no follows`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { emptySet() } + val feed = filter.feed() + + assertTrue(feed.isEmpty(), "Following feed should be empty when no follows") + } + + @Test + fun `ProfileFeedFilter only shows notes from target pubkey`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) + + val filter = DesktopProfileFeedFilter(followedPubKey, cache) + val feed = filter.feed() + + assertEquals(1, feed.size) + assertEquals(followedPubKey, feed[0].author?.pubkeyHex) + } + + @Test + fun `ThreadFilter returns root and replies`() { + val cache = DesktopLocalCache() + val rootId = "root".padEnd(64, '0') + val replyId = "reply".padEnd(64, '0') + + cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl) + + val filter = DesktopThreadFilter(rootId, cache) + val feed = filter.feed() + + assertEquals(2, feed.size, "Thread should contain root + reply") + } + + @Test + fun `NotificationFeedFilter shows events tagging user`() { + val cache = DesktopLocalCache() + val noteId = "note1".padEnd(64, '0') + cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl) + + // Reaction from someone else targeting user's note + val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200) + cache.consume(react, relayUrl) + + val filter = DesktopNotificationFeedFilter(userPubKey, cache) + val feed = filter.feed() + + // ReactionEvent tags "e" not "p" — notification filter requires isTaggedUser + // This test documents the current behavior + val reactNote = cache.getNoteIfExists("react1".padEnd(64, '0')) + val reactEvent = reactNote?.event + assertTrue(reactEvent != null, "Reaction event should exist in cache") + } + + // ----------------------------------------------------------------------- + // 4. ViewModel integration + // ----------------------------------------------------------------------- + + @Test + fun `ViewModel starts in Loading then transitions to Loaded after refresh`() = + runBlocking { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + // Wait for init refresh + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "After consuming notes and refreshing, state should be Loaded") + + val notes = vm.feedState.visibleNotes() + assertEquals(1, notes.size) + vm.destroy() + } + + @Test + fun `ViewModel shows Empty when cache has no matching notes`() = + runBlocking { + val cache = DesktopLocalCache() + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "ViewModel should show Empty when no notes in cache") + vm.destroy() + } + + @Test + fun `ViewModel updates when new notes arrive via eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Simulate relay event arriving + val event = textNote("n1".padEnd(64, '0'), userPubKey) + cache.consume(event, relayUrl) + val note = cache.getNoteIfExists(event.id)!! + cache.emitNewNotes(setOf(note)) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "ViewModel should transition to Loaded after new notes arrive") + assertEquals(1, vm.feedState.visibleNotes().size) + vm.destroy() + } + + @Test + fun `Following ViewModel only shows followed users notes via eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + // Add followed user's note + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100) + cache.consume(e1, relayUrl) + val note1 = cache.getNoteIfExists(e1.id)!! + cache.emitNewNotes(setOf(note1)) + waitForBundler() + + assertEquals(1, vm.feedState.visibleNotes().size, "Should show followed user's note") + + // Add unfollowed user's note + val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200) + cache.consume(e2, relayUrl) + val note2 = cache.getNoteIfExists(e2.id)!! + cache.emitNewNotes(setOf(note2)) + waitForBundler() + + assertEquals(1, vm.feedState.visibleNotes().size, "Should NOT show unfollowed user's note") + vm.destroy() + } + + @Test + fun `Following ViewModel feed is empty when followedUsers is empty`() = + runBlocking { + val cache = DesktopLocalCache() + // No contact list consumed — followedUsers remains empty + + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) + cache.consume(e1, relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + assertIs( + vm.feedState.feedContent.value, + "Following feed should be empty when no contact list loaded", + ) + vm.destroy() + } + + // ----------------------------------------------------------------------- + // 5. Cache clear + // ----------------------------------------------------------------------- + + @Test + fun `clear resets all cache state`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + cache.clear() + + assertEquals(0, cache.noteCount()) + assertEquals(0, cache.userCount()) + assertTrue(cache.followedUsers.value.isEmpty()) + } + + // ----------------------------------------------------------------------- + // 6. Feed ordering + // ----------------------------------------------------------------------- + + @Test + fun `global feed is sorted newest first`() { + val cache = DesktopLocalCache() + cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl) + cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl) + + val filter = DesktopGlobalFeedFilter(cache) + val feed = filter.feed() + + assertEquals("new".padEnd(64, '0'), feed[0].idHex, "Newest note should be first") + assertEquals("old".padEnd(64, '0'), feed[2].idHex, "Oldest note should be last") + } + + // ----------------------------------------------------------------------- + // 7. Metadata consumption + // ----------------------------------------------------------------------- + + @Test + fun `consumeMetadata updates user info`() { + val cache = DesktopLocalCache() + val metadata = + com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent( + id = "meta1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""", + sig = dummySig, + ) + + cache.consume(metadata, relayUrl) + + val user = cache.getUserIfExists(userPubKey) + assertTrue(user != null, "User should exist after metadata consumption") + // Metadata parsing may vary, but user object should be created + assertEquals(userPubKey, user.pubkeyHex) + } + + // ----------------------------------------------------------------------- + // 8. Additive filter incremental updates + // ----------------------------------------------------------------------- + + @Test + fun `GlobalFeedFilter applyFilter only accepts TextNoteEvents`() { + val cache = DesktopLocalCache() + val filter = DesktopGlobalFeedFilter(cache) + + // Create a text note + val textEvent = textNote("t1".padEnd(64, '0'), userPubKey) + cache.consume(textEvent, relayUrl) + val textNote = cache.getNoteIfExists(textEvent.id)!! + + // Create a reaction (not a text note) + val reactEvent = reaction("r1".padEnd(64, '0'), userPubKey, "t1".padEnd(64, '0')) + cache.consume(reactEvent, relayUrl) + val reactNote = cache.getNoteIfExists(reactEvent.id)!! + + val filtered = filter.applyFilter(setOf(textNote, reactNote)) + + assertEquals(1, filtered.size, "applyFilter should only pass TextNoteEvents") + assertTrue(filtered.first().event is TextNoteEvent) + } + + @Test + fun `FollowingFeedFilter applyFilter respects follow set`() { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) + cache.consume(e1, relayUrl) + val note1 = cache.getNoteIfExists(e1.id)!! + + val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey) + cache.consume(e2, relayUrl) + val note2 = cache.getNoteIfExists(e2.id)!! + + val filtered = filter.applyFilter(setOf(note1, note2)) + + assertEquals(1, filtered.size, "applyFilter should only include followed users") + assertEquals(followedPubKey, filtered.first().author?.pubkeyHex) + } + + // ----------------------------------------------------------------------- + // 10. Profile count caching + // ----------------------------------------------------------------------- + + @Test + fun `profile follower count is cached and survives clear of note cache`() { + val cache = DesktopLocalCache() + + assertEquals(0, cache.getCachedFollowerCount(userPubKey)) + + cache.cacheFollowerCount(userPubKey, 42) + assertEquals(42, cache.getCachedFollowerCount(userPubKey)) + + // Updating again overwrites + cache.cacheFollowerCount(userPubKey, 100) + assertEquals(100, cache.getCachedFollowerCount(userPubKey)) + } + + @Test + fun `profile following count is cached`() { + val cache = DesktopLocalCache() + + cache.cacheFollowingCount(userPubKey, 150) + assertEquals(150, cache.getCachedFollowingCount(userPubKey)) + } + + @Test + fun `clear resets profile count caches`() { + val cache = DesktopLocalCache() + cache.cacheFollowerCount(userPubKey, 42) + cache.cacheFollowingCount(userPubKey, 150) + + cache.clear() + + assertEquals(0, cache.getCachedFollowerCount(userPubKey)) + assertEquals(0, cache.getCachedFollowingCount(userPubKey)) + } + + @Test + fun `metadata is available from cache after consumption`() { + val cache = DesktopLocalCache() + val metadata = + com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent( + id = "meta1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""", + sig = dummySig, + ) + + cache.consume(metadata, relayUrl) + + val user = cache.getUserIfExists(userPubKey)!! + val cached = user.metadataOrNull() + assertTrue(cached != null, "Metadata should be cached after consumption") + assertEquals("Test User", cached.bestName()) + assertEquals( + "A test user", + cached.flow.value + ?.info + ?.about, + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt index 773ebd3b4..0f494ac3e 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt @@ -39,13 +39,4 @@ class DesktopRelayConnectionManagerTest { assertTrue(connectedRelays.isEmpty(), "Should have no connected relays on initialization") assertTrue(availableRelays.isEmpty(), "Should have no available relays on initialization") } - - @Test - fun testRelayConnectionManagerInheritsFromBaseClass() { - val manager = DesktopRelayConnectionManager() - assertTrue( - manager is RelayConnectionManager, - "DesktopRelayConnectionManager should extend RelayConnectionManager", - ) - } } diff --git a/docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md b/docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md new file mode 100644 index 000000000..5779bcfb0 --- /dev/null +++ b/docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md @@ -0,0 +1,151 @@ +# Desktop Cache Architecture — Navigation Persistence + +**Date:** 2026-03-17 +**Status:** Brainstorm +**Branch:** `feat/desktop-media` (current), will need dedicated branch + +## What We're Building + +A cache-centric data architecture for Amethyst Desktop that mirrors Android Amethyst's pattern: `DesktopLocalCache` as the single source of truth, `FeedFilter` query objects, and `FeedViewModel` for reactive UI state. This ensures loaded data (notes, metadata, reactions, zaps) survives navigation between screens. + +### The Problem + +- Feed events live in per-screen `EventCollectionState` inside `remember {}` — destroyed on navigation +- Navigating search → thread → back loses all loaded notes and search results +- Metadata is re-fetched per-screen via `loadMetadataForPubkeys()` even though `DesktopLocalCache` already holds it +- Zaps, reactions, reply counts are tracked per-screen in mutable state — lost on navigation +- UX feels broken: back navigation shows loading spinners for already-seen data + +### The Goal + +| Before | After | +|--------|-------| +| Screen creates EventCollectionState in `remember` | Screen observes FeedViewModel backed by cache | +| Events stored per-screen, lost on navigation | Events stored in DesktopLocalCache singleton | +| Metadata re-fetched per screen | Metadata cached, available immediately | +| Back navigation = full reload | Back navigation = instant (data in cache) | + +## Why This Approach + +**Mirror Android Amethyst's cache-centric design** rather than inventing a new repository pattern: + +1. **Proven pattern** — Android Amethyst handles millions of events this way +2. **Shared code** — `FeedFilter`, `FeedViewModel`, `FeedContentState` already exist in `commons/` +3. **Future merge safety** — staying aligned with upstream means less divergence +4. **Natural fit** — `DesktopLocalCache` already implements `ICacheProvider` and `ICacheEventStream` + +### Android's Architecture (what we're mirroring) + +``` +Relays → LocalCache (stores ALL events) → ICacheEventStream + ↓ + FeedViewModel subscribes + ↓ + FeedFilter.feed() queries cache + ↓ + FeedContentState (Loading/Loaded/Empty) + ↓ + UI collects StateFlow +``` + +### Desktop's Current Architecture (broken) + +``` +Relays → Screen composable (EventCollectionState in remember) + ↓ + UI renders from local state + ↓ + [navigation] → state destroyed → reload from scratch +``` + +### Desktop's Target Architecture + +``` +Relays → DesktopLocalCache (stores ALL events) → DesktopCacheEventStream + ↓ + FeedViewModel subscribes + ↓ + FeedFilter queries cache + ↓ + Screen observes FeedContentState + ↓ + [navigation] → cache persists → instant back +``` + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Architecture | Cache-centric (mirror Android) | Proven, shared code, merge-safe | +| Migration | Incremental (3 phases) | Each phase is a standalone UX improvement | +| Storage | In-memory only (no disk) | Matches Android, sufficient for navigation persistence | +| Feed queries | FeedFilter pattern from commons | Already exists, well-tested | +| State management | FeedViewModel + FeedContentState | Already in commons, handles Loading/Loaded/Empty | +| Thumbnails | Out of scope | Already survive navigation (singleton cache) | + +## Implementation Phases + +### Phase 1: Store ALL events in DesktopLocalCache + +**Goal:** Make the cache the source of truth instead of per-screen state. + +**Changes:** +- `DesktopLocalCache`: Store note events (not just metadata) when received from relays +- Relay subscription handlers: Call `localCache.consume(event)` for ALL event types +- `DesktopCacheEventStream`: Emit to `newEventBundles` / `deletedEventBundles` flows +- Zaps, reactions, reposts: Store relationship data in Note model (like Android) + +**What it fixes:** Data accumulates in a singleton — screens can query it on mount. + +### Phase 2: Create Desktop FeedFilters + +**Goal:** Query the cache instead of holding per-screen event lists. + +**Changes:** +- `DesktopGlobalFeedFilter` — queries cache for kind 1 events, sorted by createdAt +- `DesktopFollowingFeedFilter` — queries cache for events from followed users +- `DesktopThreadFilter` — queries cache for root + replies to a note ID +- `DesktopProfileFeedFilter` — queries cache for events by a specific pubkey +- `DesktopBookmarkFeedFilter` — queries cache for bookmarked event IDs + +**What it fixes:** Screens get data from cache immediately, no relay round-trip on back navigation. + +### Phase 3: Migrate Screens to FeedViewModel + +**Goal:** Replace per-screen `EventCollectionState` with shared `FeedViewModel`. + +**Changes per screen:** +- Replace `val eventState = remember { EventCollectionState(...) }` with `val viewModel = remember { FeedViewModel(filter, localCache) }` +- Replace `events by eventState.items.collectAsState()` with `feedState by viewModel.feedContent.collectAsState()` +- Remove per-screen relay subscription handlers (cache handles it) +- Remove per-screen zap/reaction/reply tracking (stored in Note model) + +**Migration order:** FeedScreen → ThreadScreen → UserProfileScreen → SearchResultsList → BookmarksScreen → ReadsScreen → NotificationsScreen + +**What it fixes:** Full navigation persistence, cleaner screen composables, shared ViewModel pattern. + +## Existing Code to Reuse + +| Component | Location | Status | +|-----------|----------|--------| +| `ICacheProvider` | `commons/model/cache/ICacheProvider.kt` | ✅ Already implemented by DesktopLocalCache | +| `ICacheEventStream` | `commons/model/cache/ICacheEventStream.kt` | ✅ Already implemented by DesktopCacheEventStream | +| `FeedFilter` | `commons/ui/feeds/FeedFilter.kt` | ✅ Ready to subclass | +| `AdditiveFeedFilter` | `commons/ui/feeds/AdditiveFeedFilter.kt` | ✅ Optimized for incremental updates | +| `FeedViewModel` | `commons/viewmodels/FeedViewModel.kt` | ⚠️ May need adaptation for desktop lifecycle | +| `FeedContentState` | `commons/ui/feeds/FeedContentState.kt` | ✅ Ready to use | +| `User` / `Note` models | `commons/model/` | ✅ Already used by DesktopLocalCache | + +## Resolved Questions + +| Question | Decision | Rationale | +|----------|----------|-----------| +| **ViewModel lifecycle** | App-level singletons | Desktop has no Activity lifecycle. Create ViewModels at startup, keep alive forever. Simple and matches desktop mental model. | +| **Cache eviction** | LRU eviction | Cap cache per type (e.g., 10k notes, 5k users). Desktop has more RAM but still finite. Defensive choice. | +| **Subscription management** | Centralized coordinator | `DesktopRelaySubscriptionsCoordinator` manages all feed subs. Screens request what they need, coordinator deduplicates. Already partially exists. | + +## Resolved Questions (continued) + +| Question | Decision | Rationale | +|----------|----------|-----------| +| **Event consumption scope** | Full port of Android's consume methods | Future-proof. Port all event kind handlers from Android LocalCache to DesktopLocalCache. | diff --git a/docs/design/banner_by_purple_painter.jpeg b/docs/design/banner_by_purple_painter.jpeg new file mode 100644 index 000000000..4117c124c Binary files /dev/null and b/docs/design/banner_by_purple_painter.jpeg differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg b/docs/design/profile_banner_old.jpg similarity index 100% rename from amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg rename to docs/design/profile_banner_old.jpg diff --git a/docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md b/docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md new file mode 100644 index 000000000..f1fb2340d --- /dev/null +++ b/docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md @@ -0,0 +1,569 @@ +--- +title: "feat: Desktop Cache-Centric Architecture for Navigation Persistence" +type: feat +status: active +date: 2026-03-18 +origin: docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md +--- + +# feat: Desktop Cache-Centric Architecture for Navigation Persistence + +## Overview + +Migrate Amethyst Desktop from per-screen event state (`EventCollectionState` in `remember {}`) to Android's cache-centric pattern where `DesktopLocalCache` is the single source of truth. Feeds query the cache via `FeedFilter`, `FeedViewModel` subscribes to the cache event stream, and data survives navigation. + +Three-phase incremental migration. Each phase is a standalone PR that improves UX. + +## Problem Statement + +| Symptom | Root Cause | +|---------|------------| +| Back navigation shows loading spinners | `EventCollectionState` destroyed when composable leaves composition | +| Metadata re-fetched per screen | Screens call `loadMetadataForPubkeys()` instead of reading cache | +| Zap/reaction counts lost on navigate | Tracked in per-screen mutable state, not in `Note` model | +| Search results vanish on thread → back | Search screen's `EventCollectionState` is gone | +| Wasted network/relay resources | Same events re-fetched on every navigation; duplicate REQ filters | + +## Proposed Solution + +Mirror Android Amethyst's architecture (see brainstorm: `docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md`): + +``` +Relays ──→ DesktopLocalCache.consume(event) + │ + ├──→ Store in maps (users, notes, addressableNotes) + ├──→ Update Note relationships (replies, reactions, zaps) + └──→ DesktopCacheEventStream.emitNewNotes(note) + │ + ▼ + FeedViewModel.collect { newNotes → + feedState.updateFeedWith(newNotes) + } + │ + ▼ + Screen observes feedState.feedContent (Loading/Loaded/Empty) +``` + +## Technical Approach + +### Phase 1: Store ALL Events in DesktopLocalCache + +**Goal:** Make `DesktopLocalCache` the source of truth. + +**Branch:** `feat/desktop-cache-phase1` + +#### 1.1 Switch cache backing to `LargeCache` with size enforcement + +**File:** `desktopApp/.../cache/DesktopLocalCache.kt` + +Replace `ConcurrentHashMap` with `LargeCache` from quartz — same backing store Android uses (`ConcurrentSkipListMap` on JVM), lock-free reads (CAS-based), rich query APIs (`filterIntoSet`, `mapNotNull`, range queries). Desktop keeps strong references (no `WeakReference` wrapper — that's Android-only `LargeSoftCache` for mobile memory pressure). + +`LargeCache` chosen over `LruCache` because: +- **Lock-free reads** — `ConcurrentSkipListMap` vs `synchronized` on every `get()`. Critical at 1000 events/sec. +- **Rich query API** — `filterIntoSet`, `mapNotNull` match Android's filter patterns exactly. +- **No snapshot overhead** — `LruCache.snapshot()` copies the entire map; `LargeCache` iterates in-place. + +Add size enforcement via a `BoundedLargeCache` wrapper that checks size on `put()` and evicts oldest entries when cap is exceeded: + +```kotlin +class BoundedLargeCache, V>( + private val maxSize: Int, + private val evictPercent: Float = 0.1f, // Remove 10% when cap hit +) { + private val inner = LargeCache() + + fun get(key: K): V? = inner.get(key) + fun put(key: K, value: V) { + inner.put(key, value) + enforceSize() + } + fun getOrCreate(key: K, builder: (K) -> V): V = inner.getOrCreate(key, builder).also { enforceSize() } + fun remove(key: K): V? = inner.remove(key) + fun clear() = inner.clear() + fun size(): Int = inner.size() + fun values(): Iterable = inner.values() + fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set = inner.filterIntoSet(consumer) + // ... delegate other LargeCache methods as needed + + private fun enforceSize() { + if (inner.size() > maxSize) { + val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1) + // ConcurrentSkipListMap keys are sorted — first N keys are "oldest" by insertion order + val keys = inner.keys().take(toRemove) + keys.forEach { inner.remove(it) } + } + } +} + +// Usage: +private val notes = BoundedLargeCache(MAX_NOTES) +private val users = BoundedLargeCache(MAX_USERS) +private val addressableNotes = BoundedLargeCache(MAX_ADDRESSABLE) + +companion object { + const val MAX_NOTES = 50_000 // ~100-150MB at ~2-3KB/note + const val MAX_USERS = 25_000 // ~25-50MB at ~1-2KB/user + const val MAX_ADDRESSABLE = 10_000 +} +``` + +Note: `ConcurrentSkipListMap` keys are sorted, so `keys().take(N)` removes the lexicographically smallest hex keys — not strictly "oldest by time." For true time-based eviction, the `enforceSize()` could sort by `note.event?.createdAt` instead, but the simple key-based approach is cheaper and good enough (hex keys from Nostr events are effectively random, so eviction is approximately random). + +#### 1.2 Port consume methods from Android LocalCache + +**File:** `desktopApp/.../cache/DesktopLocalCache.kt` + +Start with 4 new event kinds (kind 9734 required for zap processing). Add remaining kinds per-screen in Phase 3. + +| Kind | Event Type | Phase | Notes | +|------|-----------|-------|-------| +| 0 | `MetadataEvent` | Done | Already exists (`consumeMetadata`) | +| 1 | `TextNoteEvent` | 1 | Core feed content | +| 7 | `ReactionEvent` | 1 | Reaction counts on Note | +| 9734 | `LnZapRequestEvent` | 1 | Required before kind 9735 can process | +| 9735 | `LnZapEvent` | 1 | Zap counts on Note | + +Each consume method follows Android's pattern. Use `event.tagsWithoutCitations()` for reply parsing (handles both NIP-10 marked and legacy positional tags, excluding inline nostr: citations): + +```kotlin +fun consume(event: TextNoteEvent, relay: NormalizedRelayUrl?): Boolean { + val note = checkGetOrCreateNote(event.id) ?: return false + if (note.event != null) return false // already have it + val author = getOrCreateUser(event.pubKey) + val repliesTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + note.loadEvent(event, author, repliesTo) + repliesTo.forEach { it.addReply(note) } + refreshObservers(note) + return true +} +``` + +For reactions, check both `e`-tags and `a`-tags (reactions to addressable events like articles): +```kotlin +fun consume(event: ReactionEvent, relay: NormalizedRelayUrl?): Boolean { + val note = checkGetOrCreateNote(event.id) ?: return false + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val reactedTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + note.loadEvent(event, author, reactedTo) + reactedTo.forEach { it.addReaction(note) } + refreshObservers(note) + return true +} +``` + +#### 1.3 Bridge relay callbacks to cache consumption + +**Problem:** Relay `onEvent` callbacks are non-suspend. `emitNewNotes()` is suspend. + +**Solution:** Use `BasicBundledInsert` from commons (already exists, battle-tested in Android) with 250ms delay (snappier than Android's 1000ms — desktop has mains power, users expect faster updates). + +**File:** `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt` + +```kotlin +private val eventBundler = BasicBundledInsert( + delay = 250, // 250ms for desktop (Android uses 1000ms to save battery) + dispatcher = Dispatchers.IO, + scope = scope, +) + +fun consumeEvent(event: Event, relay: NormalizedRelayUrl?) { + scope.launch(Dispatchers.IO) { + val consumed = localCache.consume(event, relay) + if (consumed) { + val note = localCache.getNoteIfExists(event.id) as? Note ?: return@launch + eventBundler.invalidateList(note) { batch -> + localCache.eventStream.emitNewNotes(batch) + } + } + } +} +``` + +Keep per-screen `rememberSubscription` — just change the `onEvent` callback to route through cache. Per-screen subscriptions handle lifecycle automatically (auto-cleanup on navigate away). + +```kotlin +// Before (per-screen state): +onEvent = { event, _, _, _ -> eventState.addItem(event) } + +// After (routes to cache): +onEvent = { event, _, relay, _ -> coordinator.consumeEvent(event, relay) } +``` + +#### 1.4 Fix SharedFlow configuration + +**Problem:** `MutableSharedFlow(replay = 0)` with no buffer drops events and blocks emitters on slow collectors. Android uses `extraBufferCapacity=100, DROP_OLDEST`. + +**File:** `desktopApp/.../cache/DesktopLocalCache.kt` (DesktopCacheEventStream) + +```kotlin +class DesktopCacheEventStream : ICacheEventStream { + private val _newEventBundles = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val _deletedEventBundles = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + // ... +} +``` + +Dropped emissions are fine — events are already in the cache. The flow signals "something changed," not "here is the data." + +#### 1.5 Set JVM memory limit + +**File:** `desktopApp/build.gradle.kts` + +```kotlin +compose.desktop.application { + jvmArgs += "-Xmx2g" +} +``` + +Size enforcement is handled by `BoundedLargeCache` (section 1.1). `-Xmx2g` is a safety net for the JVM heap overall. + +**Eviction safety:** Feed lists hold strong references to `Note` objects. When `BoundedLargeCache` evicts a key, the `Note` object survives in the feed list (GC won't collect it). On next `FeedFilter.feed()` refresh, the evicted note won't appear — acceptable (feed shows most recent N items). If a user clicks an evicted note, `checkGetOrCreateNote(id)` creates a shell Note that triggers a relay re-fetch. + +#### 1.6 Add cache clear on logout + +**File:** `desktopApp/.../account/AccountManager.kt` + +Cancel coordinator scope BEFORE clearing cache to prevent race between in-flight `consume()` calls and `clear()`. + +```kotlin +fun logout() { + coordinator.clear() // Stop subscriptions first + localCache.clear() // Then clear cache +} +``` + +#### Phase 1 Acceptance Criteria + +- [ ] `DesktopLocalCache` backed by `BoundedLargeCache` with size caps (50k notes, 25k users, 10k addressable) +- [ ] Consume methods for kinds 1, 7, 9734, 9735 +- [ ] Relay `onEvent` callbacks route through `coordinator.consumeEvent()` +- [ ] `BasicBundledInsert(250ms)` batches events before `emitNewNotes()` +- [ ] `DesktopCacheEventStream` has `extraBufferCapacity = 64, DROP_OLDEST` +- [ ] `-Xmx2g` JVM arg set +- [ ] Cache cleared on logout (coordinator first, then cache) +- [ ] `createGlobalFeedSubscription` / `createFollowingFeedSubscription` confirmed routing through `consumeEvent` +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew spotlessApply` clean + +--- + +### Phase 2: Create Desktop FeedFilters + +**Goal:** Query cache instead of holding per-screen event lists. + +**Branch:** `feat/desktop-cache-phase2` + +#### 2.1 Desktop feed filters + +**Directory:** `desktopApp/.../feeds/` + +| Filter | Query Strategy | Base Class | Limit | +|--------|---------------|------------|-------| +| `DesktopGlobalFeedFilter` | `notes.filterIntoSet { kind == 1 }` | `AdditiveFeedFilter` | 2500 | +| `DesktopFollowingFeedFilter` | Same + author in account's follow list | `AdditiveFeedFilter` | 2500 | +| `DesktopThreadFilter(noteId)` | Root note + `Note.replies` graph walk | `FeedFilter` | unlimited | +| `DesktopProfileFeedFilter(pubkey)` | `notes.filterIntoSet { author == pubkey }` | `AdditiveFeedFilter` | 1000 | +| `DesktopBookmarkFeedFilter(ids)` | Direct lookup by ID set | `FeedFilter` | 2500 | +| `DesktopReadsFeedFilter` | `notes.filterIntoSet { kind == 30023 }` | `AdditiveFeedFilter` | 500 | +| `DesktopNotificationFeedFilter` | Events tagging logged-in user (reactions, zaps, replies, reposts) | `AdditiveFeedFilter` | 2500 | +| `DesktopSearchFeedFilter(query)` | Cache search + relay search results stored in cache | `AdditiveFeedFilter` | 500 | + +Limits are ~5x Android's (desktop has more screen space and RAM). Filters use `BoundedLargeCache.filterIntoSet` — same API as Android's `LocalCache`. + +The initial `feed()` scan runs only once on first load or `feedKey()` change. After that, `updateListWith()` uses `applyFilter()` + `sort()` incrementally — O(batch_size) not O(cache_size). + +Following filter reads followed pubkeys from account state (same pattern as Android's `HomeConversationsFeedFilter` which reads `account.liveHomeFollowLists`). + +Notification filter mirrors Android's `NotificationFeedFilter`: filters for events that tag the logged-in user across relevant kinds (kind 1, 6, 7, 9735), with mute list support. Simplified from Android's 20+ kinds to the kinds desktop actually displays. + +```kotlin +class DesktopGlobalFeedFilter( + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feed(): List = + cache.notes.filterIntoSet { _, note -> note.event?.kind == 1 } + .sortedByDescending { it.event?.createdAt ?: 0 } + .take(limit()) + + override fun applyFilter(newItems: Set): Set = + newItems.filter { it.event?.kind == 1 }.toSet() + + override fun sort(items: Set): List = + items.sortedByDescending { it.event?.createdAt ?: 0 }.take(limit()) + + override fun limit(): Int = 2500 +} +``` + +#### Phase 2 Acceptance Criteria + +- [ ] All 8 feed filters implemented and compile +- [ ] `applyFilter()` and `sort()` work for incremental updates +- [ ] Notification filter correctly identifies events tagging logged-in user +- [ ] Following filter reads follow list from account state +- [ ] Unit tests for each filter with mock cache data +- [ ] `./gradlew :desktopApp:compileKotlin` passes + +--- + +### Phase 3: Migrate Screens to FeedViewModel + +**Goal:** Replace `EventCollectionState` with `FeedViewModel` pattern across all screens. + +**Branch:** `feat/desktop-cache-phase3` + +#### 3.1 Create DesktopFeedViewModel + +`FeedViewModel.init` in commons only sets up stream collectors — it doesn't load existing cache data. Navigation back would show `Loading` forever until a new relay event arrives. Fix by calling `refreshSuspended()` on init. Consider upstreaming to base `FeedViewModel` in commons. + +**File:** `desktopApp/.../viewmodels/DesktopFeedViewModel.kt` + +```kotlin +class DesktopFeedViewModel( + filter: FeedFilter, + cacheProvider: ICacheProvider, +) : FeedViewModel(filter, cacheProvider) { + init { + viewModelScope.launch(Dispatchers.IO) { + feedState.refreshSuspended() + } + } +} +``` + +#### 3.2 ViewModel lifecycle management + +**Singleton feeds** — created in `Main.kt` alongside other app-level state (`relayManager`, `localCache`, `accountState`). Standard Kotlin JVM pattern: create at app startup, pass down as parameters. + +**File:** `desktopApp/.../Main.kt` + +```kotlin +// App-level state (created once) +val localCache = DesktopLocalCache() +val coordinator = DesktopRelaySubscriptionsCoordinator(localCache, ...) + +// Singleton ViewModels (created once, survive navigation) +val globalFeedVM = DesktopFeedViewModel(DesktopGlobalFeedFilter(localCache), localCache) +val followingFeedVM = DesktopFeedViewModel(DesktopFollowingFeedFilter(localCache, account), localCache) +val readsFeedVM = DesktopFeedViewModel(DesktopReadsFeedFilter(localCache), localCache) +val notificationsFeedVM = DesktopFeedViewModel(DesktopNotificationFeedFilter(localCache, account), localCache) +``` + +Passed to screens as parameters (not CompositionLocal). + +**Parameterized feeds** — use `remember(key)` in Compose. Data survives navigation via the cache, not the ViewModel. New VMs query cache on creation via `init { refreshSuspended() }` for instant results. + +```kotlin +@Composable +fun ThreadScreen(noteId: String, cache: DesktopLocalCache, ...) { + val vm = remember(noteId) { + DesktopFeedViewModel(DesktopThreadFilter(noteId, cache), cache) + } + DisposableEffect(vm) { + onDispose { vm.clear() } // Cancel viewModelScope + } + // ... +} +``` + +#### 3.3 Per-screen subscriptions (unchanged) + +Keep `rememberSubscription` in screens — it handles lifecycle automatically. Just change callbacks to route through cache. + +```kotlin +rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { + when (feedMode) { + FeedMode.GLOBAL -> createGlobalFeedSubscription( + relays = configuredRelays, + onEvent = { event, _, relay, _ -> + coordinator.consumeEvent(event, relay) + }, + onEose = { _, _ -> eoseReceivedCount++ }, + ) + // ... + } +} +``` + +#### 3.4 Migrate screens with per-screen consume methods + +**Migration order and consume methods needed per screen:** + +| Screen | VM Type | Consume Kinds to Add | Notes | +|--------|---------|---------------------|-------| +| FeedScreen | Singleton (global + following) | 3 (ContactList), 6 (Repost) | Includes FeedNoteCard rewrite | +| ThreadScreen | Parameterized (noteId) | 5 (Deletion) | Deleted note indicators | +| UserProfileScreen | Parameterized (pubkey) | — | Uses existing kinds | +| SearchResultsList | Parameterized (query) | — | Uses DesktopSearchFeedFilter | +| BookmarksScreen | Singleton | 30078 (BookmarkList) | Bookmark state from events | +| ReadsScreen | Singleton | 30023 (LongTextNote) | Articles feed | +| NotificationsScreen | Singleton | — | Uses DesktopNotificationFeedFilter | + +**FeedNoteCard rewrite** — done alongside FeedScreen migration (first screen). All subsequent screen migrations benefit. + +Current `FeedNoteCard` takes raw `Event` + per-screen counts: +```kotlin +// CURRENT: 6 per-screen state params +FeedNoteCard(event, ..., zapReceipts, reactionCount, replyCount, repostCount, ...) +``` + +New `FeedNoteCard` takes `Note` from cache — reads counts directly from model: +```kotlin +// NEW: Note replaces all per-screen count params +FeedNoteCard(note, ...) // inside: note.zaps.size, note.countReactions(), note.replies.size, note.boosts.size +``` + +**Field mapping (per-screen state → Note model):** + +| Per-Screen State Map | Note Model Replacement | +|---------------------|----------------------| +| `zapsByEvent[id]` → `List` | `note.zaps` → `Map` | +| `zapReceipts.sumOf { it.amountSats }` | `note.zapsAmount` (BigDecimal) | +| `reactionIdsByEvent[id]` → count | `note.countReactions()` | +| `replyIdsByEvent[id]` → count | `note.replies.size` | +| `repostIdsByEvent[id]` → count | `note.boosts.size` | + +**FeedScreen subscriptions removed** (5 subscriptions, ~130 lines): +- `createZapsSubscription` + `zapsByEvent` state map +- `createReactionsSubscription` + `reactionIdsByEvent` state map +- `createRepliesSubscription` + `replyIdsByEvent` state map +- `createRepostsSubscription` + `repostIdsByEvent` state map +- `createBatchMetadataSubscription` for zap senders + +These are replaced by `cache.consume()` which populates Note model relationships automatically. + +**Per-screen migration removes:** +- `val eventState = remember { EventCollectionState(...) }` +- Per-screen `zapsByEvent`, `reactionIdsByEvent`, `replyIdsByEvent`, `repostIdsByEvent` mutable state maps +- 5 per-screen subscription handlers (zaps, reactions, replies, reposts, metadata) + +**Per-screen migration adds:** +- `val feedState by viewModel.feedState.feedContent.collectAsState()` +- Route `onEvent` to `coordinator.consumeEvent()` +- Always use `key` in `items()`: `items(notes.list, key = { it.idHex })` + +```kotlin +when (val state = feedState) { + is FeedState.Loading -> LoadingState("Loading notes...") + is FeedState.Empty -> EmptyState(...) + is FeedState.Loaded -> { + val notes by state.feed.collectAsState() + LazyColumn { + items(notes.list, key = { it.idHex }) { note -> + FeedNoteCard(note = note, ...) + } + } + } + is FeedState.FeedError -> ErrorState(state.errorMessage) +} +``` + +#### Phase 3 Acceptance Criteria + +- [ ] `DesktopFeedViewModel` loads cache data on creation (initial `refreshSuspended()`) +- [ ] Singleton ViewModels created in `Main.kt` for global/following/reads/notifications +- [ ] `remember(key)` + `DisposableEffect` for parameterized feeds (thread, profile, search) +- [ ] `FeedNoteCard` rewritten to read from `Note` model (done with FeedScreen migration) +- [ ] Per-screen subscriptions route through `consumeEvent()` +- [ ] Consume methods added for kinds 3, 5, 6, 30023, 30078 +- [ ] All 9 screens migrated: Feed, Thread, Profile, Search, Bookmarks, Reads, Notifications +- [ ] Per-screen zap/reaction/reply state removed (stored in Note model) +- [ ] Navigation back shows instant data (no loading spinner) +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew spotlessApply` clean +- [ ] Manual test: Feed → Thread → Back → data persists +- [ ] Manual test: Search → Thread → Back → search results preserved + +--- + +## System-Wide Impact + +### Interaction Graph + +``` +User navigates to Feed + → FeedScreen reads globalFeedViewModel.feedState + → FeedContentState queries DesktopGlobalFeedFilter.feed() + → Filter calls cache.notes.filterIntoSet { kind==1 } + → Returns cached Note objects + +Relay sends new event + → OkHttp callback → coordinator.consumeEvent(event, relay) + → scope.launch(IO) { localCache.consume(event) } + → Note created/updated in cache + → BasicBundledInsert(250ms) batches notes + → eventStream.emitNewNotes(batch) + → FeedViewModel.collect { feedState.updateFeedWith(notes) } + → Compose recomposes + +User navigates away and back + → Singleton VM: feedState already Loaded → instant render + → Parameterized VM: new VM created, init { refreshSuspended() } loads from cache → instant render +``` + +### Error Propagation + +| Error | Source | Handling | +|-------|--------|----------| +| Relay disconnect | OkHttp | Coordinator reconnects, no cache impact | +| consume() throws | Cache | Caught in consumer coroutine, logged, event skipped | +| emitNewNotes() buffer full | SharedFlow | `DROP_OLDEST` — events already in cache | +| Filter query on empty cache | FeedFilter | Returns empty list → `FeedState.Empty` | + +### State Lifecycle Risks + +| Risk | Mitigation | +|------|-----------| +| Stale data after logout | `coordinator.clear()` then `localCache.clear()` | +| Mixed-account data | ViewModels cleared + cache cleared on account switch | +| Subscription leak on app exit | Coordinator.clear() in shutdown hook | +| Cache memory pressure | `-Xmx2g` + BoundedLargeCache caps (50k notes, 25k users) | + +## Dependencies & Prerequisites + +| Dependency | Status | Needed For | +|------------|--------|------------| +| `LargeCache` (quartz) | ✅ In quartz jvmAndroid — `ConcurrentSkipListMap`, lock-free | Phase 1 | +| `BasicBundledInsert` (commons) | ✅ In commons `BundledUpdate.kt` | Phase 1 | +| `ICacheProvider` / `ICacheEventStream` | ✅ In commons | Phase 1 | +| `Note.loadEvent()`, `addReply()`, `addReaction()`, `addZap()` | ✅ In commons | Phase 1 | +| `FeedFilter` / `AdditiveFeedFilter` | ✅ In commons | Phase 2 | +| `FeedViewModel` / `FeedContentState` | ✅ In commons | Phase 3 | +| `kotlinx-coroutines-swing` | ✅ In desktopApp deps | Dispatchers.Main | + +## Success Metrics + +| Metric | Before | After | +|--------|--------|-------| +| Back navigation time | 2-5s (full reload) | <100ms (cache hit) | +| Metadata re-fetch on navigate | Every screen | Never (cached) | +| Zap/reaction counts on back | Lost | Preserved | + +## Future Improvements (Deferred) + +| Improvement | Trigger | +|-------------|---------| +| Secondary indexes (by kind, by author) | `feed()` scan >50ms | +| Disk persistence (SQLite) | User requests cross-session persistence | +| Centralized subscription coordinator | Multiple screens need same relay filter | + +## Sources & References + +- **Origin:** [docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md](docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md) +- `commons/.../ui/feeds/FeedFilter.kt`, `AdditiveFeedFilter.kt`, `FeedContentState.kt` +- `commons/.../viewmodels/FeedViewModel.kt` +- `commons/.../model/cache/ICacheProvider.kt`, `ICacheEventStream.kt` +- `commons/.../model/Note.kt` — `loadEvent()`, `addReply()`, reactions, zaps +- `desktopApp/.../cache/DesktopLocalCache.kt` +- `amethyst/.../model/LocalCache.kt` — Android reference +- `docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md` — subscription stability diff --git a/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md new file mode 100644 index 000000000..7e3edfccb --- /dev/null +++ b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md @@ -0,0 +1,410 @@ +--- +title: "feat: Article Highlights & Note-Taking" +type: feat +status: active +date: 2026-03-24 +deepened: 2026-03-24 +origin: docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md +--- + +# feat: Article Highlights & Note-Taking + +## Enhancement Summary + +**Deepened on:** 2026-03-24 +**Research agents used:** text-selection, richtext-rendering, floating-popup, nip84-highlights + +### Key Improvements +1. **Text selection strategy resolved** — Use `LocalTextContextMenu` override (only official API exposing selected text), not clipboard polling +2. **Inline rendering strategy resolved** — Pre-process markdown with special link URI (`highlight://`) as v1; fork-level `==highlight==` syntax as v2 +3. **Floating toolbar pattern confirmed** — `Popup` + custom `PopupPositionProvider`, matches existing `ChatPane.kt` pattern +4. **NIP-84 gap found** — `HighlightEvent.create()` only takes `msg`+`signer`, needs tag assembly wrapper for full highlight creation + +### Resolved Questions +- **`` support?** No — richtext library ignores `HtmlInline` nodes. Use pre-processing or fork changes. +- **Clipboard polling reliability?** Moot — use `LocalTextContextMenu` instead (right-click UX, official API) +- **NIP-09 deletion?** Yes — send kind 5 event with `["e", highlightEventId]` + `["k", "9802"]` + +## Overview + +Text selection-based highlight and annotation system for the Desktop article reader. Users select text in NIP-23 articles, a context menu option or floating toolbar appears, and they create highlights (with optional notes). Highlights render inline as colored markers. Supports private (local) and public (NIP-84) modes. Includes a "My Highlights" aggregation screen. + +## Problem Statement + +Desktop article reader has no way to mark up, annotate, or take notes on long-form content. Users reading NIP-23 articles can't highlight passages, add personal notes, or publish highlights to their Nostr social graph. This limits the reading experience compared to tools like Kindle, Medium, or Hypothesis. + +## Proposed Solution + +Three-phase implementation: +1. **Storage + data model** — DesktopHighlightStore (Preferences-based) + highlight data classes +2. **Selection UX + inline rendering** — Text selection interception via context menu, floating toolbar, yellow highlight markers in markdown +3. **My Highlights screen + NIP-84 publishing** — Aggregation view, public/private toggle, relay broadcast + +## Technical Approach + +### Architecture + +``` +desktopApp/ +├── service/highlights/ +│ └── DesktopHighlightStore.kt # Preferences-based storage (like DraftStore) +├── ui/ +│ ├── ArticleReaderScreen.kt # Modified: selection + inline highlights +│ ├── highlights/ +│ │ ├── FloatingHighlightToolbar.kt # Popup on text selection +│ │ ├── HighlightAnnotationDialog.kt # Note entry dialog +│ │ ├── HighlightPublishAction.kt # NIP-84 tag assembly + publish +│ │ └── MyHighlightsScreen.kt # Aggregation screen +│ └── deck/ +│ ├── DeckColumnType.kt # Add MyHighlights +│ ├── DeckColumnContainer.kt # Route MyHighlights +│ └── SinglePaneLayout.kt # Add nav item + +commons/ +├── compose/markdown/ +│ └── RenderMarkdown.kt # Modified: accept highlight ranges, render yellow bg +├── model/highlights/ +│ └── HighlightData.kt # Shared data class +``` + +### Implementation Phases + +#### Phase 1: Storage & Data Model + +**Goal:** DesktopHighlightStore + highlight data classes, no UI yet. + +**Files:** +- `desktopApp/service/highlights/DesktopHighlightStore.kt` — follows DesktopDraftStore pattern (Jackson + Preferences) +- `commons/model/highlights/HighlightData.kt` — shared data class + +**Data model:** +```kotlin +data class HighlightData( + val id: String, // UUID + val text: String, // selected/highlighted text + val note: String?, // optional annotation + val articleAddressTag: String, // "30023:pubkey:d-tag" + val articleTitle: String?, // cached for My Highlights display + val createdAt: Long, // epoch seconds + val published: Boolean, // false = private, true = NIP-84 published + val eventId: String?, // NIP-84 event ID if published +) +``` + +**DesktopHighlightStore API:** +```kotlin +class DesktopHighlightStore(scope: CoroutineScope) { + private val mapper = jacksonObjectMapper() + val highlights: StateFlow>> // keyed by articleAddressTag + + suspend fun addHighlight(articleAddressTag: String, text: String, note: String?, articleTitle: String?) + suspend fun updateNote(highlightId: String, note: String) + suspend fun removeHighlight(highlightId: String) + suspend fun markPublished(highlightId: String, eventId: String) + fun getHighlightsForArticle(addressTag: String): List + fun getAllHighlights(): Map> +} +``` + +**Tests:** Unit tests for store CRUD, serialization round-trip. + +**Success criteria:** +- [ ] HighlightData serializes/deserializes via Jackson +- [ ] Store persists across app restarts via Preferences +- [ ] CRUD operations work correctly +- [ ] StateFlow emits on changes + +### Research Insights — Phase 1 + +**Storage pattern:** Follow `DesktopDraftStore.kt` exactly: +- `jacksonObjectMapper()` for serialization (line 62) +- Atomic writes with temp files + `Files.move()` (lines 237-254) +- POSIX file permissions for security (lines 261-288) +- Preferences key: `"highlights:${articleAddressTag}"` with JSON array value + +**Edge case — Preferences size limit:** `java.util.prefs.Preferences` has a per-value limit of 8192 bytes on some platforms. For articles with many highlights, the JSON array could exceed this. Mitigation: if value exceeds 6KB, spill to file-based storage (same pattern as DraftStore's file storage). + +--- + +#### Phase 2: Selection UX + Inline Rendering + +**Goal:** Select text in article → create highlight → see yellow marker. + +##### Text Selection Strategy (REVISED) + +**Primary: `LocalTextContextMenu` override** — the only official Compose Desktop API that exposes `selectedText`: + +```kotlin +@Composable +fun HighlightableContent( + onHighlight: (String) -> Unit, + onAnnotate: (String) -> Unit, + content: @Composable () -> Unit, +) { + val defaultMenu = LocalTextContextMenu.current + + CompositionLocalProvider( + LocalTextContextMenu provides object : TextContextMenu { + @Composable + override fun Area( + textManager: TextContextMenu.TextManager, + state: ContextMenuState, + content: @Composable () -> Unit, + ) { + ContextMenuDataProvider({ + val selected = textManager.selectedText + if (selected.text.isNotEmpty()) { + listOf( + ContextMenuItem("Highlight") { onHighlight(selected.text) }, + ContextMenuItem("Highlight with Note") { onAnnotate(selected.text) }, + ) + } else { + emptyList() + } + }) { + defaultMenu.Area(textManager, state, content = content) + } + } + }, + content = content, + ) +} +``` + +**UX:** Select text → right-click → "Highlight" / "Highlight with Note" in context menu. Natural desktop UX. No clipboard polling needed. + +**Secondary: Keyboard shortcut (Cmd+H)** — reads clipboard after user copies: + +```kotlin +Modifier.onPreviewKeyEvent { event -> + if (event.isMetaPressed && event.key == Key.H && event.type == KeyEventType.KeyDown) { + val clipText = clipboard.getText()?.text + if (!clipText.isNullOrBlank()) onHighlight(clipText) + true + } else false +} +``` + +##### Inline Rendering Strategy (REVISED) + +**Research finding:** richtext library ignores `HtmlInline` (``) and has no `Highlight` format. Three options ranked: + +| Approach | Effort | Quality | Recommended | +|----------|--------|---------|-------------| +| Pre-process: wrap in special link `[text](highlight://)` | Low | Hacky but works | v1 | +| Fork: add `==text==` DelimiterProcessor + `Format.Highlight` | Medium | Clean, semantic | v2 | +| Overlay: position colored Box composables | High | Fragile | No | + +**v1 approach (ship fast):** Pre-process markdown before parsing: +```kotlin +fun applyHighlights(content: String, highlights: List): String { + var result = content + // Sort by length descending to avoid nested replacement issues + highlights.sortedByDescending { it.text.length }.forEach { h -> + val idx = result.indexOf(h.text) + if (idx >= 0) { + // Wrap in bold + italic to visually distinguish + result = result.replaceFirst(h.text, "***${h.text}***") + } + } + return result +} +``` + +**v2 approach (proper):** Add highlight support to Vitor's richtext fork: +1. Add `AstHighlight` inline node type +2. Add `HighlightDelimiterProcessor` for `==text==` syntax +3. Add `Format.Highlight` with `SpanStyle(background = Color(0xFFFFEB3B))` +4. Pre-process: wrap highlights with `==text==` before parsing + +##### Floating Toolbar (for future enhancement beyond context menu) + +**Pattern:** `Popup` + custom `PopupPositionProvider` (matches existing `ChatPane.kt:584`): + +```kotlin +class MousePositionProvider(private val offset: IntOffset) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, windowSize: IntSize, + layoutDirection: LayoutDirection, popupContentSize: IntSize, + ): IntOffset { + val x = (offset.x - popupContentSize.width / 2) + .coerceIn(0, windowSize.width - popupContentSize.width) + val y = (offset.y - popupContentSize.height - 8) + .coerceIn(0, windowSize.height - popupContentSize.height) + return IntOffset(x, y) + } +} +``` + +Track mouse via `Modifier.pointerInput` + `awaitPointerEventScope` (pattern from `VideoControls.kt:89`). Dismiss on scroll via `LaunchedEffect(scrollState.isScrollInProgress)`. + +**Files:** +- `desktopApp/ui/highlights/FloatingHighlightToolbar.kt` — Popup with Highlight/Annotate buttons +- `desktopApp/ui/highlights/HighlightAnnotationDialog.kt` — AlertDialog for note text +- `desktopApp/ui/ArticleReaderScreen.kt` — Add context menu override, highlight state, inline rendering +- `commons/compose/markdown/RenderMarkdown.kt` — Add `highlights: List` param + +**UX flow (revised):** +1. User reads article, selects text by click-dragging +2. Right-click → context menu shows "Highlight" / "Highlight with Note" (via `LocalTextContextMenu`) +3. "Highlight" → saves immediately via DesktopHighlightStore, re-renders with bold/italic marker (v1) or yellow bg (v2) +4. "Highlight with Note" → opens HighlightAnnotationDialog → saves with note +5. Click existing highlight → popup with note / delete / publish toggle + +**Success criteria:** +- [ ] Right-click context menu shows "Highlight" option when text is selected +- [ ] Highlight saves and renders as visual marker in article +- [ ] Annotation dialog captures and saves notes +- [ ] Highlights persist across navigation (leave article and return) +- [ ] Best-effort match: highlights survive minor article edits (see brainstorm) +- [ ] Cmd+H keyboard shortcut works as alternative (select, copy, Cmd+H) + +--- + +#### Phase 3: My Highlights Screen + NIP-84 Publishing + +**Goal:** Aggregation screen showing all highlights grouped by article. Public/private toggle per highlight. + +**Files:** +- `desktopApp/ui/highlights/MyHighlightsScreen.kt` +- `desktopApp/ui/highlights/HighlightPublishAction.kt` — tag assembly + publish +- `desktopApp/ui/deck/DeckColumnType.kt` — Add `object MyHighlights` +- `desktopApp/ui/deck/DeckColumnContainer.kt` — Route MyHighlights +- `desktopApp/ui/deck/SinglePaneLayout.kt` — Add nav item +- `desktopApp/ui/deck/AddColumnDialog.kt` — Add to column options +- `desktopApp/ui/deck/ColumnHeader.kt` — Add icon + +##### NIP-84 Publishing (REVISED) + +**Gap found:** `HighlightEvent.create()` only takes `msg` + `signer` — doesn't accept source/context/comment tags. Need a wrapper: + +```kotlin +object HighlightPublishAction { + suspend fun publish( + highlightText: String, + articleEvent: LongTextNoteEvent, + note: String?, + signer: NostrSigner, + ): HighlightEvent { + val tags = TagArrayBuilder().apply { + // Article reference (addressable event) + add(ATag.assemble(30023, articleEvent.pubKey, articleEvent.dTag())) + // Article author attribution + add(PTag.assemble(articleEvent.pubKey, role = "author")) + // Optional annotation + note?.let { add(CommentTag.assemble(it)) } + // Surrounding paragraph as context + extractContext(articleEvent.content, highlightText)?.let { + add(ContextTag.assemble(it)) + } + // Alt text for non-NIP-84 clients + add(AltTag.assemble("Highlight: $highlightText")) + }.build() + + return HighlightEvent.create( + msg = highlightText, + tags = tags, + signer = signer, + ) + } + + /** Extract the paragraph containing the highlighted text */ + fun extractContext(content: String, highlightText: String): String? { + val paragraphs = content.split("\n\n") + return paragraphs.find { it.contains(highlightText) } + } +} +``` + +##### NIP-09 Deletion for Published Highlights + +```kotlin +suspend fun deleteHighlight(eventId: String, signer: NostrSigner): DeletionEvent { + return DeletionEvent.create( + deleteEvents = listOf(eventId), + deleteKinds = listOf(9802), + reason = "User deleted highlight", + signer = signer, + ) +} +``` + +**My Highlights screen layout:** +``` +┌─────────────────────────────────┐ +│ My Highlights │ +├─────────────────────────────────┤ +│ ▼ "Article Title One" │ +│ "highlighted text..." 🔒 │ +│ Note: my annotation ╳ │ +│ Mar 24, 2026 │ +│ │ +│ "another highlight..." 🌐 │ +│ Mar 24, 2026 │ +│ │ +│ ▼ "Article Title Two" │ +│ "highlighted passage..." 🔒 │ +│ Note: thoughts here ╳ │ +└─────────────────────────────────┘ +🔒 = private 🌐 = published +Click article title → navigates to article +Click 🔒 → publish to Nostr (NIP-84) +Click ╳ → delete (+ NIP-09 if published) +``` + +**Success criteria:** +- [ ] My Highlights accessible from sidebar nav +- [ ] Highlights grouped by article with collapsible sections +- [ ] Click article title navigates to article (onNavigateToArticle) +- [ ] Public/private toggle publishes NIP-84 event to relays +- [ ] Delete removes from local store + sends NIP-09 deletion for published +- [ ] Empty state when no highlights exist + +## Acceptance Criteria + +- [ ] Right-click selected text in article → "Highlight" in context menu +- [ ] Click "Highlight" → text marked visually, saved locally +- [ ] Click "Highlight with Note" → note dialog, then saved with annotation +- [ ] Cmd+H keyboard shortcut creates highlight from clipboard +- [ ] Highlights persist across app restarts +- [ ] Highlights survive article content updates (best-effort string match) +- [ ] "My Highlights" screen shows all highlights grouped by article +- [ ] Can toggle highlight public/private (publishes NIP-84 event) +- [ ] Can delete highlights (+ NIP-09 for published) +- [ ] Zoom (Cmd+/Cmd-) doesn't break highlight rendering +- [ ] Works in both single-pane and deck layout modes + +## Dependencies & Risks + +| Risk | Impact | Mitigation | Status | +|------|--------|------------|--------| +| `SelectionContainer` doesn't expose selection state | High | **Resolved:** Use `LocalTextContextMenu` override — official API, accesses `selectedText` directly | Mitigated | +| richtext library doesn't support `` or highlight formatting | Medium | **Resolved:** v1 uses bold/italic pre-processing; v2 adds `Format.Highlight` to fork | Mitigated | +| `LocalTextContextMenu.TextManager.selectedText` doesn't work across multiple `Text()` children | Medium | Test during Phase 2; fallback to clipboard-based Cmd+H shortcut | Open | +| `HighlightEvent.create()` doesn't accept custom tags | Low | **Resolved:** Create `HighlightPublishAction` wrapper with `TagArrayBuilder` | Mitigated | +| Preferences 8KB per-value limit | Low | Monitor; spill to file storage if needed | Open | + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md](docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md) + - Key decisions: private+public scope, select+popup UX, Preferences storage, own highlights only, best-effort persistence, My Highlights screen + +### Internal References + +- HighlightEvent protocol: `quartz/nip84Highlights/HighlightEvent.kt:137-141` +- DraftStore pattern: `desktopApp/service/drafts/DesktopDraftStore.kt` +- Event publishing: `desktopApp/ui/ArticleEditorScreen.kt:161-187` +- SelectionContainer: `desktopApp/ui/ArticleEditorScreen.kt:304` +- Context menu override: `LocalTextContextMenu` (Compose Desktop API) +- Popup pattern: `desktopApp/ui/chats/ChatPane.kt:584` +- Mouse tracking: `desktopApp/ui/media/VideoControls.kt:89` +- Android highlight rendering: `amethyst/ui/note/types/Highlight.kt:179-198` + +### External References + +- [Compose Desktop context menus](https://kotlinlang.org/docs/multiplatform/compose-desktop-context-menus.html) +- [NIP-84 spec (Highlights)](https://github.com/nostr-protocol/nips/blob/master/84.md) +- [NIP-09 spec (Event Deletion)](https://github.com/nostr-protocol/nips/blob/master/09.md) +- [commonmark-java DelimiterProcessor](https://github.com/commonmark/commonmark-java) diff --git a/docs/plans/2026-03-24-long-form-reads-manual-testing.md b/docs/plans/2026-03-24-long-form-reads-manual-testing.md new file mode 100644 index 000000000..93a9e01c6 --- /dev/null +++ b/docs/plans/2026-03-24-long-form-reads-manual-testing.md @@ -0,0 +1,156 @@ +--- +title: "Long-Form Reads — Manual Testing Sheet" +date: 2026-03-24 +branch: features/long-form-content +--- + +# Long-Form Reads — Manual Testing Sheet + +**Run:** `cd AmethystMultiplatform-long-form && ./gradlew :desktopApp:run` + +## Pre-Test Setup + +- [x] App launches without crash +- [x] Login with existing account (needs relay connections) +- [x] Navigate to Reads tab in sidebar + +--- + +## 1. ReadsScreen Feed + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 1.1 | Feed loads articles | Click Reads in sidebar | Long-form article cards appear | | +| 1.2 | Reading time shown | Check article cards | "X min read" displayed on each card | | +| 1.3 | Global/Following toggle | Click Global/Following chips | Feed switches between modes | | +| 1.4 | Article click navigates | Click any article card | ArticleReaderScreen opens (not ThreadScreen) | | + +--- + +## 2. ArticleReaderScreen + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 2.1 | Article loads | Click article from Reads feed | Content renders with markdown formatting | | +| 2.2 | Title + metadata | Check header area | Title, author name, reading time, date displayed | | +| 2.3 | Banner image | Open article with banner | Hero image renders at top (if article has `image` tag) | | +| 2.4 | Markdown headings | Scroll through article | H1-H3 render with different sizes | | +| 2.5 | Bold/italic/code | Check formatting | **Bold**, *italic*, `inline code` render correctly | | +| 2.6 | Code blocks | Find code block | Monospace font, distinct background | | +| 2.7 | Links clickable | Click a URL link | Opens in system browser | | +| 2.8 | nostr: links | Click a nostr: link | Does NOT open OS error dialog (scheme filtered) | | +| 2.9 | Images in content | Find article with images | Images render via Coil | | +| 2.10 | Back button | Click ← Back | Returns to ReadsScreen | | +| 2.11 | Content width | Check article body | Max ~680dp centered column | | +| 2.12 | Loading state | Open article (watch transition) | "Loading article..." shown briefly | | +| 2.13 | Error state | Open invalid address tag (if testable) | "Article not found" message | | + +--- + +## 3. Table of Contents + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 3.1 | ToC visible | Open article on wide window (>1100dp) | ToC sidebar appears on left with heading list | | +| 3.2 | ToC hidden | Resize window to <900dp | ToC sidebar disappears | | +| 3.3 | Heading hierarchy | Check ToC entries | H2 indented less than H3 | | +| 3.4 | Click heading | Click a ToC entry | Active entry highlights (scroll-to is TODO) | | +| 3.5 | No headings | Open article with no markdown headings | ToC sidebar not shown or empty | | + +--- + +## 4. Article Editor + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 4.1 | Navigate to editor | Drafts tab → New Draft (or via menu) | Editor screen opens with split pane | | +| 4.2 | Split pane | Check layout | Source left, preview right | | +| 4.3 | Live preview | Type markdown in source pane | Preview updates after ~300ms | | +| 4.4 | Toolbar: Bold | Click B button | `**text**` inserted at cursor | | +| 4.5 | Toolbar: Italic | Click I button | `*text*` inserted | | +| 4.6 | Toolbar: Heading | Click H button | `## ` inserted | | +| 4.7 | Toolbar: Link | Click link button | `[text](url)` inserted | | +| 4.8 | Toolbar: Code | Click code button | Backticks inserted | | +| 4.9 | Toolbar: Quote | Click quote button | `> ` inserted | | +| 4.10 | Metadata: Title | Enter title | Title field accepts input, max 256 chars | | +| 4.11 | Metadata: Summary | Enter summary | Summary field accepts input, max 1024 chars | | +| 4.12 | Metadata: Tags | Type tag + Enter | Tag chip added | | +| 4.13 | Metadata: Slug | Enter slug | Auto-sanitized (no special chars) | | +| 4.14 | Ctrl+S save | Press Ctrl+S (or Cmd+S) | Draft saved to disk | | +| 4.15 | Back button | Click ← Back | Returns to previous screen | | +| 4.16 | Preview link safety | Add `[click](javascript:alert(1))` in source | Link NOT clickable in preview (scheme blocked) | | + +--- + +## 5. Draft Storage + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 5.1 | Draft saved | Create draft, save, check filesystem | `~/.amethyst/drafts/.md` exists | | +| 5.2 | Index file | Check filesystem | `~/.amethyst/drafts/index.json` exists with metadata | | +| 5.3 | Drafts screen | Navigate to Drafts | Lists saved drafts with title, date | | +| 5.4 | Resume editing | Click a draft in list | Editor opens with content restored | | +| 5.5 | Delete draft | Click delete on a draft | Confirmation dialog → draft removed | | +| 5.6 | Slug sanitization | Try slug with `../` or special chars | Slug sanitized to safe characters | | +| 5.7 | Directory permissions | `ls -la ~/.amethyst/drafts/` | Dir permissions 700 (Unix) | | + +--- + +## 6. Publish + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 6.1 | Publish button | Fill title + content, click Publish | Event signed and sent to relays | | +| 6.2 | Publish feedback | After publish | Success snackbar / confirmation | | +| 6.3 | Published in feed | After publish, check Reads feed | Your article appears in Global feed | | +| 6.4 | Re-publish (replace) | Edit same draft, publish again | Article updated (same d-tag) | | +| 6.5 | Size limit | Try publishing >100KB content | Error message about content too large | | + +--- + +## 7. Typography (Visual) + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 7.1 | Body text | Read article body | Georgia-style serif, ~18sp, generous line height | | +| 7.2 | Content centered | Check horizontal layout | Content centered with max ~680dp width | | +| 7.3 | Dark mode | Check dark theme | Text ~#E0E0E0 on dark background, comfortable contrast | | +| 7.4 | Blockquotes | Find blockquote | Left border/indent, slightly larger text | | +| 7.5 | Tables | Find table | Renders with columns and rows | | + +--- + +## 8. Security Checks + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 8.1 | XSS in markdown | Article with `` | Rendered as literal text, no execution | | +| 8.2 | URI scheme: javascript | Link `[x](javascript:alert(1))` in article | Link not clickable / filtered | | +| 8.3 | URI scheme: file | Link `[x](file:///etc/passwd)` in article | Link not clickable / filtered | | +| 8.4 | Image URL validation | Article with `file:///etc/passwd` as banner | Image not loaded | | +| 8.5 | Slug traversal | Set slug to `../../.ssh/keys` | Sanitized to safe string | | + +--- + +## 9. Edge Cases + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 9.1 | Empty article | Article with no content | Reader shows empty body, no crash | | +| 9.2 | No relays connected | Disconnect all relays → open article | "Connecting to relays..." loading state | | +| 9.3 | Very long article | Article with 10k+ words | Renders without freeze (may be slow) | | +| 9.4 | No banner image | Article without `image` tag | Header renders without banner, no crash | | +| 9.5 | No author metadata | Article from unknown pubkey | Shows pubkey hex, no profile pic | | +| 9.6 | Window resize | Resize during article reading | Layout adapts, ToC shows/hides | | + +--- + +## Notes + +_Record any bugs, unexpected behavior, or UX issues here:_ + +| | Issue | Severity | Notes | +|--|-------|----------|-------| +| | | | | +| | | | | +| | | | | diff --git a/docs/temp-file-cleanup-analysis.md b/docs/temp-file-cleanup-analysis.md new file mode 100644 index 000000000..d9acd1ea5 --- /dev/null +++ b/docs/temp-file-cleanup-analysis.md @@ -0,0 +1,201 @@ +# Temporary File Cleanup Analysis + +## Overview + +Analysis of temporary file creation and cleanup patterns across the Amethyst codebase. +The goal is to identify opportunities for more aggressive cleanup — deleting temp files +as soon as they are no longer needed rather than deferring to cache cleanup. + +## Temporary File Creation Sites (Android) + +| Area | File | Creates | Cleanup | Status | +|------|------|---------|---------|--------| +| Image compression | `MediaCompressor.kt:94` | Temp copy via `MediaCompressorFileUtils.from()` | Deleted immediately after compression | FIXED | +| Image metadata strip | `MetadataStripper.kt:199` | `stripped_*.jpg` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| Video metadata strip | `MetadataStripper.kt:238` | `stripped_video_*.mp4` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| Audio metadata strip | `MetadataStripper.kt:286` | `stripped_audio_*.m4a` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| MP3 metadata strip | `MetadataStripper.kt:307,363` | `mp3_input_*` + `stripped_mp3_*` | Mixed: some immediate, some orchestrator | Already OK | +| File encryption | `EncryptFiles.kt:47` | `EncryptFiles*.encrypted` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| URI temp copy | `MediaCompressorFileUtils.kt:41` | Random UUID temp file | Deleted by MediaCompressor after use | FIXED | +| Video compression | `VideoCompressionHelper.kt` | Compressed video in app storage | Abandoned file deleted when larger than original | FIXED | +| Voice anonymization | `VoiceAnonymizationController.kt:85` | Distorted voice files | `deleteDistortedFiles()` explicit | Already OK | +| Video sharing | `ZoomableContentView.kt:905` | Temp video + sharable copy | Delayed GlobalScope (1 min) | Skipped (intentional) | +| Camera capture | `TakePicture.kt:244` | Camera temp file | System/caller | Skipped (system-managed) | + +## Temporary File Creation Sites (Desktop) + +| Area | File | Creates | Cleanup | Notes | +|------|------|---------|---------|-------| +| Clipboard paste | `ClipboardPasteHandler.kt:43` | `clipboard_*.png` | `deleteOnExit()` only | Leaks until JVM exit | +| Image compression | `DesktopMediaCompressor.kt:42` | `stripped_*.jpg` | `deleteOnExit()` only | Leaks until JVM exit | + +## The Upload Pipeline + +The `UploadOrchestrator` is the central cleanup coordinator. Each intermediate temp file +is now deleted as soon as the next pipeline stage produces its output: + +``` +1. MediaCompressorFileUtils.from() --> temp copy of original URI +2. MediaCompressor.compress() --> compressed file; temp copy from #1 deleted immediately +3. MetadataStripper.strip*() --> stripped file; compressed file from #2 deleted immediately +4. (optional) EncryptFiles.encrypt() --> encrypted file; stripped file from #3 deleted immediately +5. Upload to server +6. finally: delete the last remaining intermediate +``` + +## What Was Fixed + +### 1. MediaCompressor temp file leak (MediaCompressor.kt) +- `MediaCompressorFileUtils.from()` created a temp copy that was never deleted +- Now deleted immediately after `Compressor.compress()` produces a separate output file +- Also cleaned up on compression failure (catch block) + +### 2. Eager pipeline cleanup (UploadOrchestrator.kt) +- `upload()`: compressed file deleted right after stripping produces `finalUri` +- `uploadEncrypted()`: compressed file deleted after stripping, stripped file deleted + after encryption — only the encrypted file survives until after upload +- Cancel path also cleans up compressed file via `.also {}` block +- `finally` block now only handles the last surviving intermediate + +### 3. Abandoned compressed video (VideoCompressionHelper.kt) +- When compressed video is larger than original, the original is used instead +- The abandoned compressed file was leaked — now deleted before returning + +## What Was Skipped + +### Desktop temp files (out of scope for this change) +- `ClipboardPasteHandler.kt` and `DesktopMediaCompressor.kt` use `deleteOnExit()` +- Files persist until JVM process exits — not ideal for a long-running desktop app +- `DesktopUploadOrchestrator.kt` uses bare `processedFile.delete()` with no error + handling or logging, diverging from the Android `deleteTempUri` pattern +- **Reason:** User requested Android-only focus for this iteration + +### Voice anonymization intermediates +- `VoiceAnonymizationController.deleteDistortedFiles()` is already reasonably aggressive +- Called explicitly by `ShortNotePostViewModel` after upload +- **Reason:** Already working well, no leak identified + +### Video sharing delay +- `ZoomableContentView.kt` uses a 1-minute delay before cleanup +- **Reason:** Intentional — receiving app needs time to read the shared file + +### Camera capture temp files +- `TakePicture.kt` creates temp files via camera provider +- **Reason:** Managed by the Android system/camera provider, not our responsibility + +## Exception: Video Sharing + +Sharing a video to other Android apps requires the temporary file to remain accessible +for at least 1 minute. The current `SHARED_VIDEO_CLEANUP_DELAY_MS` delay in +`ZoomableContentView.kt` handles this correctly and should **not** be made more aggressive. + +## Manual Test Plan + +### Setup + +Enable `adb logcat` filtering to observe cleanup behavior: + +```bash +adb logcat -s MediaCompressor:* UploadOrchestrator:* VideoCompressionHelper:* MetadataStripper:* +``` + +To verify temp files are actually being deleted, monitor the cache directory before and +after each test: + +```bash +adb shell "ls -la /data/data/com.vitorpamplona.amethyst/cache/ | grep -E 'stripped_|EncryptFiles|mp3_input|stripped_mp3|stripped_video|stripped_audio'" +``` + +### Test 1: Image upload with compression + +1. Open a new note compose screen +2. Attach a JPEG photo from the gallery +3. Set compression quality to Medium +4. Post the note +5. **Verify in logcat:** + - `MediaCompressor: Image compression success` appears + - `MediaCompressor: Failed to delete temp file` does NOT appear + - `UploadOrchestrator: Deleted temp file` appears (for the stripped file after upload) +6. **Verify in cache dir:** No `stripped_*.jpg` files remain after upload completes + +### Test 2: Image upload without compression + +1. Open a new note compose screen +2. Attach a JPEG photo from the gallery +3. Set compression quality to Uncompressed +4. Post the note +5. **Verify in logcat:** + - No `MediaCompressor` compression log appears + - `UploadOrchestrator: Deleted temp file` appears for the stripped file +6. **Verify in cache dir:** No `stripped_*` files remain + +### Test 3: Video upload with compression + +1. Open a new note compose screen +2. Attach a video from the gallery +3. Set compression quality to Medium +4. Post the note +5. **Verify in logcat:** + - `VideoCompressionHelper: Compression success` appears + - `UploadOrchestrator: Deleted temp file` appears +6. **Verify in cache dir:** No `stripped_video_*.mp4` files remain + +### Test 4: Video compression produces larger file + +1. Attach a very small or already-compressed video +2. Set compression to Low quality +3. Post the note +4. **Verify in logcat:** + - `VideoCompressionHelper: Compressed file larger than original. Using original.` appears + - The compressed file is deleted (no orphaned file in cache) + +### Test 5: Audio/voice message upload + +1. Record a voice message in a note or reply +2. Send it +3. **Verify in logcat:** + - `UploadOrchestrator: Deleted temp file` appears for the stripped audio +4. **Verify in cache dir:** No `stripped_audio_*.m4a` files remain + +### Test 6: MP3 upload + +1. Attach an MP3 file (with ID3 tags) from the file picker +2. Post the note +3. **Verify in logcat:** + - `MetadataStripper: Stripped ID3 tags from MP3` appears + - `UploadOrchestrator: Deleted temp file` appears +4. **Verify in cache dir:** No `mp3_input_*` or `stripped_mp3_*` files remain + +### Test 7: Encrypted file upload (NIP-44 DM) + +1. Open a DM conversation +2. Attach an image +3. Send the message (triggers encrypted upload path) +4. **Verify in logcat:** + - Compressed file is deleted after stripping + - Stripped file is deleted after encryption + - Encrypted file is deleted after upload + - Three separate `UploadOrchestrator: Deleted temp file` log lines appear +5. **Verify in cache dir:** No `stripped_*` or `EncryptFiles*` files remain + +### Test 8: Upload cancellation + +1. Open a new note compose screen +2. Attach a large image or video +3. Cancel the upload while compression or upload is in progress +4. **Verify in cache dir:** No temp files remain from the cancelled upload + +### Test 9: Video sharing to other apps + +1. Open a note with a video +2. Long-press or use the share button to share the video to another app +3. **Verify:** The receiving app successfully receives the video +4. **Verify:** After ~1 minute, the temp file in the share directory is cleaned up +5. **This test confirms the 1-minute delay was not broken by our changes** + +### Test 10: Image compression failure fallback + +1. Attach a GIF or SVG file (compression is skipped for these) +2. Post the note +3. **Verify:** Upload succeeds using the original file +4. **Verify in cache dir:** No orphaned temp files \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db9af4a9e..bdaed5d14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,11 +11,11 @@ androidKotlinGeohash = "b481c6a64e" androidxJunit = "1.3.0" appcompat = "1.7.1" audiowaveform = "1.1.2" -benchmark = "1.5.0-alpha04" +benchmark = "1.5.0-alpha05" biometricKtx = "1.2.0-alpha05" coil = "3.4.0" -composeBom = "2026.03.00" -composeRuntimeAnnotation = "1.10.5" +composeBom = "2026.03.01" +composeRuntimeAnnotation = "1.10.6" coreKtx = "1.18.0" datastore = "1.2.1" devWhyolegCryptography = "0.5.0" @@ -23,9 +23,8 @@ espressoCore = "3.7.0" firebaseBom = "34.11.0" fragmentKtx = "1.8.9" gms = "4.4.4" -jacksonModuleKotlin = "2.21.1" +jacksonModuleKotlin = "2.21.2" javaKeyring = "1.0.4" -jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" kchesslib = "1.0.5" @@ -34,16 +33,15 @@ kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.10.0" languageId = "17.0.6" -lazysodiumAndroid = "5.2.0" -lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.10.0" -lightcompressor-enhanced = "1.6.0" +lightcompressor-enhanced = "1.8.1" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3" -media3 = "1.9.3" +media3 = "1.10.0" mockk = "1.14.9" kotlinx-coroutines-test = "1.10.2" +negentropyKmp = "1.0.1" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.7" okhttp = "5.3.2" @@ -57,7 +55,6 @@ torAndroid = "0.4.9.5.1" translate = "17.0.3" jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" -uriReferenceKmp = "1.0" vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" @@ -66,7 +63,7 @@ commonsImaging = "1.0.0-alpha6" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" -androidxCamera = "1.5.3" +androidxCamera = "1.6.0" androidxCollection = "1.6.0" androidxExifinterface = "1.4.2" kotlinTest = "2.3.0" @@ -127,7 +124,6 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio 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" } @@ -145,7 +141,6 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } -jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } @@ -154,14 +149,13 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } -lazysodium-java = { group = "com.goterl", name = "lazysodium-java", version.ref = "lazysodiumJava" } -lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" } markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" } markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} +negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", version.ref = "negentropyKmp" } 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" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 1fed30907..adb968e98 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -58,70 +58,8 @@ kotlin { // project can be found here: // https://developer.android.com/kotlin/multiplatform/migrate val xcfName = "quartz-kmpKit" - val libsodiumPath = project.file("src/nativeInterop/libsodium") - val libsodiumHeaderFilesPath = project.file("$libsodiumPath/include/sodium") - - // 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.files(libsodiumDeviceDefFile, libsodiumSimulatorDefFile) - doLast { - libsodiumDeviceDefFile.parentFile.mkdirs() - libsodiumDeviceDefFile.writeText( - "package = Clibsodium\n" + - "staticLibraries = libsodium.a\n" + - "libraryPaths = ${libsodiumPath.absolutePath}/ios/lib\n", - ) - libsodiumSimulatorDefFile.writeText( - "package = Clibsodium\n" + - "staticLibraries = libsodium-simulator.a\n" + - "libraryPaths = ${libsodiumPath.absolutePath}/ios-simulators/lib\n", - ) - } - } - - listOf( - iosArm64(), - 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 = defFile - packageName = "Clibsodium" - - headers( - "$libsodiumHeaderFilesPath/crypto_aead_xchacha20poly1305.h", - "$libsodiumHeaderFilesPath/crypto_core_hchacha20.h", - "$libsodiumHeaderFilesPath/crypto_stream_chacha20.h", - ) - } - - tasks.named(cinterops.getByName("clibsodium").interopProcessingTaskName).configure { - dependsOn(libsodiumDefFileGeneration) - } - } - } iosArm64 { - binaries.all { - linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium") - } binaries.framework { baseName = xcfName isStatic = true @@ -130,9 +68,6 @@ kotlin { } iosSimulatorArm64 { - binaries.all { - linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator") - } binaries.framework { baseName = xcfName isStatic = true @@ -140,6 +75,8 @@ kotlin { } } + linuxX64() + // This makes sure that the resource file directory is visible for iOS tests. val rootDir = "${rootProject.rootDir.path}/quartz/src/commonTest/resources" @@ -183,9 +120,9 @@ kotlin { // SQLite KMP driver for event store api(libs.androidx.sqlite) implementation(libs.androidx.sqlite.bundled) - - // RFC3986 library(normalizes URLs) - api(libs.uri.reference.kmp) + + // Negentropy set reconciliation (NIP-77) + api(libs.negentropy.kmp) } } @@ -235,10 +172,6 @@ kotlin { dependencies { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - - // LibSodium for ChaCha encryption (NIP-44) - implementation(libs.lazysodium.java) - implementation(libs.jna) } } @@ -260,10 +193,6 @@ kotlin { // 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") } } @@ -275,10 +204,6 @@ kotlin { // 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) } @@ -296,22 +221,40 @@ kotlin { // 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") } } - iosMain { - dependsOn(commonMain.get()) - dependencies { - implementation(libs.charlietap.cachemap) - implementation(libs.net.thauvin.erik.urlencoder.lib) - implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) - implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") - implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") + // Must be defined before appleMain, linuxMain, etc. + val nativeMain = + create("nativeMain") { + dependsOn(commonMain.get()) } + + val nativeTest = + create("nativeTest") { + dependsOn(commonTest.get()) + } + + // Must be defined before iosMain and macosMain + val appleMain = + create("appleMain") { + dependsOn(nativeMain) + dependencies { + implementation(libs.charlietap.cachemap) + implementation(libs.net.thauvin.erik.urlencoder.lib) + implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) + implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") + implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") + } + } + + val appleTest = + create("appleTest") { + dependsOn(nativeTest) + } + + iosMain { + dependsOn(appleMain) } val iosArm64Main by getting { @@ -323,9 +266,7 @@ kotlin { } iosTest { - dependsOn(commonTest.get()) - dependencies { - } + dependsOn(appleTest) } val iosArm64Test by getting { @@ -335,6 +276,30 @@ kotlin { val iosSimulatorArm64Test by getting { dependsOn(iosTest.get()) } + + val linuxMain = + create("linuxMain") { + dependsOn(nativeMain) + dependencies { + implementation(libs.net.thauvin.erik.urlencoder.lib) + implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) + implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") + implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") + } + } + + val linuxTest = + create("linuxTest") { + dependsOn(nativeTest) + } + + val linuxX64Main by getting { + dependsOn(linuxMain) + } + + val linuxX64Test by getting { + dependsOn(linuxTest) + } } } diff --git a/quartz/consumer-rules.pro b/quartz/consumer-rules.pro index 4985bd413..04373ed53 100644 --- a/quartz/consumer-rules.pro +++ b/quartz/consumer-rules.pro @@ -16,32 +16,10 @@ # preserve access to native classses -keep class fr.acinq.secp256k1.** { *; } -# JNA For Libsodium --keep class com.goterl.lazysodium.** { *; } - # libscrypt -keep class com.lambdaworks.codec.** { *; } -keep class com.lambdaworks.crypto.** { *; } -keep class com.lambdaworks.jni.** { *; } -# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out --keep class com.sun.jna.ToNativeConverter { *; } --keep class com.sun.jna.NativeMapped { *; } --keep class com.sun.jna.CallbackReference { *; } --keep class com.sun.jna.ptr.IntByReference { *; } --keep class com.sun.jna.NativeLong { *; } --keep class com.sun.jna.Structure { *; } --keep class com.sun.jna.Structure$* { *; } --keep class com.sun.jna.Native$ffi_callback { *; } --keep class * implements com.sun.jna.Structure$* { *; } --keep class * implements com.sun.jna.Native$* { *; } --keep class com.sun.jna.Native { - private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object); - private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object); - private static java.lang.Class nativeType(java.lang.Class); - private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object); - private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method); -} - # JSON parsing -keep class com.vitorpamplona.quartz.** { *; } \ No newline at end of file diff --git a/quartz/proguard-rules.pro b/quartz/proguard-rules.pro index 4d9f471e2..e1f545f4d 100644 --- a/quartz/proguard-rules.pro +++ b/quartz/proguard-rules.pro @@ -30,33 +30,11 @@ # preserve access to native classses -keep class fr.acinq.secp256k1.** { *; } -# JNA For Libsodium --keep class com.goterl.lazysodium.** { *; } - # libscrypt -keep class com.lambdaworks.codec.** { *; } -keep class com.lambdaworks.crypto.** { *; } -keep class com.lambdaworks.jni.** { *; } -# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out --keep class com.sun.jna.ToNativeConverter { *; } --keep class com.sun.jna.NativeMapped { *; } --keep class com.sun.jna.CallbackReference { *; } --keep class com.sun.jna.ptr.IntByReference { *; } --keep class com.sun.jna.NativeLong { *; } --keep class com.sun.jna.Structure { *; } --keep class com.sun.jna.Structure$* { *; } --keep class com.sun.jna.Native$ffi_callback { *; } --keep class * implements com.sun.jna.Structure$* { *; } --keep class * implements com.sun.jna.Native$* { *; } --keep class com.sun.jna.Native { - private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object); - private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object); - private static java.lang.Class nativeType(java.lang.Class); - private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object); - private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method); -} - # JSON parsing -keep class com.vitorpamplona.quartz.** { *; } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt deleted file mode 100644 index fc97ca488..000000000 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt +++ /dev/null @@ -1,94 +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.nip44Encryption - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class Nip44v1Test { - private val nip44v1 = Nip44v1() - - @Test - fun testSharedSecretCompatibilityWithCoracle() { - val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" - val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f" - - val key = - nip44v1.getSharedSecret( - privateKey = privateKey.hexToByteArray(), - pubKey = publicKey.hexToByteArray(), - ) - - assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey()) - } - - @Test - fun testSharedSecret() { - val sender = KeyPair() - val receiver = KeyPair() - - val sharedSecret1 = nip44v1.getSharedSecret(sender.privKey!!, receiver.pubKey) - val sharedSecret2 = nip44v1.getSharedSecret(receiver.privKey!!, sender.pubKey) - - assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey()) - - val secretKey1 = KeyPair(privKey = sharedSecret1) - val secretKey2 = KeyPair(privKey = sharedSecret2) - - assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey()) - assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey()) - } - - @Test - fun encryptDecrypt() { - val msg = "Hi" - - val privateKey = Nip01Crypto.privKeyCreate() - val publicKey = Nip01Crypto.pubKeyCreate(privateKey) - - val encrypted = nip44v1.encrypt(msg, privateKey, publicKey) - val decrypted = nip44v1.decrypt(encrypted, privateKey, publicKey) - - assertEquals(msg, decrypted) - } - - @Test - fun encryptDecryptSharedSecret() { - val msg = "Hi" - - val privateKey = Nip01Crypto.privKeyCreate() - val publicKey = Nip01Crypto.pubKeyCreate(privateKey) - - val sharedSecret = nip44v1.getSharedSecret(privateKey, publicKey) - - val encrypted = nip44v1.encrypt(msg, sharedSecret) - val decrypted = nip44v1.decrypt(encrypted, sharedSecret) - - assertEquals(msg, decrypted) - } -} diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt deleted file mode 100644 index a17fb31d7..000000000 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt +++ /dev/null @@ -1,185 +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.nip44Encryption - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.core.JsonMapper -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto -import com.vitorpamplona.quartz.utils.RandomInstance -import com.vitorpamplona.quartz.utils.sha256.sha256 -import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertNull -import junit.framework.TestCase.fail -import kotlinx.serialization.json.decodeFromStream -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class Nip44v2Test { - private val vectors: VectorFile = - JsonMapper.jsonInstance.decodeFromStream( - javaClass.classLoader.getResourceAsStream("nip44.vectors.json"), - ) - - private val nip44v2 = Nip44v2() - - @Test - fun conversationKeyTest() { - for (v in vectors.v2?.valid?.getConversationKey!!) { - val conversationKey = - nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) - - assertEquals(v.conversationKey, conversationKey.toHexKey()) - } - } - - @Test - fun paddingTest() { - for (v in vectors.v2?.valid?.calcPaddedLen!!) { - val actual = nip44v2.calcPaddedLen(v[0]) - assertEquals(v[1], actual) - } - } - - @Test - fun testCompressedWith02Keys() { - val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() - val privateKeyB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray() - - val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA) - val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB) - - assertEquals( - nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(), - nip44v2.getConversationKey(privateKeyB, publicKeyA).toHexKey(), - ) - } - - @Test - fun testCompressedWith03Keys() { - val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() - val privateKeyB = "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0".hexToByteArray() - - val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA) - val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB) - - assertEquals( - nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(), - nip44v2.getConversationKey(privateKeyB, publicKeyA).toHexKey(), - ) - } - - @Test - fun encryptDecryptTest() { - for (v in vectors.v2?.valid?.encryptDecrypt!!) { - val pub2 = KeyPair(v.sec2!!.hexToByteArray()) - val conversationKey1 = nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), pub2.pubKey) - assertEquals(v.conversationKey, conversationKey1.toHexKey()) - - val ciphertext = - nip44v2 - .encryptWithNonce( - v.plaintext!!, - conversationKey1, - v.nonce!!.hexToByteArray(), - ).encodePayload() - - assertEquals(v.payload, ciphertext) - - val pub1 = KeyPair(v.sec1.hexToByteArray()) - val conversationKey2 = nip44v2.getConversationKey(v.sec2.hexToByteArray(), pub1.pubKey) - assertEquals(v.conversationKey, conversationKey2.toHexKey()) - - val decrypted = nip44v2.decrypt(v.payload!!, conversationKey2) - assertEquals(v.plaintext, decrypted) - } - } - - @Test - fun encryptDecryptLongTest() { - for (v in vectors.v2?.valid?.encryptDecryptLongMsg!!) { - val conversationKey = v.conversationKey!!.hexToByteArray() - val plaintext = v.pattern!!.repeat(v.repeat!!) - - assertEquals(v.plaintextSha256, sha256Hex(plaintext.toByteArray(Charsets.UTF_8))) - - val ciphertext = - nip44v2 - .encryptWithNonce( - plaintext, - conversationKey, - v.nonce!!.hexToByteArray(), - ).encodePayload() - - assertEquals(v.payloadSha256, sha256Hex(ciphertext.toByteArray(Charsets.UTF_8))) - - val decrypted = nip44v2.decrypt(ciphertext, conversationKey) - - assertEquals(plaintext, decrypted) - } - } - - @Test - fun extendedMessageLengths() { - for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { - val key = RandomInstance.bytes(32) - try { - val input = "a".repeat(v) - val result = nip44v2.encrypt(input, key) - val decrypted = nip44v2.decrypt(result, key) - assertEquals(input, decrypted) - } catch (e: Exception) { - assertNotNull(e) - } - } - } - - @Test - fun invalidDecrypt() { - for (v in vectors.v2?.invalid?.decrypt!!) { - try { - val result = nip44v2.decrypt(v.payload!!, v.conversationKey!!.hexToByteArray()) - assertNull(result) - // fail("Should Throw for ${v.note}") - } catch (e: Exception) { - assertNotNull(e) - } - } - } - - @Test - fun invalidConversationKey() { - for (v in vectors.v2?.invalid?.getConversationKey!!) { - try { - nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) - fail("Should Throw for ${v.note}") - } catch (e: Exception) { - assertNotNull(e) - } - } - } - - private fun sha256Hex(data: ByteArray) = sha256(data).toHexKey() -} diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt index 35f268033..33adaa885 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Ignore @@ -41,7 +40,7 @@ internal class Nip46Test { val dummyEvent = EventTemplate( - createdAt = TimeUtils.now(), + createdAt = 1753988264, kind = 1, tags = emptyArray(), content = "test", @@ -49,12 +48,12 @@ internal class Nip46Test { val dummyEventSigned = TextNoteEvent( - id = "", - pubKey = "", - createdAt = TimeUtils.now(), + id = "0b6d941c46411a95edb1c93da7ad6ca26370497d8c7b7d621f5cb59f48841bad", + pubKey = "6dd3b72e325da7383b275eef1c66131ba4664326e162bc060527509b4e33ae43", + createdAt = 1753988264, tags = emptyArray(), content = "test", - sig = "", + sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356", ) suspend fun encodeDecodeEvent(req: T): T { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt index 5f5c98e24..64551b128 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -83,6 +83,7 @@ class NostrSignerExternal( val result = backgroundQuery.sign(unsignedEvent) ?: foregroundQuery.sign(unsignedEvent) if (result is SignerResult.RequestAddressed.Successful) { + @Suppress("UNCHECKED_CAST") (result.result.event as? T)?.let { return it } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/transport/AndroidBleTransport.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/transport/AndroidBleTransport.kt new file mode 100644 index 000000000..640670baa --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/transport/AndroidBleTransport.kt @@ -0,0 +1,478 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nipBEBle.transport + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.ParcelUuid +import com.vitorpamplona.quartz.nipBEBle.AndroidBleTransportContract +import com.vitorpamplona.quartz.nipBEBle.BleConfig +import com.vitorpamplona.quartz.nipBEBle.BlePeer +import com.vitorpamplona.quartz.nipBEBle.BleRole +import com.vitorpamplona.quartz.utils.Log +import java.util.UUID + +/** + * Android implementation of [BleTransport] using the Android BLE APIs. + * + * Handles BLE advertising, scanning, GATT server, and GATT client operations + * per NIP-BE specification. + * + * **Required permissions** (must be granted before calling any methods): + * - `BLUETOOTH_ADVERTISE` (Android 12+) + * - `BLUETOOTH_CONNECT` (Android 12+) + * - `BLUETOOTH_SCAN` (Android 12+) + * - `ACCESS_FINE_LOCATION` (for scanning on Android < 12) + * + * Usage: + * ```kotlin + * val mesh = BleNostrMesh(AndroidBleTransport(context)) + * mesh.onEvent { event, peer -> saveEvent(event) } + * mesh.start() + * ``` + */ +@SuppressLint("MissingPermission") +class AndroidBleTransport( + private val context: Context, + override val deviceUuid: String = UUID.randomUUID().toString().uppercase(), +) : BleTransport, + AndroidBleTransportContract { + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter + + private var listener: BleTransportListener? = null + private var gattServer: BluetoothGattServer? = null + private val gattConnections = mutableMapOf() + private val connectedDevices = mutableMapOf() + private val deviceUuidMap = mutableMapOf() + private val peerMap = mutableMapOf() + + private val serviceUuid = UUID.fromString(BleConfig.SERVICE_UUID) + private val writeCharUuid = UUID.fromString(BleConfig.WRITE_CHARACTERISTIC_UUID) + private val readCharUuid = UUID.fromString(BleConfig.READ_CHARACTERISTIC_UUID) + + private var readCharacteristic: BluetoothGattCharacteristic? = null + + /** + * Sets the listener for transport events. Must be called before [startAdvertising] or [startScanning]. + */ + fun setListener(listener: BleTransportListener) { + this.listener = listener + } + + override fun setTransportListener(listener: BleTransportListener) { + this.listener = listener + } + + override fun startAdvertising() { + val advertiser = + bluetoothAdapter?.bluetoothLeAdvertiser ?: run { + listener?.onError(null, "BLE advertising not supported") + return + } + + val settings = + AdvertiseSettings + .Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setConnectable(true) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setTimeout(0) + .build() + + val data = + AdvertiseData + .Builder() + .addServiceUuid(ParcelUuid(serviceUuid)) + .addServiceData(ParcelUuid(serviceUuid), uuidToBytes(deviceUuid)) + .setIncludeDeviceName(false) + .build() + + advertiser.startAdvertising(settings, data, advertiseCallback) + } + + override fun stopAdvertising() { + bluetoothAdapter?.bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback) + } + + override fun startScanning() { + val scanner = + bluetoothAdapter?.bluetoothLeScanner ?: run { + listener?.onError(null, "BLE scanning not supported") + return + } + + val filter = + ScanFilter + .Builder() + .setServiceUuid(ParcelUuid(serviceUuid)) + .build() + + val settings = + ScanSettings + .Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + scanner.startScan(listOf(filter), settings, scanCallback) + } + + override fun stopScanning() { + bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback) + } + + override fun openGattServer() { + gattServer = bluetoothManager.openGattServer(context, gattServerCallback) + + val service = BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY) + + val writeChar = + BluetoothGattCharacteristic( + writeCharUuid, + BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE, + ) + writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + + readCharacteristic = + BluetoothGattCharacteristic( + readCharUuid, + BluetoothGattCharacteristic.PROPERTY_READ or + BluetoothGattCharacteristic.PROPERTY_NOTIFY, + BluetoothGattCharacteristic.PERMISSION_READ, + ) + + // Client Characteristic Configuration Descriptor for notifications + val cccd = + BluetoothGattDescriptor( + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), + BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE, + ) + readCharacteristic!!.addDescriptor(cccd) + + service.addCharacteristic(writeChar) + service.addCharacteristic(readCharacteristic) + + gattServer?.addService(service) + } + + override fun closeGattServer() { + gattServer?.close() + gattServer = null + readCharacteristic = null + } + + override fun connectToPeer(peer: BlePeer) { + val device = + peer.platformHandle as? BluetoothDevice ?: run { + listener?.onError(peer, "Invalid platform handle for peer ${peer.deviceUuid}") + return + } + + peerMap[peer.deviceUuid] = peer + val gatt = device.connectGatt(context, false, gattClientCallback, BluetoothDevice.TRANSPORT_LE) + gattConnections[peer.deviceUuid] = gatt + } + + override fun disconnectFromPeer(peer: BlePeer) { + gattConnections.remove(peer.deviceUuid)?.let { + it.disconnect() + it.close() + } + connectedDevices.remove(peer.deviceUuid) + peerMap.remove(peer.deviceUuid) + } + + override fun writeChunk( + peer: BlePeer, + chunk: ByteArray, + ): Boolean { + val gatt = gattConnections[peer.deviceUuid] ?: return false + val service = gatt.getService(serviceUuid) ?: return false + val char = service.getCharacteristic(writeCharUuid) ?: return false + + char.value = chunk + return gatt.writeCharacteristic(char) + } + + override fun notifyChunk( + peer: BlePeer, + chunk: ByteArray, + ): Boolean { + val server = gattServer ?: return false + val device = connectedDevices[peer.deviceUuid] ?: return false + val char = readCharacteristic ?: return false + + char.value = chunk + return server.notifyCharacteristicChanged(device, char, false) + } + + override fun requestMtu( + peer: BlePeer, + mtu: Int, + ) { + gattConnections[peer.deviceUuid]?.requestMtu(mtu) + } + + // -- Advertise Callback -- + + private val advertiseCallback = + object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + Log.d("AndroidBleTransport", "Advertising started") + } + + override fun onStartFailure(errorCode: Int) { + listener?.onError(null, "Advertising failed with error code: $errorCode") + } + } + + // -- Scan Callback -- + + private val scanCallback = + object : ScanCallback() { + override fun onScanResult( + callbackType: Int, + result: ScanResult, + ) { + val serviceData = result.scanRecord?.getServiceData(ParcelUuid(serviceUuid)) ?: return + val peerUuid = bytesToUuid(serviceData) ?: return + val device = result.device + + deviceUuidMap[device.address] = peerUuid + + listener?.onPeerDiscovered(peerUuid, device) + } + + override fun onScanFailed(errorCode: Int) { + listener?.onError(null, "Scan failed with error code: $errorCode") + } + } + + // -- GATT Server Callback -- + + private val gattServerCallback = + object : BluetoothGattServerCallback() { + override fun onConnectionStateChange( + device: BluetoothDevice, + status: Int, + newState: Int, + ) { + val peerUuid = deviceUuidMap[device.address] ?: return + val peer = peerMap[peerUuid] ?: BlePeer(peerUuid, BleRole.CLIENT, device) + + if (newState == BluetoothProfile.STATE_CONNECTED) { + connectedDevices[peerUuid] = device + peerMap[peerUuid] = peer + listener?.onPeerConnected(peer) + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + connectedDevices.remove(peerUuid) + listener?.onPeerDisconnected(peer) + } + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray?, + ) { + if (characteristic.uuid == writeCharUuid && value != null) { + val peerUuid = deviceUuidMap[device.address] ?: return + val peer = peerMap[peerUuid] ?: return + + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) + } + + listener?.onChunkReceived(peer, value) + listener?.onWriteSuccess(peer) + } + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic, + ) { + if (characteristic.uuid == readCharUuid) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + characteristic.value ?: ByteArray(0), + ) + } + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice, + requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray?, + ) { + // Client subscribing/unsubscribing to notifications + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) + } + } + } + + // -- GATT Client Callback -- + + private val gattClientCallback = + object : BluetoothGattCallback() { + override fun onConnectionStateChange( + gatt: BluetoothGatt, + status: Int, + newState: Int, + ) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + gatt.requestMtu(BleConfig.DEFAULT_MTU) + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + listener?.onPeerDisconnected(peer) + } + } + + override fun onServicesDiscovered( + gatt: BluetoothGatt, + status: Int, + ) { + if (status != BluetoothGatt.GATT_SUCCESS) return + + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + + // Subscribe to Read Characteristic notifications + val service = gatt.getService(serviceUuid) ?: return + val readChar = service.getCharacteristic(readCharUuid) ?: return + gatt.setCharacteristicNotification(readChar, true) + + val cccd = readChar.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) + cccd?.let { + it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(it) + } + + listener?.onPeerConnected(peer) + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + if (characteristic.uuid == readCharUuid) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + val value = characteristic.value ?: return + + listener?.onChunkReceived(peer, value) + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + if (characteristic.uuid == writeCharUuid && status == BluetoothGatt.GATT_SUCCESS) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + listener?.onWriteSuccess(peer) + } + } + + override fun onMtuChanged( + gatt: BluetoothGatt, + mtu: Int, + status: Int, + ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + listener?.onMtuChanged(peer, mtu) + } + // Discover services after MTU negotiation (matching samiz flow) + gatt.discoverServices() + } + } + + companion object { + /** + * Converts a UUID string to a 16-byte array for advertisement data. + */ + fun uuidToBytes(uuid: String): ByteArray { + val parsed = UUID.fromString(uuid) + val bytes = ByteArray(16) + val msb = parsed.mostSignificantBits + val lsb = parsed.leastSignificantBits + for (i in 0..7) { + bytes[i] = (msb shr (56 - i * 8)).toByte() + bytes[i + 8] = (lsb shr (56 - i * 8)).toByte() + } + return bytes + } + + /** + * Converts a 16-byte array back to a UUID string. + */ + fun bytesToUuid(bytes: ByteArray): String? { + if (bytes.size != 16) return null + var msb = 0L + var lsb = 0L + for (i in 0..7) { + msb = (msb shl 8) or (bytes[i].toLong() and 0xFF) + lsb = (lsb shl 8) or (bytes[i + 8].toLong() and 0xFF) + } + return UUID(msb, lsb).toString().uppercase() + } + } +} 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 deleted file mode 100644 index 7bfa83a2f..000000000 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt +++ /dev/null @@ -1,131 +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 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: 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, - nSec: ByteArray, - ciphertext: ByteArray, - ad: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean = - lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( - message, - longArrayOf(message.size.toLong()), - nSec, - ciphertext, - ciphertext.size.toLong(), - ad, - ad.size.toLong(), - nPub, - k, - ) - - actual fun cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext: ByteArray, - message: ByteArray, - ad: ByteArray, - nSec: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean = - lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext, - longArrayOf(ciphertext.size.toLong()), - message, - message.size.toLong(), - ad, - ad.size.toLong(), - nSec, - nPub, - k, - ) - - actual fun cryptoStreamChaCha20IetfXor( - message: ByteArray, - nonce: ByteArray?, - key: ByteArray?, - ): ByteArray { - val ciphertext = ByteArray(message.size) - lazySodium.cryptoStreamChaCha20IetfXor(ciphertext, message, message.size.toLong(), nonce, key) - return ciphertext - } - - // This function wasn't available in the bindings library. I had to move them here from C - actual fun cryptoStreamXChaCha20Xor( - messageBytes: ByteArray, - nonce: ByteArray, - key: ByteArray, - ): ByteArray { - val cipher = ByteArray(messageBytes.size) - val k2 = ByteArray(32) - - val nonceChaCha = nonce.drop(16).toByteArray() - assert(nonceChaCha.size == 8) - - libSodium.crypto_core_hchacha20(k2, nonce, key, null) - val resultCode = - libSodium.crypto_stream_chacha20_xor_ic( - cipher, - messageBytes, - messageBytes.size.toLong(), - nonceChaCha, - 0, - k2, - ) - - return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message") - } -} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Log.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.android.kt similarity index 81% rename from quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Log.android.kt rename to quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.android.kt index c91c857b4..6f9bf5ac6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Log.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.android.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.utils -actual object Log { +actual object PlatformLog { actual fun w( tag: String, message: String, @@ -48,14 +48,24 @@ actual object Log { actual fun d( tag: String, message: String, + throwable: Throwable?, ) { - android.util.Log.d(tag, message) + if (throwable != null) { + android.util.Log.d(tag, message, throwable) + } else { + android.util.Log.d(tag, message) + } } actual fun i( tag: String, message: String, + throwable: Throwable?, ) { - android.util.Log.i(tag, message) + if (throwable != null) { + android.util.Log.i(tag, message, throwable) + } else { + android.util.Log.i(tag, message) + } } } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.apple.kt diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.apple.kt new file mode 100644 index 000000000..79f18d8e7 --- /dev/null +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.apple.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.zlib.Z_DEFAULT_COMPRESSION +import platform.zlib.Z_DEFAULT_STRATEGY +import platform.zlib.Z_DEFLATED +import platform.zlib.Z_FINISH +import platform.zlib.Z_NO_FLUSH +import platform.zlib.Z_OK +import platform.zlib.Z_STREAM_END +import platform.zlib.deflate +import platform.zlib.deflateBound +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2 +import platform.zlib.inflate +import platform.zlib.inflateEnd +import platform.zlib.inflateInit2 +import platform.zlib.z_stream + +@OptIn(ExperimentalForeignApi::class) +actual object Deflate { + actual fun compress(input: ByteArray): ByteArray = + memScoped { + val stream = alloc() + + // windowBits = -15 → raw DEFLATE (no gzip/zlib header) + deflateInit2( + stream.ptr, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + -15, + 8, + Z_DEFAULT_STRATEGY, + ).let { check(it == Z_OK) { "deflateInit2 failed: $it" } } + + val maxSize = deflateBound(stream.ptr, input.size.toULong()).toInt() + val output = ByteArray(maxSize) + + val written = + input.usePinned { pinIn -> + output.usePinned { pinOut -> + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = input.size.toUInt() + + if (output.isNotEmpty()) { + stream.next_out = pinOut.addressOf(0).reinterpret() + } + stream.avail_out = maxSize.toUInt() + + deflate(stream.ptr, Z_FINISH) + .let { check(it == Z_STREAM_END) { "deflate failed: $it" } } + + maxSize - stream.avail_out.toInt() + } + } + + deflateEnd(stream.ptr) + output.copyOf(written) + } + + actual fun decompress(input: ByteArray): ByteArray { + if (input.isEmpty()) return ByteArray(0) + + val chunks = ArrayList() + val chunkSize = maxOf(input.size * 4, 4096) + + memScoped { + val stream = alloc() + + // windowBits = -15 → raw DEFLATE + inflateInit2(stream.ptr, -15) + .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } + + input.usePinned { pinIn -> + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = input.size.toUInt() + + var status: Int = Z_OK + do { + val chunk = ByteArray(chunkSize) + chunk.usePinned { pinOut -> + stream.next_out = pinOut.addressOf(0).reinterpret() + stream.avail_out = chunkSize.toUInt() + status = inflate(stream.ptr, Z_NO_FLUSH) + val produced = chunkSize - stream.avail_out.toInt() + if (produced > 0) chunks.add(chunk.copyOf(produced)) + } + } while (status == Z_OK) + + check(status == Z_STREAM_END) { "inflate failed: $status" } + } + + inflateEnd(stream.ptr) + } + + val totalSize = chunks.sumOf { it.size } + val result = ByteArray(totalSize) + var pos = 0 + chunks.forEach { chunk -> + chunk.copyInto(result, pos) + pos += chunk.size + } + return result + } +} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/GZip.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/GZip.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Log.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.apple.kt similarity index 74% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Log.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.apple.kt index 727cdfc0b..1c53c5373 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Log.ios.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.apple.kt @@ -22,42 +22,41 @@ package com.vitorpamplona.quartz.utils import platform.Foundation.NSLog -actual object Log { - actual fun w( +actual object PlatformLog { + private fun log( + level: String, tag: String, message: String, throwable: Throwable?, ) { if (throwable != null) { - NSLog("WARN: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}") + NSLog("$level: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}") } else { - NSLog("WARN: [$tag] $message") + NSLog("$level: [$tag] $message") } } + actual fun w( + tag: String, + message: String, + throwable: Throwable?, + ) = log("WARN", tag, message, throwable) + actual fun e( tag: String, message: String, throwable: Throwable?, - ) { - if (throwable != null) { - NSLog("ERROR: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}") - } else { - NSLog("ERROR: [$tag] $message") - } - } + ) = log("ERROR", tag, message, throwable) actual fun d( tag: String, message: String, - ) { - NSLog("DEBUG: [$tag] $message") - } + throwable: Throwable?, + ) = log("DEBUG", tag, message, throwable) actual fun i( tag: String, message: String, - ) { - NSLog("INFO: [$tag] $message") - } + throwable: Throwable?, + ) = log("INFO", tag, message, throwable) } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt similarity index 99% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt index bcec6b77b..9d29a6e2b 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.ios.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt @@ -23,8 +23,6 @@ package com.vitorpamplona.quartz.utils.cache import io.github.charlietap.cachemap.CacheMap import io.github.charlietap.cachemap.cacheMapOf import kotlinx.coroutines.runBlocking -import kotlin.collections.plus -import kotlin.collections.set // An implementation of a Threadsafe map, using CacheMap. // Investigating a Swift-based alternative(for now) @@ -56,7 +54,7 @@ actual class LargeCache : ICacheOperations { actual fun getOrCreate( key: K, - builder: (K) -> V, + builder: (key: K) -> V, ): V { val value = concurrentMap.get(key) @@ -71,7 +69,7 @@ actual class LargeCache : ICacheOperations { actual fun createIfAbsent( key: K, - builder: (K) -> V, + builder: (key: K) -> V, ): Boolean = runBlocking { val value = concurrentMap.get(key) diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt 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 index 9e707b75b..820298930 100644 --- 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 @@ -22,7 +22,6 @@ 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 @@ -32,17 +31,14 @@ 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( @@ -54,8 +50,7 @@ class AttestationEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider, - PubKeyHintProvider { + AddressHintProvider { override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) @@ -64,12 +59,6 @@ class AttestationEvent( 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() @@ -94,10 +83,6 @@ class AttestationEvent( 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" @@ -106,7 +91,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -117,7 +101,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) about(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } @@ -129,7 +112,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -140,7 +122,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) aboutReplaceable(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } @@ -152,7 +133,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -163,7 +143,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) aboutAddressable(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } 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 index b64090b6f..3ef5d1a5b 100644 --- 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 @@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque 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 @@ -36,8 +34,6 @@ 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)) 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 index 2b545818e..8e71b066b 100644 --- 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 @@ -24,11 +24,8 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque 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) 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 index 424c698db..853f84c7f 100644 --- 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 @@ -26,10 +26,9 @@ import com.vitorpamplona.quartz.utils.ensure enum class AttestationStatus( val code: String, ) { - ACCEPTED("accepted"), - REJECTED("rejected"), VERIFYING("verifying"), - VERIFIED("verified"), + VALID("valid"), + INVALID("invalid"), REVOKED("revoked"), } @@ -45,10 +44,9 @@ class StatusTag { 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.VALID.code -> AttestationStatus.VALID + AttestationStatus.INVALID.code -> AttestationStatus.INVALID AttestationStatus.REVOKED.code -> AttestationStatus.REVOKED else -> null } 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 index 49bd5109f..879bcf3c7 100644 --- 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 @@ -40,7 +40,7 @@ class AttestorProficiencyEvent( ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun kinds() = tags.kinds() - fun description() = tags.description() + fun description() = content.ifBlank { null } companion object { const val KIND = 11871 @@ -51,10 +51,9 @@ class AttestorProficiencyEvent( description: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, description ?: "", 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 index fc617bb51..652450801 100644 --- 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 @@ -20,11 +20,8 @@ */ 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 index a6dfab465..e9e9fe1b3 100644 --- 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 @@ -20,10 +20,7 @@ */ 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 index 704d85680..2a3a4bbf5 100644 --- 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 @@ -41,7 +41,7 @@ class AttestorRecommendationEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun kinds() = tags.kinds() - fun description() = tags.description() + fun description() = content.ifBlank { null } companion object { const val KIND = 31873 @@ -53,11 +53,10 @@ class AttestorRecommendationEvent( description: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, description ?: "", 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 index 23dfbc7e7..e1dfcca6b 100644 --- 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 @@ -20,11 +20,8 @@ */ 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 index d784f3d9e..d04e04bd8 100644 --- 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 @@ -20,10 +20,7 @@ */ 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/request/AttestationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt index f5c646a73..0a1328c15 100644 --- 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 @@ -21,8 +21,6 @@ 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 @@ -41,7 +39,6 @@ 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( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt index 0c90339f6..64ef18092 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt @@ -38,6 +38,7 @@ class GalleryListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Suppress("DEPRECATION") companion object { const val KIND = 10011 const val ALT = "Profile Gallery" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt index 1f9029cff..ec77fae66 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt @@ -46,7 +46,7 @@ class AddressSerializer { if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) { if (parts[0].length > 5) { // invalid kind - Log.w("AddressableId", "Error parsing. invalid kind $addressId") + Log.w("AddressableId") { "Error parsing. invalid kind $addressId" } null } else { Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") @@ -57,11 +57,11 @@ class AddressSerializer { if (addr is NAddress) { addr.address() } else { - Log.w("AddressableId", "Error parsing. naddr1 seems invalid: $addressId") + Log.w("AddressableId") { "Error parsing. naddr1 seems invalid: $addressId" } null } } else { - Log.w("AddressableId", "Error parsing. Not a valid address: $addressId") + Log.w("AddressableId") { "Error parsing. Not a valid address: $addressId" } null } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt index 4d3eba299..34d245752 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -27,6 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -78,6 +81,21 @@ object CommandKSerializer : KSerializer { add(FilterKSerializer.serializeToElement(filter)) } } + + is NegOpenCmd -> { + add(JsonPrimitive(value.subId)) + add(FilterKSerializer.serializeToElement(value.filter)) + add(JsonPrimitive(value.initialMessage)) + } + + is NegMsgCmd -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.message)) + } + + is NegCloseCmd -> { + add(JsonPrimitive(value.subId)) + } } } jsonEncoder.encodeJsonElement(element) @@ -119,6 +137,27 @@ object CommandKSerializer : KSerializer { AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) } + NegOpenCmd.LABEL -> { + NegOpenCmd( + subId = array[1].jsonPrimitive.content, + filter = FilterKSerializer.deserializeFromElement(array[2].jsonObject), + initialMessage = array[3].jsonPrimitive.content, + ) + } + + NegMsgCmd.LABEL -> { + NegMsgCmd( + subId = array[1].jsonPrimitive.content, + message = array[2].jsonPrimitive.content, + ) + } + + NegCloseCmd.LABEL -> { + NegCloseCmd( + subId = array[1].jsonPrimitive.content, + ) + } + else -> { throw IllegalArgumentException("Message $type is not supported") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt index ed0ff95ae..27cc372c3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.kotlinSerialization import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip45Count.HyperLogLog import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -57,6 +58,7 @@ object CountResultKSerializer : KSerializer { put("count", value.count) // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate put("pubkey", value.approximate) + value.hll?.let { put("hll", HyperLogLog.encode(it)) } } override fun deserialize(decoder: Decoder): CountResult { @@ -68,5 +70,6 @@ object CountResultKSerializer : KSerializer { CountResult( count = jsonObject["count"]!!.jsonPrimitive.int, approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false, + hll = jsonObject["hll"]?.jsonPrimitive?.content?.let { HyperLogLog.decode(it) }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 34ee1b491..4edf4ee4f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -94,6 +96,16 @@ object MessageKSerializer : KSerializer { is EoseMessage -> { add(JsonPrimitive(value.subId)) } + + is NegMsgMessage -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.message)) + } + + is NegErrMessage -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.reason)) + } } } jsonEncoder.encodeJsonElement(element) @@ -148,6 +160,20 @@ object MessageKSerializer : KSerializer { CountMessage(queryId, result) } + NegMsgMessage.LABEL -> { + NegMsgMessage( + subId = array[1].jsonPrimitive.content, + message = array[2].jsonPrimitive.content, + ) + } + + NegErrMessage.LABEL -> { + NegErrMessage( + subId = array[1].jsonPrimitive.content, + reason = if (array.size > 2) array[2].jsonPrimitive.content else "", + ) + } + else -> { throw IllegalArgumentException("Message $type is not supported") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index eaae4027d..d7084aad9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -71,7 +71,7 @@ class MetadataEvent( Json.parseToJsonElement(content) as JsonObject } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("MetadataEvent", "Content Parse Error: ${toNostrUri()} ${e.message}") + Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } null } @@ -80,7 +80,7 @@ class MetadataEvent( JsonMapper.fromJson(content) } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("MetadataEvent", "Content Parse Error: ${toNostrUri()} ${e.message}") + Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 639e3fb45..71362b9d7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -28,6 +28,13 @@ import com.vitorpamplona.quartz.utils.startsWithAny import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +class Birthday { + var year: Int? = null + var month: Int? = null + var day: Int? = null +} + @Stable @Serializable class UserMetadata { @@ -41,6 +48,7 @@ class UserMetadata { var about: String? = null var bot: Boolean? = null var pronouns: String? = null + var birthday: Birthday? = null var nip05: String? = null var domain: String? = null var lud06: String? = null 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 6b738aec6..f7b111950 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 @@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -52,29 +52,29 @@ interface INostrClient : AutoCloseable { * This is called every time the relay connects * and when auth is successful */ - fun renewFilters(relay: IRelayClient) + fun syncFilters(relay: IRelayClient) - fun openReqSubscription( + fun subscribe( subId: String = newSubId(), filters: Map>, - listener: IRequestListener? = null, + listener: SubscriptionListener? = null, ) - fun queryCount( + fun count( subId: String = newSubId(), filters: Map>, ) - fun close(subId: String) + fun unsubscribe(subId: String) - fun send( + fun publish( event: Event, relayList: Set, ) - fun subscribe(listener: IRelayClientListener) + fun addConnectionListener(listener: RelayConnectionListener) - fun unsubscribe(listener: IRelayClientListener) + fun removeConnectionListener(listener: RelayConnectionListener) fun getReqFiltersOrNull(subId: String): Map>? @@ -103,29 +103,29 @@ class EmptyNostrClient : INostrClient { override fun isActive() = false - override fun renewFilters(relay: IRelayClient) { } + override fun syncFilters(relay: IRelayClient) { } - override fun openReqSubscription( + override fun subscribe( subId: String, filters: Map>, - listener: IRequestListener?, + listener: SubscriptionListener?, ) { } - override fun queryCount( + override fun count( subId: String, filters: Map>, ) { } - override fun close(subId: String) { } + override fun unsubscribe(subId: String) { } - override fun send( + override fun publish( event: Event, relayList: Set, ) { } - override fun subscribe(listener: IRelayClientListener) {} + override fun addConnectionListener(listener: RelayConnectionListener) {} - override fun unsubscribe(listener: IRelayClientListener) {} + override fun removeConnectionListener(listener: RelayConnectionListener) {} override fun getReqFiltersOrNull(subId: String): Map>? = null 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 a314a0dd8..91b1952d9 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 @@ -22,12 +22,12 @@ package com.vitorpamplona.quartz.nip01Core.relay.client import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolCounts import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutbox import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolRequests import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd @@ -52,7 +52,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** - * The NostrClient manages Nostr relay operations, subscriptions, and event delivery. It maintains: + * The INostrClient manages Nostr relay operations, subscriptions, and event delivery. It maintains: * - A RelayPool for managing connections to a collection of Nostr relays * - Active subscriptions tracking through PoolSubscriptionRepository * - An event outbox for managing unsent events and retry logic @@ -80,7 +80,7 @@ class NostrClient( private val websocketBuilder: WebsocketBuilder, private val parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ) : INostrClient, - IRelayClientListener, + RelayConnectionListener, AutoCloseable { private val relayPool: RelayPool = RelayPool(websocketBuilder, this) @@ -91,7 +91,7 @@ class NostrClient( private val activeCounts: PoolCounts = PoolCounts() private val eventOutbox: PoolEventOutbox = PoolEventOutbox() - private var listeners = setOf() + private var listeners = setOf() // controls the state of the client in such a way that if it is active // new filters will be sent to the relays and a potential reconnect can @@ -164,10 +164,10 @@ class NostrClient( refreshConnection.tryEmit(Reconnect(onlyIfChanged, ignoreRetryDelays)) } - override fun openReqSubscription( + override fun subscribe( subId: String, filters: Map>, - listener: IRequestListener?, + listener: SubscriptionListener?, ) { val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener) @@ -188,7 +188,7 @@ class NostrClient( } } - override fun queryCount( + override fun count( subId: String, filters: Map>, ) { @@ -208,7 +208,7 @@ class NostrClient( } } - override fun send( + override fun publish( event: Event, relayList: Set, ) { @@ -222,7 +222,7 @@ class NostrClient( } } - override fun close(subId: String) { + override fun unsubscribe(subId: String) { val relaysToUpdateReqs = activeRequests.remove(subId) val relaysToUpdateCounts = activeCounts.remove(subId) @@ -232,7 +232,7 @@ class NostrClient( } } - override fun renewFilters(relay: IRelayClient) { + override fun syncFilters(relay: IRelayClient) { if (isActive) { scope.launch { activeRequests.syncState(relay.url, relay::sendOrConnectAndSync) @@ -259,7 +259,7 @@ class NostrClient( pingMillis: Int, compressed: Boolean, ) { - renewFilters(relay) + syncFilters(relay) listeners.forEach { it.onConnected(relay, pingMillis, compressed) } } @@ -308,13 +308,13 @@ class NostrClient( listeners.forEach { it.onCannotConnect(relay, errorMessage) } } - override fun subscribe(listener: IRelayClientListener) { + override fun addConnectionListener(listener: RelayConnectionListener) { listeners = listeners.plus(listener) } - fun isSubscribed(listener: IRelayClientListener): Boolean = listeners.contains(listener) + fun hasConnectionListener(listener: RelayConnectionListener): Boolean = listeners.contains(listener) - override fun unsubscribe(listener: IRelayClientListener) { + override fun removeConnectionListener(listener: RelayConnectionListener) { listeners = listeners.minus(listener) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt index 1853525ee..7c97ec321 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt @@ -22,21 +22,21 @@ 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.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.utils.Log /** - * Listens to NostrClient's onEvent messages for caching purposes. + * Listens to INostrClient's onEvent messages for caching purposes. */ class EventCollector( val client: INostrClient, val onEvent: (event: Event, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -50,12 +50,12 @@ class EventCollector( init { Log.d("EventCollector", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("EventCollector", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } 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 15dd206ff..9222ba74d 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 @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip45Count.HyperLogLog import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull @@ -42,7 +43,7 @@ import kotlinx.coroutines.withTimeoutOrNull * @param timeoutMs How long to wait for a response (default 15 s). * @return The [CountResult], or `null` on timeout. */ -suspend fun INostrClient.queryCountSuspend( +suspend fun INostrClient.count( relay: NormalizedRelayUrl, filter: Filter, timeoutMs: Long = 15_000, @@ -51,7 +52,7 @@ suspend fun INostrClient.queryCountSuspend( val resultChannel = Channel(UNLIMITED) val listener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -63,18 +64,18 @@ suspend fun INostrClient.queryCountSuspend( } } - subscribe(listener) + addConnectionListener(listener) val result = try { - queryCount(subId = subId, filters = mapOf(relay to listOf(filter))) + count(subId = subId, filters = mapOf(relay to listOf(filter))) withTimeoutOrNull(timeoutMs) { resultChannel.receive() } } finally { - close(subId) - unsubscribe(listener) + unsubscribe(subId) + removeConnectionListener(listener) } resultChannel.close() @@ -91,7 +92,7 @@ suspend fun INostrClient.queryCountSuspend( * @param timeoutMs How long to wait for all responses (default 15 s). * @return Map of relay -> [CountResult] for every relay that responded in time. */ -suspend fun INostrClient.queryCountSuspend( +suspend fun INostrClient.count( filters: Map>, timeoutMs: Long = 15_000, ): Map { @@ -101,7 +102,7 @@ suspend fun INostrClient.queryCountSuspend( val resultChannel = Channel>(UNLIMITED) val listener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -114,12 +115,12 @@ suspend fun INostrClient.queryCountSuspend( } } - subscribe(listener) + addConnectionListener(listener) filters.forEach { (relay, filterList) -> val subId = newSubId() subIdToRelay[subId] = relay - queryCount(subId = subId, filters = mapOf(relay to filterList)) + count(subId = subId, filters = mapOf(relay to filterList)) } val results = mutableMapOf() @@ -131,9 +132,56 @@ suspend fun INostrClient.queryCountSuspend( } } - subIdToRelay.keys.forEach { close(it) } - unsubscribe(listener) + subIdToRelay.keys.forEach { unsubscribe(it) } + removeConnectionListener(listener) resultChannel.close() return results } + +/** + * Queries multiple relays for a COUNT and merges the HyperLogLog + * registers from all responses to produce a single merged estimate. + * + * If any relay returns HLL data, the results are merged by taking + * the maximum register value across all relays, and the cardinality + * is re-estimated from the merged registers. + * + * If no relay returns HLL data, falls back to the maximum count + * reported by any relay. + * + * @param relays List of relays to query. + * @param filter The filter to count against. + * @param timeoutMs How long to wait for all responses (default 15 s). + * @return A merged [CountResult], or `null` if no relay responded. + */ +suspend fun INostrClient.countMerged( + relays: List, + filter: Filter, + timeoutMs: Long = 15_000, +): CountResult? { + if (relays.isEmpty()) return null + + val results = + count( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = timeoutMs, + ) + + if (results.isEmpty()) return null + + val hlls = results.values.mapNotNull { it.hll } + + return if (hlls.isNotEmpty()) { + val merged = HyperLogLog.merge(hlls) + val estimate = HyperLogLog.estimate(merged) + CountResult( + count = estimate.toInt(), + approximate = true, + hll = merged, + ) + } else { + // No HLL data - use the maximum count from any relay + results.values.maxByOrNull { it.count } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllExt.kt new file mode 100644 index 000000000..01cc3a560 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllExt.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +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.withTimeoutOrNull + +suspend fun INostrClient.fetchAll( + relay: String, + filter: Filter, + timeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), timeoutMs) + +suspend fun INostrClient.fetchAll( + relay: String, + filters: List, + timeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs) + +suspend fun INostrClient.fetchAll( + subscriptionId: String = newSubId(), + relay: String, + filters: List, + timeoutMs: Long = 30_000L, +) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs) + +suspend fun INostrClient.fetchAll( + relay: NormalizedRelayUrl, + filter: Filter, + timeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), timeoutMs) + +suspend fun INostrClient.fetchAll( + relay: NormalizedRelayUrl, + filters: List, + timeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(relay to filters), timeoutMs) + +suspend fun INostrClient.fetchAll( + subscriptionId: String = newSubId(), + relay: NormalizedRelayUrl, + filters: List, + timeoutMs: Long = 30_000L, +) = fetchAll(subscriptionId, mapOf(relay to filters), timeoutMs) + +suspend fun INostrClient.fetchAll( + subscriptionId: String = newSubId(), + filters: Map>, + timeoutMs: Long = 30_000L, +): List { + val doneChannel = Channel(Channel.UNLIMITED) + + val events = mutableListOf() + val seenIds = mutableSetOf() + + val remaining = filters.keys.toMutableSet() + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (seenIds.add(event.id)) { + events.add(event) + } + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + } + + try { + subscribe(subscriptionId, filters, listener) + + withTimeoutOrNull(timeoutMs) { + while (remaining.isNotEmpty()) { + val finished = doneChannel.receive() + remaining.remove(finished) + } + } + } finally { + unsubscribe(subscriptionId) + doneChannel.close() + } + + return events.sortedWith(DefaultFeedOrderEvent) +} + +val DefaultFeedOrderEvent: Comparator = + compareByDescending { it.createdAt }.thenBy { it.id } 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/NostrClientFetchAllPagesExt.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientReqBypassingRelayLimitsExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index a4a20ab3f..8bacb7b38 100644 --- 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/NostrClientFetchAllPagesExt.kt @@ -22,7 +22,7 @@ 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.reqs.SubscriptionListener 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 @@ -51,7 +51,7 @@ import kotlin.math.min * @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( +suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, filters: List, timeoutMs: Long = 30_000L, @@ -95,7 +95,7 @@ suspend fun INostrClient.reqBypassingRelayLimits( try { val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -144,16 +144,16 @@ suspend fun INostrClient.reqBypassingRelayLimits( } } - openReqSubscription(subId, mapOf(relay to remainingFilters), listener) + subscribe(subId, mapOf(relay to remainingFilters), listener) withTimeoutOrNull(timeoutMs) { doneChannel.receive() } - close(subId) + unsubscribe(subId) doneChannel.close() } finally { - close(subId) + unsubscribe(subId) doneChannel.close() } @@ -168,14 +168,14 @@ suspend fun INostrClient.reqBypassingRelayLimits( return totalEvents } -suspend fun INostrClient.reqBypassingRelayLimits( +suspend fun INostrClient.fetchAllPages( relay: String, filters: List, timeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, onEvent: (Event) -> Unit, ): Int = - reqBypassingRelayLimits( + fetchAllPages( relay = RelayUrlNormalizer.normalize(relay), filters = filters, timeoutMs = timeoutMs, 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/NostrClientFetchFirstExt.kt similarity index 77% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index 68ee0f621..16aa65034 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/NostrClientFetchFirstExt.kt @@ -22,7 +22,7 @@ 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.reqs.SubscriptionListener 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 @@ -31,46 +31,46 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull -suspend fun INostrClient.downloadFirstEvent( +suspend fun INostrClient.fetchFirst( relay: String, filter: Filter, -) = downloadFirstEvent(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter))) +) = fetchFirst(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter))) -suspend fun INostrClient.downloadFirstEvent( +suspend fun INostrClient.fetchFirst( relay: String, filters: List, -) = downloadFirstEvent(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters)) +) = fetchFirst(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters)) -suspend fun INostrClient.downloadFirstEvent( +suspend fun INostrClient.fetchFirst( subscriptionId: String = newSubId(), relay: String, filters: List, -) = downloadFirstEvent(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters)) +) = fetchFirst(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters)) -suspend fun INostrClient.downloadFirstEvent( +suspend fun INostrClient.fetchFirst( relay: NormalizedRelayUrl, filter: Filter, -) = downloadFirstEvent(newSubId(), mapOf(relay to listOf(filter))) +) = fetchFirst(newSubId(), mapOf(relay to listOf(filter))) -suspend fun INostrClient.downloadFirstEvent( +suspend fun INostrClient.fetchFirst( relay: NormalizedRelayUrl, filters: List, -) = downloadFirstEvent(newSubId(), mapOf(relay to filters)) +) = fetchFirst(newSubId(), mapOf(relay to filters)) -suspend fun INostrClient.downloadFirstEvent( +suspend fun INostrClient.fetchFirst( subscriptionId: String = newSubId(), relay: NormalizedRelayUrl, filters: List, -) = downloadFirstEvent(subscriptionId, mapOf(relay to filters)) +) = fetchFirst(subscriptionId, mapOf(relay to filters)) -suspend fun INostrClient.downloadFirstEvent( +suspend fun INostrClient.fetchFirst( subscriptionId: String = newSubId(), filters: Map>, ): Event? { val resultChannel = Channel(UNLIMITED) val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -106,13 +106,13 @@ suspend fun INostrClient.downloadFirstEvent( val result = try { - openReqSubscription(subscriptionId, filters, listener) + subscribe(subscriptionId, filters, listener) withTimeoutOrNull(30000) { resultChannel.receive() } } finally { - close(subscriptionId) + unsubscribe(subscriptionId) } resultChannel.close() 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/NostrClientPublishExt.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt index 9815f8d42..5904626a7 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/NostrClientPublishExt.kt @@ -22,7 +22,7 @@ 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.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -41,42 +41,42 @@ class Result( ) @OptIn(DelicateCoroutinesApi::class) -suspend fun INostrClient.sendAndWaitForResponse( +suspend fun INostrClient.publishAndConfirm( event: Event, relayList: Set, timeoutInSeconds: Long = 15, -): Boolean = sendAndWaitForResponseDetailed(event, relayList, timeoutInSeconds).any { it.value } +): Boolean = publishAndConfirmDetailed(event, relayList, timeoutInSeconds).any { it.value } /** * Sends an event to the given relays and waits for OK responses. * Returns per-relay results: relay URL -> accepted (true/false). */ @OptIn(DelicateCoroutinesApi::class) -suspend fun INostrClient.sendAndWaitForResponseDetailed( +suspend fun INostrClient.publishAndConfirmDetailed( event: Event, relayList: Set, timeoutInSeconds: Long = 15, ): Map { val resultChannel = Channel(UNLIMITED) - Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses") + Log.d("publishAndConfirm") { "Waiting for ${relayList.size} responses" } val subscription = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onCannotConnect( relay: IRelayClient, errorMessage: String, ) { if (relay.url in relayList) { resultChannel.trySend(Result(relay.url, false)) - Log.d("sendAndWaitForResponse", "Error from relay ${relay.url}: $errorMessage") + Log.d("publishAndConfirm") { "Error from relay ${relay.url}: $errorMessage" } } } override fun onDisconnected(relay: IRelayClient) { if (relay.url in relayList) { resultChannel.trySend(Result(relay.url, false)) - Log.d("sendAndWaitForResponse", "Disconnected from relay ${relay.url}") + Log.d("publishAndConfirm") { "Disconnected from relay ${relay.url}" } } } @@ -91,7 +91,7 @@ suspend fun INostrClient.sendAndWaitForResponseDetailed( is OkMessage -> { if (msg.eventId == event.id) { resultChannel.trySend(Result(relay.url, msg.success)) - Log.d("sendAndWaitForResponse", "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}") + Log.d("publishAndConfirm") { "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}" } } } } @@ -100,7 +100,7 @@ suspend fun INostrClient.sendAndWaitForResponseDetailed( val receivedResults = try { - subscribe(subscription) + addConnectionListener(subscription) // subscribe before sending the result. val resultSubscription = @@ -123,20 +123,20 @@ suspend fun INostrClient.sendAndWaitForResponseDetailed( receivedResults } - send(event, relayList) + publish(event, relayList) result } resultSubscription.await() } finally { - unsubscribe(subscription) + removeConnectionListener(subscription) } // Clean up the channel resultChannel.close() - Log.d("sendAndWaitForResponse", "Finished with ${receivedResults.size} results") + Log.d("publishAndConfirm") { "Finished with ${receivedResults.size} results" } return receivedResults } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index 1b73dcd78..5014eada6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -22,21 +22,21 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.utils.Log /** - * Listens to NostrClient's onEvent messages for caching purposes. + * Listens to INostrClient's onEvent messages for caching purposes. */ class RelayInsertConfirmationCollector( val client: INostrClient, val onRelayReceived: (eventId: HexKey, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -50,12 +50,12 @@ class RelayInsertConfirmationCollector( init { Log.d("RelayInsertConfirmationCollector", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("RelayInsertConfirmationCollector", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index ceb4b36de..ba3c1e5a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.Log /** - * Listens to NostrClient's onNotify messages from the relay + * Listens to INostrClient's onNotify messages from the relay */ class RelayLogger( val client: INostrClient, @@ -48,7 +48,7 @@ class RelayLogger( fun logTag(url: NormalizedRelayUrl) = "Relay ${url.displayUrl()}" private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -57,14 +57,14 @@ class RelayLogger( val logTag = logTag(relay.url) when (msg) { - is EventMessage -> if (debugReceiving) Log.d(logTag, "Received: $msgStr") - is EoseMessage -> if (debugReceiving) Log.d(logTag, "EOSE: ${msg.subId}") - is NoticeMessage -> Log.w(logTag, "Notice: ${msg.message}") - is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") - is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}") - is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}") - is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate}") - is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}") + is EventMessage -> if (debugReceiving) Log.d(logTag) { "Received: $msgStr" } + is EoseMessage -> if (debugReceiving) Log.d(logTag) { "EOSE: ${msg.subId}" } + is NoticeMessage -> Log.w(logTag) { "Notice: ${msg.message}" } + is OkMessage -> if (debugReceiving) Log.d(logTag) { "OK: ${msg.eventId} ${msg.success} ${msg.message}" } + is AuthMessage -> if (debugReceiving) Log.d(logTag) { "Auth: ${msg.challenge}" } + is NotifyMessage -> if (debugReceiving) Log.d(logTag) { "Notify: ${msg.message}" } + is CountMessage -> if (debugReceiving) Log.d(logTag) { "Count: ${msg.result.count} approx: ${msg.result.approximate} hll: ${msg.result.hll != null}" } + is ClosedMessage -> Log.w(logTag) { "Closed: ${msg.subId} ${msg.message}" } } } @@ -76,7 +76,7 @@ class RelayLogger( ) { if (success) { if (debugSending) { - Log.d(logTag(relay.url), "Sent (${cmdStr.length} chars): $cmdStr") + Log.d(logTag(relay.url)) { "Sent (${cmdStr.length} chars): $cmdStr" } } } else { Log.e(logTag(relay.url), "Failure sending (${cmdStr.length} chars): $cmdStr") @@ -92,7 +92,7 @@ class RelayLogger( pingMillis: Int, compressed: Boolean, ) { - Log.d(logTag(relay.url), "OnOpen (ping: ${pingMillis}ms${if (compressed) ", using compression" else ""})") + Log.d(logTag(relay.url)) { "OnOpen (ping: ${pingMillis}ms${if (compressed) ", using compression" else ""})" } } override fun onDisconnected(relay: IRelayClient) { @@ -110,12 +110,12 @@ class RelayLogger( init { Log.d("RelayLogger", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("RelayLogger", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt index 7deb24690..65f2c6aae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt @@ -21,14 +21,14 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage import com.vitorpamplona.quartz.utils.Log /** - * Listens to NostrClient's onNotify messages from the relay + * Listens to INostrClient's onNotify messages from the relay */ class RelayNotifier( val client: INostrClient, @@ -39,7 +39,7 @@ class RelayNotifier( } private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -54,12 +54,12 @@ class RelayNotifier( init { Log.d(TAG, "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d(TAG, "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } 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 abda66eb1..c7981bdf7 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 @@ -21,13 +21,13 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log /** - * Listens to NostrClient's onNotify messages from the relay + * Listens to INostrClient's onNotify messages from the relay */ class RelayOfflineTracker( val client: INostrClient, @@ -39,7 +39,7 @@ class RelayOfflineTracker( var cannotConnectRelays = setOf() private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onConnected( relay: IRelayClient, pingMillis: Int, @@ -58,12 +58,12 @@ class RelayOfflineTracker( init { Log.d(TAG, "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d(TAG, "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 226f0ce8d..cce594855 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.auth import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -53,7 +53,7 @@ class RelayAuthenticator( private val authStatus = mutableMapOf() private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -95,7 +95,7 @@ class RelayAuthenticator( ) { // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. if (authStatus[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) { - client.renewFilters(relay) + client.syncFilters(relay) } } @@ -103,12 +103,12 @@ class RelayAuthenticator( init { Log.d("RelayAuthenticator", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("RelayAuthenticator", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt index c8b7388c6..dba6888dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.counts import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage @@ -40,7 +40,7 @@ class RelayActiveCountStates( fun subGetOrCreate(relay: NormalizedRelayUrl): CountQueryState = queryStates[relay] ?: CountQueryState().also { queryStates.put(relay, it) } private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onConnecting(relay: IRelayClient) { queryStates.put(relay.url, CountQueryState()) } @@ -75,12 +75,12 @@ class RelayActiveCountStates( init { Log.d("RelaySubStateMachine", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("RelaySubStateMachine", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectConnectionListener.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectConnectionListener.kt index 543811874..5d7fba5ff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectConnectionListener.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command -open class RedirectRelayClientListener( - val listener: IRelayClientListener, -) : IRelayClientListener { +open class RedirectConnectionListener( + val listener: RelayConnectionListener, +) : RelayConnectionListener { override fun onConnecting(relay: IRelayClient) { listener.onConnecting(relay) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt index 9910400b8..90d4c167b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command -interface IRelayClientListener { +interface RelayConnectionListener { fun onConnecting(relay: IRelayClient) {} /** @@ -72,4 +72,4 @@ interface IRelayClientListener { ) {} } -object EmptyClientListener : IRelayClientListener +object EmptyConnectionListener : RelayConnectionListener 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 49b0e2a16..afcace262 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 @@ -29,7 +29,6 @@ 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() 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 0ea0c49d7..fce701969 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 @@ -20,9 +20,9 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.pool -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.ReqSubStatus import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage @@ -51,7 +51,7 @@ class PoolRequests { * to what the app whants to do. */ private val desiredSubs = LargeCache>>() - private val desiredSubListeners = LargeCache() + private val desiredSubListeners = LargeCache() val desiredRelays = MutableStateFlow(setOf()) /** @@ -101,7 +101,7 @@ class PoolRequests { fun addOrUpdate( subId: String, filters: Map>, - listener: IRequestListener?, + listener: SubscriptionListener?, ): Set { // saves old relays val oldRelays = desiredSubs.get(subId)?.keys ?: emptySet() @@ -165,15 +165,15 @@ class PoolRequests { when (cmd) { is ReqCmd -> { subState(cmd.subId).onOpenReq(relay, cmd.filters) - desiredSubListeners.get(cmd.subId)?.onStartReq( + desiredSubListeners.get(cmd.subId)?.onSubscriptionStarted( relay = relay.url, forFilters = cmd.filters, ) } is CloseCmd -> { - subState(cmd.subId).onCloseReq(relay) - desiredSubListeners.get(cmd.subId)?.onCloseReq( + subState(cmd.subId).onSubscriptionClosed(relay) + desiredSubListeners.get(cmd.subId)?.onSubscriptionClosed( relay = relay.url, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt index c117d6f95..102101ec9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt @@ -36,7 +36,7 @@ fun List.groupByRelay(): Map> val result = mutableMapOf>() for (relayBasedFilter in this) { if (relayBasedFilter.filter.isEmpty()) { - Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}") + Log.e("FilterError") { "Ignoring empty filter for ${relayBasedFilter.relay}" } } else { result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index 6f2c4b03a..b60eda98a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.pool -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -56,8 +56,8 @@ import kotlinx.coroutines.flow.update */ class RelayPool( val websocketBuilder: WebsocketBuilder, - val listener: IRelayClientListener = EmptyClientListener, -) : IRelayClientListener { + val listener: RelayConnectionListener = EmptyConnectionListener, +) : RelayConnectionListener { private val relays = LargeCache() private val _connectedRelays = MutableStateFlow>(emptySet()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientDynamicReq.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/DynamicSubscription.kt similarity index 86% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientDynamicReq.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/DynamicSubscription.kt index dd77200a3..1821f4ad8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientDynamicReq.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/DynamicSubscription.kt @@ -26,12 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.RandomInstance -class NostrClientDynamicReq( +class DynamicSubscription( val client: INostrClient, val filter: () -> Map>, val onEvent: (event: Event) -> Unit = {}, -) : IRequestListener, - IOpenNostrRequest { +) : SubscriptionListener, + SubscriptionHandle { val subId = RandomInstance.randomChars(10) override fun onEvent( @@ -47,16 +47,16 @@ class NostrClientDynamicReq( * Creates or Updates the filter with relays. This method should be called * everytime the filter changes. */ - override fun updateFilter() = client.openReqSubscription(subId, filter(), this) + override fun refresh() = client.subscribe(subId, filter(), this) - override fun close() = client.close(subId) + override fun close() = client.unsubscribe(subId) init { - updateFilter() + refresh() } } -fun INostrClient.req( +fun INostrClient.subscribe( filters: () -> Map>, onEvent: (event: Event) -> Unit = {}, -): IOpenNostrRequest = NostrClientDynamicReq(this, filters, onEvent) +): SubscriptionHandle = DynamicSubscription(this, filters, onEvent) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqUntilEoseAsFlow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientFetchAsFlowExt.kt similarity index 86% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqUntilEoseAsFlow.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientFetchAsFlowExt.kt index 371c48d29..1cda8b7e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqUntilEoseAsFlow.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientFetchAsFlowExt.kt @@ -37,22 +37,22 @@ import kotlinx.coroutines.flow.callbackFlow * 3. Closes the flow (completes) when the relay sends EOSE, * cancelling the subscription automatically via [awaitClose]. */ -fun INostrClient.reqUntilEoseAsFlow( +fun INostrClient.fetchAsFlow( relay: String, filters: List, -) = reqUntilEoseAsFlow(RelayUrlNormalizer.normalize(relay), filters) +) = fetchAsFlow(RelayUrlNormalizer.normalize(relay), filters) -fun INostrClient.reqUntilEoseAsFlow( +fun INostrClient.fetchAsFlow( relay: String, filter: Filter, -) = reqUntilEoseAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter)) +) = fetchAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter)) -fun INostrClient.reqUntilEoseAsFlow( +fun INostrClient.fetchAsFlow( relay: NormalizedRelayUrl, filter: Filter, -) = reqUntilEoseAsFlow(relay, listOf(filter)) +) = fetchAsFlow(relay, listOf(filter)) -fun INostrClient.reqUntilEoseAsFlow( +fun INostrClient.fetchAsFlow( relay: NormalizedRelayUrl, filters: List, ): Flow> = @@ -62,7 +62,7 @@ fun INostrClient.reqUntilEoseAsFlow( var currentEvents = listOf() val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -84,9 +84,9 @@ fun INostrClient.reqUntilEoseAsFlow( } } - openReqSubscription(subId, mapOf(relay to filters), listener) + subscribe(subId, mapOf(relay to filters), listener) awaitClose { - close(subId) + unsubscribe(subId) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqAsStateFlow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientSubscribeAsFlowExt.kt similarity index 89% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqAsStateFlow.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientSubscribeAsFlowExt.kt index aacd7ec70..50b6af68a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqAsStateFlow.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientSubscribeAsFlowExt.kt @@ -42,22 +42,22 @@ import kotlinx.coroutines.flow.callbackFlow * - They will be ignored if they are already in the list. * - They will be added to the beginning of the list if they are new. */ -fun INostrClient.reqAsFlow( +fun INostrClient.subscribeAsFlow( relay: String, filters: List, -) = reqAsFlow(RelayUrlNormalizer.normalize(relay), filters) +) = subscribeAsFlow(RelayUrlNormalizer.normalize(relay), filters) -fun INostrClient.reqAsFlow( +fun INostrClient.subscribeAsFlow( relay: String, filter: Filter, -) = reqAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter)) +) = subscribeAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter)) -fun INostrClient.reqAsFlow( +fun INostrClient.subscribeAsFlow( relay: NormalizedRelayUrl, filter: Filter, -) = reqAsFlow(relay, listOf(filter)) +) = subscribeAsFlow(relay, listOf(filter)) -fun INostrClient.reqAsFlow( +fun INostrClient.subscribeAsFlow( relay: NormalizedRelayUrl, filters: List, ): Flow> = @@ -68,7 +68,7 @@ fun INostrClient.reqAsFlow( var currentEvents = listOf() val listener = - object : IRequestListener { + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -98,9 +98,9 @@ fun INostrClient.reqAsFlow( } } - openReqSubscription(subId, mapOf(relay to filters), listener) + subscribe(subId, mapOf(relay to filters), listener) awaitClose { - close(subId) + unsubscribe(subId) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt index f3c822491..a51d2f1df 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.reqs import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage @@ -41,7 +41,7 @@ class RelayActiveRequestStates( fun subGetOrCreate(relay: NormalizedRelayUrl): RequestSubscriptionState = subStates[relay] ?: RequestSubscriptionState().also { subStates.put(relay, it) } private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onConnecting(relay: IRelayClient) { subStates[relay.url] = RequestSubscriptionState() } @@ -66,7 +66,7 @@ class RelayActiveRequestStates( ) { when (cmd) { is ReqCmd -> subGetOrCreate(relay.url).onOpenReq(cmd.subId, cmd.filters) - is CloseCmd -> subGetOrCreate(relay.url).onCloseReq(cmd.subId) + is CloseCmd -> subGetOrCreate(relay.url).onSubscriptionClosed(cmd.subId) } } @@ -77,12 +77,12 @@ class RelayActiveRequestStates( init { Log.d("RelaySubStateMachine", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("RelaySubStateMachine", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt index 3cd75342f..f4c8bd8dd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt @@ -80,7 +80,7 @@ class RequestSubscriptionState { lastKnownFilterStates[reference] = filters } - fun onCloseReq(reference: T) { + fun onSubscriptionClosed(reference: T) { subStates[reference] = ReqSubStatus.CLOSED filterStates.remove(reference) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReq.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/StaticSubscription.kt similarity index 73% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReq.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/StaticSubscription.kt index f6e1371c6..1e87560ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReq.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/StaticSubscription.kt @@ -27,12 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.RandomInstance -class NostrClientStaticReq( +class StaticSubscription( val client: INostrClient, val filter: Map>, val onEvent: (event: Event) -> Unit = {}, -) : IRequestListener, - IOpenNostrRequest { +) : SubscriptionListener, + SubscriptionHandle { val subId = RandomInstance.randomChars(10) override fun onEvent( @@ -48,50 +48,50 @@ class NostrClientStaticReq( * Creates or Updates the filter with relays. This method should be called * everytime the filter changes. */ - override fun updateFilter() = client.openReqSubscription(subId, filter, this) + override fun refresh() = client.subscribe(subId, filter, this) - override fun close() = client.close(subId) + override fun close() = client.unsubscribe(subId) init { - updateFilter() + refresh() } } -fun INostrClient.req( +fun INostrClient.subscribe( relay: NormalizedRelayUrl, filters: List, onEvent: (event: Event) -> Unit = {}, -): IOpenNostrRequest = NostrClientStaticReq(this, mapOf(relay to filters), onEvent) +): SubscriptionHandle = StaticSubscription(this, mapOf(relay to filters), onEvent) -fun INostrClient.req( +fun INostrClient.subscribe( relay: NormalizedRelayUrl, filter: Filter, onEvent: (event: Event) -> Unit = {}, -): IOpenNostrRequest = NostrClientStaticReq(this, mapOf(relay to listOf(filter)), onEvent) +): SubscriptionHandle = StaticSubscription(this, mapOf(relay to listOf(filter)), onEvent) -fun INostrClient.req( +fun INostrClient.subscribe( relays: List, filters: List, onEvent: (event: Event) -> Unit = {}, -): IOpenNostrRequest = NostrClientStaticReq(this, relays.associateWith { filters }, onEvent) +): SubscriptionHandle = StaticSubscription(this, relays.associateWith { filters }, onEvent) -fun INostrClient.req( +fun INostrClient.subscribe( relays: List, filter: Filter, onEvent: (event: Event) -> Unit = {}, -): IOpenNostrRequest = NostrClientStaticReq(this, relays.associateWith { listOf(filter) }, onEvent) +): SubscriptionHandle = StaticSubscription(this, relays.associateWith { listOf(filter) }, onEvent) // ----------------------------------- // Helper methods with relay as string // ----------------------------------- -fun INostrClient.req( +fun INostrClient.subscribe( relay: String, filters: List, onEvent: (event: Event) -> Unit = {}, -): IOpenNostrRequest = NostrClientStaticReq(this, mapOf(RelayUrlNormalizer.normalize(relay) to filters), onEvent) +): SubscriptionHandle = StaticSubscription(this, mapOf(RelayUrlNormalizer.normalize(relay) to filters), onEvent) -fun INostrClient.req( +fun INostrClient.subscribe( relay: String, filter: Filter, onEvent: (event: Event) -> Unit = {}, -): IOpenNostrRequest = NostrClientStaticReq(this, mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), onEvent) +): SubscriptionHandle = StaticSubscription(this, mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), onEvent) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IOpenNostrRequest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionHandle.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IOpenNostrRequest.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionHandle.kt index c9eaea304..6aa55066f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IOpenNostrRequest.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionHandle.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.reqs -interface IOpenNostrRequest { - fun updateFilter() +interface SubscriptionHandle { + fun refresh() fun close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt index 4c88592f2..45f614237 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -interface IRequestListener { +interface SubscriptionListener { fun onEose( relay: NormalizedRelayUrl, forFilters: List?, @@ -49,10 +49,10 @@ interface IRequestListener { forFilters: List?, ) {} - fun onStartReq( + fun onSubscriptionStarted( relay: String, forFilters: List, ) {} - fun onCloseReq(relay: String) {} + fun onSubscriptionClosed(relay: String) {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt index 86d576f10..2da8b3ca6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt @@ -21,14 +21,14 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.utils.Log /** - * Listens to NostrClient's onNotify messages from the relay + * Listens to INostrClient's onNotify messages from the relay */ class RelayReqStats( val client: INostrClient, @@ -36,7 +36,7 @@ class RelayReqStats( private val stats = ReqStatsRepository() private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, @@ -51,17 +51,17 @@ class RelayReqStats( fun printStats() = stats.printCounter { subId, kind, counter -> - Log.d("RelaySubStats", "$subId, kind $kind: $counter") + Log.d("RelaySubStats") { "$subId, kind $kind: $counter" } } init { Log.d("RelaySubStats", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("RelaySubStats", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index c8a4373ff..e83256988 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_SECS import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command @@ -55,7 +55,7 @@ import kotlin.coroutines.cancellation.CancellationException open class BasicRelayClient( override val url: NormalizedRelayUrl, val socketBuilder: WebsocketBuilder, - val listener: IRelayClientListener, + val listener: RelayConnectionListener, ) : IRelayClient { companion object { // minimum wait time to reconnect: 1 second diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt index a5964708b..5063f0331 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.single.standalone import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -46,7 +46,7 @@ import com.vitorpamplona.quartz.utils.cache.LargeCache class StandaloneRelayClient( url: NormalizedRelayUrl, socketBuilder: WebsocketBuilder, - listener: IRelayClientListener = EmptyClientListener, + listener: RelayConnectionListener = EmptyConnectionListener, ) { private val outbox = LargeCache() private val reqs = LargeCache>() @@ -56,14 +56,14 @@ class StandaloneRelayClient( BasicRelayClient( url, socketBuilder, - object : RedirectRelayClientListener(listener) { + object : RedirectConnectionListener(listener) { override fun onConnected( relay: IRelayClient, pingMillis: Int, compressed: Boolean, ) { super.onConnected(relay, pingMillis, compressed) - renewFilters() + syncFilters() } override fun onIncomingMessage( @@ -83,7 +83,7 @@ class StandaloneRelayClient( }, ) - fun renewFilters() { + fun syncFilters() { outbox.forEach { id, event -> client.sendOrConnectAndSync(EventCmd(event)) } 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 835499361..b6548e1a2 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 @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -47,7 +47,7 @@ class RelayStats( fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen") private val clientListener = - object : IRelayClientListener { + object : RelayConnectionListener { override fun onConnecting(relay: IRelayClient) { super.onConnecting(relay) with(get(relay.url)) { @@ -116,12 +116,12 @@ class RelayStats( init { Log.d("RelayStats", "Init, Subscribe") - client.subscribe(clientListener) + client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run Log.d("RelayStats", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) + client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt index 0e0b592c9..ef2a72ed0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt @@ -20,14 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener 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 data class Subscription( val id: String = newSubId(), - val listener: IRequestListener, + val listener: SubscriptionListener, ) { private var currentVersion: Map>? = null // Inactive when null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt index cf926f8e4..6e0f4883e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -21,13 +21,13 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache /** - * Manages Nostr subscriptions using a [NostrClient], allowing subscriptions to be created, modified, + * Manages Nostr subscriptions using a [INostrClient], allowing subscriptions to be created, modified, * and synchronized with relay filters. Subscriptions are stored in a cache and processed through * [updateRelays] to update relay filters dynamically. Also tracks event statistics and EOSE (End of * Stored Events) events, and provides utility methods to interact with subscriptions like dismissal. @@ -53,7 +53,7 @@ class SubscriptionController( fun requestNewSubscription( subId: String, - listener: IRequestListener, + listener: SubscriptionListener, ): Subscription = Subscription(subId, listener).also { subscriptions.put(it.id, it) } fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } @@ -61,7 +61,7 @@ class SubscriptionController( fun dismissSubscription(subscription: Subscription) { subscription.reset() subscriptions.remove(subscription.id) - client.close(subscription.id) + client.unsubscribe(subscription.id) } fun updateRelays() { @@ -77,23 +77,23 @@ class SubscriptionController( fun updateRelaysIfNeeded( subId: String, - listener: IRequestListener, + listener: SubscriptionListener, newFilters: Map>?, oldFilters: Map>?, ) { if (oldFilters != null) { if (newFilters == null) { // was active and is not active anymore, just close. - client.close(subId) + client.unsubscribe(subId) } else { - client.openReqSubscription(subId, newFilters, listener) + client.subscribe(subId, newFilters, listener) } } else { if (newFilters == null) { // was not active and is still not active, does nothing } else { // was not active and becomes active, sends the entire filter. - client.openReqSubscription(subId, newFilters, listener) + client.subscribe(subId, newFilters, listener) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt index f6661bc23..27915753c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt @@ -34,4 +34,5 @@ class CountMessage( class CountResult( val count: Int, val approximate: Boolean = false, + val hll: ByteArray? = null, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index 4fa6c6847..b21b7eec0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -91,32 +91,32 @@ class Filter( init { ids?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid id length $it on ${toJson()}") + if (it.length != 64) Log.e("FilterError") { "Invalid id length $it on ${toJson()}" } } authors?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}") + if (it.length != 64) Log.e("FilterError") { "Invalid author length $it on ${toJson()}" } } // tests common tags. if (tags != null) { tags["p"]?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + if (it.length != 64) Log.e("FilterError") { "Invalid p-tag length $it on ${toJson()}" } } tags["e"]?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + if (it.length != 64) Log.e("FilterError") { "Invalid e-tag length $it on ${toJson()}" } } tags["a"]?.forEach { - if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + if (Address.parse(it) == null) Log.e("FilterError") { "Invalid a-tag $it on ${toJson()}" } } } if (tagsAll != null) { tagsAll["p"]?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + if (it.length != 64) Log.e("FilterError") { "Invalid p-tag length $it on ${toJson()}" } } tagsAll["e"]?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + if (it.length != 64) Log.e("FilterError") { "Invalid e-tag length $it on ${toJson()}" } } tagsAll["a"]?.forEach { - if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + if (Address.parse(it) == null) Log.e("FilterError") { "Invalid a-tag $it on ${toJson()}" } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index c89c5df52..ffd1da4d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -145,7 +145,7 @@ class RelayUrlNormalizer { if (trimmed.contains("://")) { // some other scheme we cannot connect to. - Log.w("RelayUrlNormalizer", "Rejected $url") + Log.w("RelayUrlNormalizer") { "Rejected $url" } return null } @@ -178,14 +178,14 @@ class RelayUrlNormalizer { normalizedUrls.put(url, NormalizationResult.Success(normalized)) normalized } else { - Log.w("NormalizedRelayUrl", "Rejected $url") + Log.w("NormalizedRelayUrl") { "Rejected $url" } normalizedUrls.put(url, NormalizationResult.Error) null } } catch (e: Exception) { if (e is CancellationException) throw e normalizedUrls.put(url, NormalizationResult.Error) - Log.w("NormalizedRelayUrl", "Rejected $url") + Log.w("NormalizedRelayUrl") { "Rejected $url" } null } } 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 index 9a65df954..113824d62 100644 --- 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 @@ -27,7 +27,6 @@ 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. 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 index 2a384d8a6..71a6fb944 100644 --- 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 @@ -73,7 +73,7 @@ class RelaySession( try { onSend(OptimizedJsonMapper.toJson(message)) } catch (e: Exception) { - Log.w("ClientSession", "Failed to send to ${e.message}") + Log.w("ClientSession") { "Failed to send to ${e.message}" } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 3f85e755f..0a861cc9b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -23,14 +23,16 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite 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.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.store.IEventStore class EventStore( dbName: String? = "events.db", - relayUrl: String? = "wss://quartz.local", + relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IEventStore { - val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relayUrl, indexStrategy) + val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy) override fun insert(event: Event) = store.insertEvent(event) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index 86113f617..a88a12ceb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -28,7 +28,6 @@ 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt index c85022bd7..13ac44a00 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent class RightToVanishModule( @@ -100,11 +101,11 @@ class RightToVanishModule( fun insert( event: Event, - relayUrl: String?, + relay: NormalizedRelayUrl?, headerId: Long, db: SQLiteConnection, ) { - if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { + if (event is RequestToVanishEvent && event.shouldVanishFrom(relay)) { db.prepare(insertRTV).use { stmt -> stmt.bindLong(1, headerId) stmt.bindLong(2, hasher(db).hash(event.pubKey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 604c671c1..faa9e7cf3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.isEphemeral import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.EventFactory @@ -40,7 +41,7 @@ import kotlinx.coroutines.withContext class SQLiteEventStore( val driver: SQLiteDriver = BundledSQLiteDriver(), val dbName: String? = "events.db", - val relayUrl: String? = null, + val relay: NormalizedRelayUrl? = null, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) { companion object { @@ -182,7 +183,7 @@ class SQLiteEventStore( deletionModule.insert(event, db) expirationModule.insert(event, headerId, db) fullTextSearchModule.insert(event, headerId, db) - rightToVanishModule.insert(event, relayUrl, headerId, db) + rightToVanishModule.insert(event, relay, headerId, db) } fun insertEvent(event: Event) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt index 9392896b1..ce326cf83 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt @@ -156,7 +156,7 @@ class OpenTimestamps( ): Timestamp { val responses = mapNotNullAsync(calendarUrls) { calendarUrl -> - Log.i("OpenTimestamps", "Submitting to remote calendar $calendarUrl") + Log.i("OpenTimestamps") { "Submitting to remote calendar $calendarUrl" } calendar.submit(calendarUrl, timestamp.digest) } @@ -361,7 +361,7 @@ class OpenTimestamps( val attsFromRemote: MutableSet = upgradedStamp.getAttestations() if (attsFromRemote.isNotEmpty()) { - Log.i("OpenTimestamps", "Got 1 attestation(s) from $calendarUrl") + Log.i("OpenTimestamps") { "Got 1 attestation(s) from $calendarUrl" } } // Set difference from remote attestations & existing attestations diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpUnary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpUnary.kt index 40d8004da..0c1c3cf73 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpUnary.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpUnary.kt @@ -56,7 +56,7 @@ abstract class OpUnary : Op() { } else -> { - Log.e("OpenTimestamp", "Unknown operation tag: $tag") + Log.e("OpenTimestamp") { "Unknown operation tag: $tag" } null // TODO: Is this OK? Won't it blow up later? Better to throw? } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt index a144170ae..a0dcaef32 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt @@ -62,7 +62,7 @@ class EncryptedInfo( nonce = Base64.decode(parts[1]), ) } catch (e: Exception) { - Log.w("NIP04", "Unable to Parse encrypted payload: $payload") + Log.w("NIP04") { "Unable to Parse encrypted payload: $payload" } null } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index 0ac30f796..c7520138d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.CancellationException @Stable class Nip05Client( val fetcher: Nip05Fetcher, - val namecoinResolver: NamecoinNameResolver? = null, + val namecoinResolverBuilder: (() -> NamecoinNameResolver)? = null, ) : INip05Client { val parser = Nip05Parser() @@ -37,8 +37,8 @@ class Nip05Client( hexKey: HexKey, ): Boolean { // Namecoin: route .bit domains to blockchain verification - if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { - val result = namecoinResolver.resolve(nip05.toValue()) + if (namecoinResolverBuilder != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolverBuilder().resolve(nip05.toValue()) return result?.pubkey == hexKey } @@ -61,8 +61,8 @@ class Nip05Client( override suspend fun get(nip05: Nip05Id): Nip05KeyInfo? { // Namecoin: route .bit domains to blockchain resolution - if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { - val result = namecoinResolver.resolve(nip05.toValue()) ?: return null + if (namecoinResolverBuilder != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolverBuilder().resolve(nip05.toValue()) ?: return null return Nip05KeyInfo(result.pubkey, result.relays) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index f31edfe7d..4c6c96937 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -44,8 +44,14 @@ data class ElectrumxServer( val host: String, val port: Int, val useSsl: Boolean = true, - /** If true, accept any certificate (self-signed, expired, etc.) */ - val trustAllCerts: Boolean = false, + /** + * If true, use the pinned trust store (hardcoded + TOFU-pinned certs + * plus system CAs) instead of the default system-only trust store. + * + * Required for ElectrumX servers that use self-signed certificates, + * which is the norm for the Namecoin ElectrumX ecosystem. + */ + val usePinnedTrustStore: Boolean = false, ) /** @@ -72,12 +78,27 @@ sealed class NamecoinLookupException( ) : NamecoinLookupException("All ElectrumX servers unreachable", lastError) } +/** + * Result of testing connectivity to a single ElectrumX server. + */ +data class ServerTestResult( + val server: ElectrumxServer, + val success: Boolean, + val responseTimeMs: Long, + val error: String? = null, + val tlsVersion: String? = null, + /** PEM-encoded server certificate, captured during test for TOFU pinning. */ + val serverCertPem: String? = null, + /** SHA-256 fingerprint of the server certificate. */ + val certFingerprint: String? = null, +) + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_ELECTRUMX_SERVERS = listOf( - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), - ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("46.229.238.187", 57002, useSsl = true, usePinnedTrustStore = true), ) /** Tor-preferred server list: onion primary, clearnet fallback. */ @@ -87,8 +108,8 @@ val TOR_ELECTRUMX_SERVERS = "i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion", 50002, useSsl = true, - trustAllCerts = true, + usePinnedTrustStore = true, ), - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/auction/AuctionData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/auction/AuctionData.kt new file mode 100644 index 000000000..16d4620bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/auction/AuctionData.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.quartz.nip15Marketplace.auction + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip15Marketplace.product.ProductShippingSurcharge +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class AuctionData( + val id: String, + @SerialName("stall_id") + val stallId: String, + val name: String, + val description: String? = null, + val images: List? = null, + val currency: String, + @SerialName("starting_bid") + val startingBid: Double, + @SerialName("start_date") + val startDate: Long? = null, + val duration: Long, + val specs: List>? = null, + val shipping: List? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/auction/AuctionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/auction/AuctionEvent.kt new file mode 100644 index 000000000..01487fcf5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/auction/AuctionEvent.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip15Marketplace.auction + +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.JsonMapper +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.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +@Immutable +class AuctionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun auctionData(): AuctionData? = + try { + JsonMapper.fromJson(content) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("AuctionEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } + null + } + + companion object { + const val KIND = 30020 + const val ALT_DESCRIPTION = "Marketplace auction" + + fun build( + auction: AuctionData, + categories: List? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, JsonMapper.toJson(auction), createdAt) { + dTag(auction.id) + alt(ALT_DESCRIPTION) + categories?.let { hashtags(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bid/BidEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bid/BidEvent.kt new file mode 100644 index 000000000..d07f841ac --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bid/BidEvent.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip15Marketplace.bid + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BidEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun amount() = content.toDoubleOrNull() + + fun auctionId() = tags.firstNotNullOfOrNull(ETag::parseId) + + companion object { + const val KIND = 1021 + const val ALT_DESCRIPTION = "Auction bid" + + fun build( + auction: EventHintBundle, + amount: Double, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, amount.toLong().toString(), createdAt) { + alt(ALT_DESCRIPTION) + auction(auction) + notifyAuthor(auction) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bid/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bid/TagArrayBuilderExt.kt new file mode 100644 index 000000000..79b05a495 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bid/TagArrayBuilderExt.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip15Marketplace.bid + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag +import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent + +fun TagArrayBuilder.auction(auctionHint: EventHintBundle) = addUnique(ETag.assemble(auctionHint.event.id, auctionHint.relay, auctionHint.event.pubKey)) + +fun TagArrayBuilder.notifyAuthor(auctionHint: EventHintBundle) = add(auctionHint.toPTag().toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/BidConfirmationData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/BidConfirmationData.kt new file mode 100644 index 000000000..10d6389a3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/BidConfirmationData.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip15Marketplace.bidConfirmation + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class BidConfirmationData( + val status: String, + val message: String? = null, + @SerialName("duration_extension") + val durationExtension: Long? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/BidConfirmationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/BidConfirmationEvent.kt new file mode 100644 index 000000000..55bc78d79 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/BidConfirmationEvent.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.quartz.nip15Marketplace.bidConfirmation + +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.JsonMapper +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent +import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent +import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +@Immutable +class BidConfirmationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun confirmationData(): BidConfirmationData? = + try { + JsonMapper.fromJson(content) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("BidConfirmationEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } + null + } + + fun status() = confirmationData()?.status + + companion object { + const val KIND = 1022 + const val ALT_DESCRIPTION = "Bid confirmation" + + const val STATUS_ACCEPTED = "accepted" + const val STATUS_REJECTED = "rejected" + const val STATUS_PENDING = "pending" + const val STATUS_WINNER = "winner" + + fun build( + bid: EventHintBundle, + auction: EventHintBundle, + confirmation: BidConfirmationData, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, JsonMapper.toJson(confirmation), createdAt) { + alt(ALT_DESCRIPTION) + bid(bid) + auction(auction) + notifyBidder(bid) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/TagArrayBuilderExt.kt new file mode 100644 index 000000000..a3ba209d3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/bidConfirmation/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip15Marketplace.bidConfirmation + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag +import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent +import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent + +fun TagArrayBuilder.bid(bidHint: EventHintBundle) = add(ETag.assemble(bidHint.event.id, bidHint.relay, bidHint.event.pubKey)) + +fun TagArrayBuilder.auction(auctionHint: EventHintBundle) = add(ETag.assemble(auctionHint.event.id, auctionHint.relay, auctionHint.event.pubKey)) + +fun TagArrayBuilder.notifyBidder(bidHint: EventHintBundle) = add(bidHint.toPTag().toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/marketplace/MarketplaceData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/marketplace/MarketplaceData.kt new file mode 100644 index 000000000..71fae5776 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/marketplace/MarketplaceData.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.nip15Marketplace.marketplace + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +data class MarketplaceUi( + val picture: String? = null, + val banner: String? = null, + val theme: String? = null, + val darkMode: Boolean? = null, +) + +@Immutable +@Serializable +data class MarketplaceData( + val name: String? = null, + val about: String? = null, + val ui: MarketplaceUi? = null, + val merchants: List? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/marketplace/MarketplaceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/marketplace/MarketplaceEvent.kt new file mode 100644 index 000000000..11d31790f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/marketplace/MarketplaceEvent.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.quartz.nip15Marketplace.marketplace + +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.JsonMapper +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.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +@Immutable +class MarketplaceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun marketplaceData(): MarketplaceData? = + try { + JsonMapper.fromJson(content) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("MarketplaceEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } + null + } + + companion object { + const val KIND = 30019 + const val ALT_DESCRIPTION = "Marketplace UI" + + fun build( + marketplace: MarketplaceData, + dTag: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, JsonMapper.toJson(marketplace), createdAt) { + dTag(dTag) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/product/ProductData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/product/ProductData.kt new file mode 100644 index 000000000..cc937afbf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/product/ProductData.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip15Marketplace.product + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProductShippingSurcharge( + val id: String, + val cost: Double, +) + +@Immutable +@Serializable +data class ProductData( + val id: String, + @SerialName("stall_id") + val stallId: String, + val name: String, + val description: String? = null, + val images: List? = null, + val currency: String, + val price: Double, + val quantity: Int? = null, + val specs: List>? = null, + val shipping: List? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/product/ProductEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/product/ProductEvent.kt new file mode 100644 index 000000000..e2a7dba30 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/product/ProductEvent.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip15Marketplace.product + +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.JsonMapper +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.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +@Immutable +class ProductEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun productData(): ProductData? = + try { + JsonMapper.fromJson(content) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("ProductEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } + null + } + + fun categories() = tags.hashtags() + + companion object { + const val KIND = 30018 + const val ALT_DESCRIPTION = "Marketplace product" + + fun build( + product: ProductData, + categories: List? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, JsonMapper.toJson(product), createdAt) { + dTag(product.id) + alt(ALT_DESCRIPTION) + categories?.let { hashtags(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/ShippingZone.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/ShippingZone.kt new file mode 100644 index 000000000..823c0f838 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/ShippingZone.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip15Marketplace.stall + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ShippingZone( + val id: String, + val name: String? = null, + val cost: Double, + val regions: List? = null, + @SerialName("countries") + val countries: List? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/StallData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/StallData.kt new file mode 100644 index 000000000..32fec641f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/StallData.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip15Marketplace.stall + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class StallData( + val id: String, + val name: String, + val description: String? = null, + val currency: String, + val shipping: List = emptyList(), +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/StallEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/StallEvent.kt new file mode 100644 index 000000000..c20e61dea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip15Marketplace/stall/StallEvent.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip15Marketplace.stall + +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.JsonMapper +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.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +@Immutable +class StallEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun stallData(): StallData? = + try { + JsonMapper.fromJson(content) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("StallEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } + null + } + + companion object { + const val KIND = 30017 + const val ALT_DESCRIPTION = "Marketplace stall" + + fun build( + stall: StallData, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, JsonMapper.toJson(stall), createdAt) { + dTag(stall.id) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt index eb643fa9f..77a0ef9f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt @@ -51,7 +51,7 @@ fun ATag.Companion.parseAtag( ATag(parts[0].toInt(), parts[1], parts[2], relayHint) } catch (t: Throwable) { - Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") + Log.w("ATag") { "Error parsing A Tag: $atag: ${t.message}" } null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index d9df56831..5fd03a905 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -78,7 +78,7 @@ object Nip19Parser { return type!! + key } catch (e: Throwable) { - Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}") + Log.e("NIP19 Parser") { "Issue trying to Decode NIP19 $uri: ${e.message}" } } return null @@ -98,7 +98,7 @@ object Nip19Parser { return parseComponents(type, key, additionalChars?.ifEmpty { null }) } catch (e: Throwable) { - Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}") + Log.e("NIP19 Parser") { "Issue trying to Decode NIP19 $uri: ${e.message}" } } return null @@ -127,7 +127,7 @@ object Nip19Parser { ParseReturn(it, nip19, additionalChars) } } catch (e: Throwable) { - Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}") + Log.w("NIP19 Parser") { "Issue trying to Decode NIP19 $key: ${e.message}" } null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt index 6cd3af2a2..155864843 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt @@ -55,7 +55,7 @@ data class NAddress( return parse(key.bechToBytes()) } } catch (e: Throwable) { - Log.w("NAddress", "Issue trying to Decode NIP19 $this: ${e.message}") + Log.w("NAddress") { "Issue trying to Decode NIP19 $this: ${e.message}" } } return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt index eb26b7f7b..deb564d2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt @@ -36,6 +36,7 @@ data class NNote( return NNote(bytes.toHexKey()) } + @Suppress("DEPRECATION") fun create(eventId: HexKey): String = eventId.hexToByteArray().toNote() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index c52358a80..b4d0d59f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -68,7 +68,7 @@ class ChannelCreateEvent( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("ChannelCreateEvent", "Failure to parse ${this.toJson()}") + Log.w("ChannelCreateEvent") { "Failure to parse ${this.toJson()}" } ChannelDataNorm() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index aa7c415da..f3208477b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -74,7 +74,7 @@ class ChannelMetadataEvent( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("ChannelCreateEvent", "Failure to parse ${this.toJson()}") + Log.w("ChannelCreateEvent") { "Failure to parse ${this.toJson()}" } ChannelDataNorm() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupAdminsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupAdminsEvent.kt new file mode 100644 index 000000000..af68af2f4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupAdminsEvent.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.quartz.nip29RelayGroups.metadata + +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GroupAdminsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = dTag() + + fun admins() = tags.mapNotNull(GroupAdminTag::parse) + + companion object { + const val KIND = 39001 + const val ALT_DESCRIPTION = "Group admins" + + fun build( + groupId: String, + admins: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(groupId) + addAll(GroupAdminTag.assemble(admins)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMembersEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMembersEvent.kt new file mode 100644 index 000000000..56e6e3cb7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMembersEvent.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.quartz.nip29RelayGroups.metadata + +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GroupMembersEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = dTag() + + fun members(): List = tags.mapNotNull(PTag::parseKey) + + companion object { + const val KIND = 39002 + const val ALT_DESCRIPTION = "Group members" + + fun build( + groupId: String, + memberPubKeys: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(groupId) + memberPubKeys.forEach { add(arrayOf("p", it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt new file mode 100644 index 000000000..4b73193ea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups.metadata + +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.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent +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 GroupMetadataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = dTag() + + fun name() = tags.firstTagValue("name") + + fun about() = tags.firstTagValue("about") + + fun picture() = tags.firstTagValue("picture") + + fun isPrivate() = tags.hasTagWithContent("private") || !tags.hasTagWithContent("public") + + fun isRestricted() = tags.hasTagWithContent("closed") || !tags.hasTagWithContent("open") + + fun isHidden() = tags.hasTagWithContent("private") + + fun isClosed() = tags.hasTagWithContent("closed") + + fun statusTags(): Set { + val statuses = mutableSetOf() + GroupStatus.entries.forEach { status -> + if (tags.hasTagWithContent(status.code)) { + statuses.add(status) + } + } + return statuses + } + + enum class GroupStatus( + val code: String, + ) { + PRIVATE("private"), + PUBLIC("public"), + OPEN("open"), + CLOSED("closed"), + } + + companion object { + const val KIND = 39000 + const val ALT_DESCRIPTION = "Group metadata" + + fun build( + groupId: String, + name: String? = null, + about: String? = null, + picture: String? = null, + status: Set = emptySet(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(groupId) + name?.let { add(arrayOf("name", it)) } + about?.let { add(arrayOf("about", it)) } + picture?.let { add(arrayOf("picture", it)) } + status.forEach { add(arrayOf(it.code)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/SupportedRolesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/SupportedRolesEvent.kt new file mode 100644 index 000000000..9dbf7eacb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/SupportedRolesEvent.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.quartz.nip29RelayGroups.metadata + +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class SupportedRolesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = dTag() + + fun roles() = tags.mapNotNull(RoleTag::parse) + + companion object { + const val KIND = 39003 + const val ALT_DESCRIPTION = "Group supported roles" + + fun build( + groupId: String, + roles: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(groupId) + addAll(RoleTag.assemble(roles)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt new file mode 100644 index 000000000..0554e2c4c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.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.quartz.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CreateGroupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + companion object { + const val KIND = 9007 + const val ALT_DESCRIPTION = "Group create" + + fun build( + groupId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateInviteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateInviteEvent.kt new file mode 100644 index 000000000..44861e1ac --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateInviteEvent.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.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CreateInviteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun code() = tags.inviteCode() + + companion object { + const val KIND = 9009 + const val ALT_DESCRIPTION = "Group create invite" + + fun build( + groupId: String, + code: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + inviteCode(code) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteEventEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteEventEvent.kt new file mode 100644 index 000000000..91674eb62 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteEventEvent.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.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class DeleteEventEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun deletedEventIds() = tags.deletedEventIds() + + fun previousEvents() = tags.previousEvents() + + companion object { + const val KIND = 9005 + const val ALT_DESCRIPTION = "Group delete event" + + fun build( + groupId: String, + eventIds: List, + previousEvents: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + eventIds.forEach { add(arrayOf("e", it)) } + previous(previousEvents) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteGroupEvent.kt new file mode 100644 index 000000000..6f71cfdbb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteGroupEvent.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.quartz.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class DeleteGroupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + companion object { + const val KIND = 9008 + const val ALT_DESCRIPTION = "Group delete" + + fun build( + groupId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt new file mode 100644 index 000000000..f05b72d96 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class EditMetadataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun previousEvents() = tags.previousEvents() + + companion object { + const val KIND = 9002 + const val ALT_DESCRIPTION = "Group edit metadata" + + fun build( + groupId: String, + name: String? = null, + about: String? = null, + picture: String? = null, + status: Set = emptySet(), + previousEvents: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + name?.let { add(arrayOf("name", it)) } + about?.let { add(arrayOf("about", it)) } + picture?.let { add(arrayOf("picture", it)) } + status.forEach { add(arrayOf(it.code)) } + previous(previousEvents) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt new file mode 100644 index 000000000..0fbf97918 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PutUserEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun userPubKeys() = tags.userPubKeys() + + fun previousEvents() = tags.previousEvents() + + companion object { + const val KIND = 9000 + const val ALT_DESCRIPTION = "Group add user" + + fun build( + groupId: String, + pubKeysWithRoles: List>>, + previousEvents: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + pubKeysWithRoles.forEach { (pubKey, roles) -> + userPubKeyWithRoles(pubKey, roles) + } + previous(previousEvents) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/RemoveUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/RemoveUserEvent.kt new file mode 100644 index 000000000..162e63252 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/RemoveUserEvent.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.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class RemoveUserEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun userPubKeys() = tags.userPubKeys() + + fun previousEvents() = tags.previousEvents() + + companion object { + const val KIND = 9001 + const val ALT_DESCRIPTION = "Group remove user" + + fun build( + groupId: String, + pubKeys: List, + previousEvents: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + pubKeys.forEach { userPubKey(it) } + previous(previousEvents) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt new file mode 100644 index 000000000..00e5fe2f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.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.nip29RelayGroups.moderation + +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.nip29RelayGroups.tags.CodeTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag + +fun TagArrayBuilder.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) + +fun TagArrayBuilder.previous(eventIdPrefixes: List) = addAll(PreviousTag.assemble(eventIdPrefixes)) + +fun TagArrayBuilder.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey)) + +fun TagArrayBuilder.userPubKeyWithRoles( + pubKey: HexKey, + roles: List, +) = add(arrayOf("p", pubKey, *roles.toTypedArray())) + +fun TagArrayBuilder.inviteCode(code: String) = addUnique(CodeTag.assemble(code)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt new file mode 100644 index 000000000..318fed79a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.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.nip29RelayGroups.moderation + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag + +fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) + +fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse) + +fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) + +fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } + +fun TagArray.inviteCode() = firstNotNullOfOrNull(CodeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/JoinRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/JoinRequestEvent.kt new file mode 100644 index 000000000..c75f3021b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/JoinRequestEvent.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip29RelayGroups.request + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.inviteCode +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class JoinRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun inviteCode() = tags.inviteCode() + + companion object { + const val KIND = 9021 + const val ALT_DESCRIPTION = "Group join request" + + fun build( + groupId: String, + reason: String = "", + inviteCode: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + inviteCode?.let { inviteCode(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/LeaveRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/LeaveRequestEvent.kt new file mode 100644 index 000000000..74999557d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/LeaveRequestEvent.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.nip29RelayGroups.request + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.groupId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class LeaveRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + companion object { + const val KIND = 9022 + const val ALT_DESCRIPTION = "Group leave request" + + fun build( + groupId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + groupId(groupId) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/CodeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/CodeTag.kt new file mode 100644 index 000000000..cb0d36228 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/CodeTag.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.quartz.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CodeTag { + companion object { + const val TAG_NAME = "code" + + 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(code: String) = arrayOf(TAG_NAME, code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupAdminTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupAdminTag.kt new file mode 100644 index 000000000..031b3b26c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupAdminTag.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip29RelayGroups.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +@Stable +class GroupAdminTag( + val pubKey: HexKey, + val roles: List, +) { + companion object { + const val TAG_NAME = "p" + + fun parse(tag: Array): GroupAdminTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + val roles = + (2 until tag.size).mapNotNull { i -> + tag[i].ifEmpty { null } + } + return GroupAdminTag(tag[1], roles) + } + + fun assemble( + pubKey: HexKey, + roles: List, + ) = arrayOf(TAG_NAME, pubKey, *roles.toTypedArray()) + + fun assemble(admins: List) = admins.map { assemble(it.pubKey, it.roles) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupIdTag.kt new file mode 100644 index 000000000..2b0e75daa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/GroupIdTag.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.quartz.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class GroupIdTag { + companion object { + const val TAG_NAME = "h" + + 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(groupId: String) = arrayOf(TAG_NAME, groupId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/PreviousTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/PreviousTag.kt new file mode 100644 index 000000000..7e7cf80b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/PreviousTag.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.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class PreviousTag { + companion object { + const val TAG_NAME = "previous" + + 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(eventIdPrefix: String) = arrayOf(TAG_NAME, eventIdPrefix) + + fun assemble(eventIdPrefixes: List) = eventIdPrefixes.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/RoleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/RoleTag.kt new file mode 100644 index 000000000..31b690199 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/RoleTag.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip29RelayGroups.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +@Stable +class RoleTag( + val name: String, + val description: String?, +) { + companion object { + const val TAG_NAME = "role" + + fun parse(tag: Array): RoleTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val description = if (tag.has(2) && tag[2].isNotEmpty()) tag[2] else null + return RoleTag(tag[1], description) + } + + fun assemble( + name: String, + description: String? = null, + ) = if (description != null) { + arrayOf(TAG_NAME, name, description) + } else { + arrayOf(TAG_NAME, name) + } + + fun assemble(roles: List) = roles.map { assemble(it.name, it.description) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/EventExt.kt new file mode 100644 index 000000000..5a1adc81a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/EventExt.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip32Labeling + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelNamespaceTag +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag + +/** + * NIP-32 self-reporting: `l` and `L` tags MAY be added to any event kind. + * For non-1985 events, labels refer to the event itself. + */ +fun Event.selfReportLabels() = tags.mapNotNull(LabelTag::parse) + +fun Event.selfReportNamespaces() = tags.mapNotNull(LabelNamespaceTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt new file mode 100644 index 000000000..9615773a0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip32Labeling + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +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.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelNamespaceTag +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-32: Label Event (kind 1985) + * + * Attaches labels to existing events, pubkeys, relays, or topics. + * Supports distributed moderation, collection management, license assignment, + * and content classification. + */ +@Immutable +class LabelEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + AddressHintProvider { + override fun pubKeyHints(): List = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys(): List = tags.mapNotNull(PTag::parseKey) + + 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) + + /** All label namespace (`L`) tags on this event. */ + fun namespaces() = tags.mapNotNull(LabelNamespaceTag::parse) + + /** All label (`l`) tags on this event. */ + fun labels() = tags.mapNotNull(LabelTag::parse) + + /** Labels filtered by a specific namespace. */ + fun labelsByNamespace(namespace: String) = labels().filter { it.namespace == namespace } + + /** Referenced event IDs (label targets). */ + fun labeledEvents() = tags.mapNotNull(ETag::parseId) + + /** Referenced pubkeys (label targets). */ + fun labeledPubKeys() = tags.mapNotNull(PTag::parseKey) + + /** Referenced addresses (label targets). */ + fun labeledAddresses() = tags.mapNotNull(ATag::parseAddressId) + + /** Referenced hashtags/topics (label targets via `t` tag). */ + fun labeledHashtags() = tags.mapNotNull(HashtagTag::parse) + + /** Referenced relay URLs (label targets via `r` tag). */ + fun labeledRelayUrls(): List = + tags + .filter { it.size >= 2 && it[0] == "r" && it[1].isNotEmpty() } + .map { it[1] } + + companion object { + const val KIND = 1985 + const val ALT = "Label event" + + /** + * Build a label event for labeling events. + */ + fun buildEventLabel( + labeledEventId: HexKey, + labeledEventRelay: String? = null, + labeledEventAuthor: HexKey? = null, + labels: List, + content: String = "", + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + eTag(ETag(labeledEventId, labeledEventRelay?.let { RelayUrlNormalizer.normalizeOrNull(it) }, labeledEventAuthor)) + labels.map { it.namespace }.distinct().forEach { labelNamespace(it) } + labels.forEach { label(it) } + } + + /** + * Build a label event for labeling pubkeys. + */ + fun buildPubKeyLabel( + labeledPubKey: HexKey, + labeledPubKeyRelay: String? = null, + labels: List, + content: String = "", + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + pTag(labeledPubKey, labeledPubKeyRelay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + labels.map { it.namespace }.distinct().forEach { labelNamespace(it) } + labels.forEach { label(it) } + } + + /** + * Build a label event with custom tag targets and labels. + */ + fun build( + labels: List, + content: String = "", + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + labels.map { it.namespace }.distinct().forEach { labelNamespace(it) } + labels.forEach { label(it) } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/TagArrayBuilderExt.kt new file mode 100644 index 000000000..f8830b89f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/TagArrayBuilderExt.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.nip32Labeling + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelNamespaceTag +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag + +/** Adds a label (`l`) tag. */ +fun TagArrayBuilder.label(tag: LabelTag) = add(tag.toTagArray()) + +/** Adds a label (`l`) tag with explicit label and namespace values. */ +fun TagArrayBuilder.label( + label: String, + namespace: String, +) = add(LabelTag.assemble(label, namespace)) + +/** Adds a label namespace (`L`) tag. */ +fun TagArrayBuilder.labelNamespace(namespace: String) = add(LabelNamespaceTag.assemble(namespace)) + +/** Adds a label namespace (`L`) tag. */ +fun TagArrayBuilder.labelNamespace(tag: LabelNamespaceTag) = add(tag.toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelNamespaceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelNamespaceTag.kt new file mode 100644 index 000000000..fdeb864fc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelNamespaceTag.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip32Labeling.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-32: `L` tag — label namespace. + * + * Format: `["L", ""]` + * + * Namespaces SHOULD be unambiguous (ISO standard or reverse domain name notation). + * The special `ugc` namespace MAY be used when label content is provided by an end user. + * `L` tags starting with `#` indicate that the label target should be associated + * with the label's value (attaching standard nostr tags to events, pubkeys, etc.). + */ +@Immutable +data class LabelNamespaceTag( + val namespace: String, +) { + fun toTagArray() = assemble(namespace) + + /** + * Returns true if this namespace is a tag-association namespace (starts with `#`). + * When `L` = `#t`, an `l` tag like `["l", "bitcoin", "#t"]` means the target + * should be associated with the hashtag `bitcoin`. + */ + fun isTagAssociation() = namespace.startsWith("#") + + companion object { + const val TAG_NAME = "L" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun isTagged( + tag: Array, + namespace: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == namespace + + fun parse(tag: Array): LabelNamespaceTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return LabelNamespaceTag(tag[1]) + } + + fun parseNamespace(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(namespace: String) = arrayOf(TAG_NAME, namespace) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelTag.kt new file mode 100644 index 000000000..807480240 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/tags/LabelTag.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip32Labeling.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-32: `l` tag — label value with an optional namespace mark. + * + * Format: `["l", "