diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c080d01670..c1ba392b9d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,8 +12,10 @@ architecture while sharing the back end components with the android counterpart. a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library -exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53 -audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under +exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's +relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and +`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs +the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under `moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the production listener AND speaker paths both run on moq-lite to interop with the nostrnests reference relay. The IETF code is kept as a reference + unit-test @@ -23,27 +25,13 @@ implementation for any future IETF target; see Canonical NIP specs live at — use `/nip ` to pull a specific one (it fetches the spec file directly). -## Verify, Don't Guess (standing instruction) +## Verify, Don't Guess -A plausible-sounding explanation is cheap; being right is not. Before -asserting what a problem is or how something behaves: - -1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm - guessing" or "haven't verified" — never dress an untested guess up as a - diagnosis. Use "I verified X by running Y" only when you actually did. -2. **Reproduce before diagnosing.** If a claim is checkable in under a - minute, check it before stating it. This repo gives you the means: - `./gradlew test`, the per-module tests, and `amy` (the CLI exists partly - to drive `quartz`/`commons` for interop checks). Write the failing case - first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix` - (reproduce-first) or `/investigate` (competing hypotheses + refutation). -3. **Predict, then run.** Before running a command, state the output you - expect. A mismatch is the cheapest signal that your model is wrong. -4. **Don't commit to one cause.** A single immediate explanation stops you - from looking. Hold 2–3 candidates and a discriminating test for each. - -If you find yourself writing paragraphs to defend a theory, that effort -almost always should have been one test. +Don't assert a diagnosis you haven't reproduced. This repo gives you cheap +verification tools: `./gradlew test`, per-module test suites, and the `amy` +CLI (built partly to drive `quartz`/`commons` for interop checks). If a +claim is checkable in under a minute, check it before stating it — write +the failing case first, watch it fail, then explain. ## Architecture @@ -69,6 +57,7 @@ amethyst/ │ └── src/ │ ├── commonMain/ # MoQ session, NestsListener, audio glue │ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack +├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code ├── desktopApp/ # Desktop JVM application (layouts, navigation) ├── amethyst/ # Android app (layouts, navigation) └── cli/ # Amy — non-interactive CLI (JVM only, no Compose) @@ -123,16 +112,6 @@ to be used together: skills: `compose-expert` tells you where shared composables live; `compose-slot-api-pattern` tells you how to shape their public API. -## Workflow - -**When you ask for a feature:** - -1. **Quick skill assessment** - I identify which skills are relevant -2. **Propose which skills** - I present which skills I'll use for the task -3. **Get approval** - You review and approve (or adjust) the skill selection -4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan -5. **Execute with skills** - Skills collaborate to implement the feature - ## Feature Workflow **CRITICAL: Check existing implementations first — most logic already exists.** @@ -142,17 +121,9 @@ job is usually to **reuse** (`quartz` protocol/business logic), **extract** (Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav — not to duplicate existing managers, caches, or state. -Capture the survey as a matrix in your plan: - -| File/Component | Status | Location | Action | -|----------------|--------|----------|--------| -| FilterBuilders | ✅ Reuse | quartz/relay/filters/ | Use as-is | -| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons | -| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern | - -**Legend:** ✅ Reuse (exists, use directly) · 📦 Extract (exists in Android, move -to `commons`) · 🆕 New (doesn't exist — platform-specific only) · ⚠️ Avoid -(duplicate; use existing pattern). +Summarize the survey in your plan: for each component, note whether it's +reused as-is, extracted from `amethyst/` to `commons/`, genuinely new +(platform-specific only), or a duplicate of an existing pattern to avoid. **Share vs keep platform-native:** @@ -189,6 +160,37 @@ version. `quartz/` is protocol-only — no composables. ./gradlew spotlessApply ``` +## Dependency Licensing + +**MANDATORY whenever you introduce a new third-party dependency** — in *any* +module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`, +`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a +module's `build.gradle.kts`: determine its license **before** wiring it in. +Amethyst ships under the **MIT** license, so a copyleft dependency linked into a +distributed artifact (APK, desktop binary) can force that artifact's +combined-work terms onto the whole project. + +Verify against the dependency's actual `LICENSE`/`COPYING` file or its published +POM — **not from memory**. Then classify and act: + +- **Permissive** (MIT, Apache-2.0, BSD, ISC, MPL-2.0, zlib, …) → **OK**, + proceed. +- **LGPL, or GPL/EPL with a linking / Classpath exception** → **WARN.** + Acceptable to link (the exception keeps our own code MIT), but call it out in + your summary so the human knows. Confirm the exception actually exists in the + LICENSE text — don't assume it does. +- **Stricter than LGPL** — GPL/AGPL **without** a linking exception, SSPL, + proprietary/commercial-only, or anything where the linking-exception check is + "no" → **STRONGLY WARN and STOP.** Do not add it silently. Surface it + prominently and **require an explicit call-out in the PR description** so a + maintainer makes the decision. Prefer a permissive alternative, a clean-room + implementation, or dropping the feature. + +For any GPL-family hit the decisive question is always **"is there a linking +(LGPL/Classpath) exception?"** — that is what separates a WARN from a STOP. +(Example: TarsosDSP, GPLv3 with no exception, was removed from `amethyst` and +replaced with an in-house pitch shifter for exactly this reason.) + ## Quartz KMP Structure Quartz uses expect/actual for platform-specific implementations (e.g. crypto diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md index 6b43f0d2bf..0b1f5eacb1 100644 --- a/.claude/commands/desktop-run.md +++ b/.claude/commands/desktop-run.md @@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application: If the build fails, check: -1. **JDK Version**: Requires JDK 17+ +1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`) ```bash java -version ``` @@ -39,7 +39,7 @@ If the build fails, check: ./gradlew :desktopApp:packageMsi # Linux -./gradlew :desktopApp:packageDeb +./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm ``` -Outputs will be in `desktopApp/build/compose/binaries/` +Outputs will be in `desktopApp/build/compose/binaries/main/` diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md index 99c01eea7c..c9648a01ab 100644 --- a/.claude/commands/extract.md +++ b/.claude/commands/extract.md @@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code: 1. **Locate the component** in the amethyst module: ```bash - find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*" + find amethyst/src -name "*$ARGUMENTS*" grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/ ``` @@ -18,7 +18,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code: - Android Compose specifics vs standard Compose 3. **Identify what can be shared**: - - Pure Composable functions → `shared-ui/commonMain/` + - Pure Composable functions → `commons/commonMain/` - Business logic → `quartz/commonMain/` - Platform-specific → create expect/actual diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 16056cd4c4..1af746f6dd 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -1,339 +1,37 @@ -# AmethystMultiplatform Skills Creation Plan - -## Overview -Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns. - -**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase. - -## Skills to Implement - -### 1. kotlin-multiplatform ✅ COMPLETED -**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual - -**SKILL.md sections:** -- Mental model: KMP hierarchy as dependency graph -- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain} -- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149) -- expect/actual mechanics with 24+ examples from codebase -- iOS framework setup for Quartz distribution - -**Bundled resources:** -- `references/source-set-hierarchy.md` - Visual diagram + examples -- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns -- `scripts/validate-kmp-structure.sh` - Verify source set dependencies -- `assets/kmp-hierarchy-diagram.png` - Visual graph - -**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples. - -**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/` - ---- - -### 2. gradle-expert ✅ COMPLETED -**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting - -**SKILL.md sections:** -- Build architecture: 4 modules, dependency flow -- Version catalog mastery (libs.versions.toml) -- Module dependency patterns (api vs implementation) -- Android-specific: compileSdk, proguard -- Desktop packaging: TargetFormat, distributions -- Build performance: daemon, parallel, caching -- Common errors: compose version conflicts, secp256k1 JNI variants - -**Bundled resources:** -- `references/build-commands.md` - Common gradle tasks -- `references/dependency-graph.md` - Module visualization -- `references/version-catalog-guide.md` - Version catalog patterns -- `references/common-errors.md` - Troubleshooting guide -- `scripts/analyze-build-time.sh` - Performance report -- `scripts/fix-dependency-conflicts.sh` - Conflict patterns - -**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts). - -**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/` - ---- - -### 3. kotlin-expert ✅ DRAFT COMPLETE -**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified - -**SKILL.md sections:** -- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager) -- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult) -- Immutability: @Immutable for Compose performance (173+ event classes) -- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder) -- Inline functions: reified generics, performance optimization (OptimizedJsonMapper) -- Value classes: Zero-cost wrappers (optimization opportunity) - -**Bundled resources:** -- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns -- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns -- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns -- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set - -**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders). - -**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline) -2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder -3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025 -4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf) -5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability) -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later (requires real usage scenarios) -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 4. compose-expert ✅ COMPLETED -**Focus:** Shared composables, state management, animations, Material3 - -**SKILL.md sections:** -- Shared composables philosophy (100+ already shared in commons/commonMain) -- State management: remember, derivedStateOf, produceState (visual patterns) -- Recomposition optimization: @Stable/@Immutable (visual usage) -- Material3 conventions: theming -- Custom icons: ImageVector builders (robohash pattern) -- Platform differences: Desktop vs Android UI -- Performance: lazy lists, image loading -- Decision framework: share by default in commonMain - -**Bundled resources:** -- `references/shared-composables-catalog.md` - Complete catalog with patterns -- `references/state-patterns.md` - State hoisting, derivedStateOf examples -- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL -- `scripts/find-composables.sh` - Grep @Composable utility - -**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert. - -**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/` - ---- - -### 5. ios-expert -**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation - -**SKILL.md sections:** -- iOS source sets: iosMain, iosArm64Main -- Swift interop: type mapping, nullability -- expect/actual iOS: 10+ examples from quartz/iosMain -- XCFramework setup: baseName = "quartz-kmpKit" -- Platform APIs: platform.posix, CFNetwork, Security -- CocoaPods integration -- XCode project setup - -**Bundled resources:** -- `references/ios-actual-implementations.md` - 10 iosMain actuals -- `references/swift-interop-guide.md` - Type mapping -- `references/xcode-integration.md` - XCode setup -- `scripts/generate-xcframework.sh` - Build all iOS targets - -**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup. - ---- - -### 6. desktop-expert ✅ DRAFT COMPLETE -**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions - -**SKILL.md sections:** -- Desktop entry point: application {} DSL -- Window management: WindowState, positioning, multi-window -- Menu system: MenuBar, keyboard shortcuts (OS-aware) -- System tray: minimize to tray -- Desktop navigation: NavigationRail pattern (vs Android bottom nav) -- File system: Desktop.getDesktop(), file pickers, drag-drop -- Desktop UX principles: keyboard-first, native feel, tooltips -- OS-specific behavior: macOS vs Windows vs Linux -- Platform detection: PlatformDetector utility -- Packaging: DMG, MSI, DEB distribution - -**Bundled resources:** -- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.) -- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns -- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper -- `references/os-detection.md` - Platform detection, file paths, system integration - -**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert. - -**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined desktop usage scenarios -2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt) -3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG) -4. ✅ SYNTHESIZE - Extracted desktop principles from codebase -5. ✅ DRAFT - Created SKILL.md + 4 reference files -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later (requires real desktop scenarios) -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 7. android-expert ✅ DRAFT COMPLETE -**Focus:** Android platform APIs, navigation, permissions, Material Design - -**SKILL.md sections:** -- Android module structure: amethyst/ layout -- Navigation: Navigation Compose, bottom nav -- Permissions: runtime (camera, biometric) -- Platform APIs: Intent, Context, ContentResolver -- Lifecycle: Lifecycle-aware, ViewModel -- Material Design: Android Material 3 -- Build config: Proguard, R8 -- Android UX: mobile-first patterns - -**Bundled resources:** -- `references/android-navigation.md` - Navigation Compose -- `references/android-permissions.md` - Permission handling -- `references/proguard-rules.md` - Proguard explanation -- `scripts/analyze-apk-size.sh` - APK optimization - -**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions. - -**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined Android usage scenarios -2. ✅ EXPLORE - Analyzed amethyst/ module patterns -3. ✅ RESEARCH - Android best practices + KMP Android patterns -4. ✅ SYNTHESIZE - Extracted Android principles from codebase -5. ✅ DRAFT - Initialized skill, created resources -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 8. nostr-expert ✅ COMPLETED -**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns - -**SKILL.md sections:** -- Quartz architecture: package structure by NIP (57 NIPs implemented) -- Event anatomy: IEvent, Event, kinds, tags -- EventTemplate & TagArrayBuilder DSL patterns -- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events -- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders -- Threading (NIP-10): reply/root markers -- Cryptography: secp256k1 signing, NIP-44 encryption -- Bech32 encoding: npub, nsec, note, nevent -- Event validation & verification -- Common workflows: publishing, querying, zaps, gift-wrapped DMs - -**Bundled resources:** -- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines) -- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines) -- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines) -- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term - -**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase. - -**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/` - ---- - -## Implementation Workflow - -Using skill-creator 10-step methodology per skill: - -**Overall Plan:** -1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained -2. **EXPLORE** ✅ - Codebase analyzed via Explore agent -3. **RESEARCH** ✅ - Domain patterns identified via Plan agent -4. **SYNTHESIZE** ✅ - Skills designed above - -**Per-Skill Implementation:** -- kotlin-multiplatform: ✅ COMPLETED -- gradle-expert: ✅ COMPLETED -- kotlin-expert: ✅ COMPLETED -- compose-expert: ✅ COMPLETED -- desktop-expert: ✅ COMPLETED -- android-expert: ✅ COMPLETED -- nostr-expert: ✅ COMPLETED -- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform) - -## Critical Files Referenced - -**Build patterns:** -- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set -- `/commons/build.gradle.kts` - Shared UI setup - -**Code patterns:** -- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure -- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern -- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual - -**Documentation:** -- `/docs/shared-ui-analysis.md` - UI migration strategy - -## Output Location -`.claude/skills//` for each skill - -## Next Steps - -1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference -2. ✅ Completed kotlin-multiplatform skill -3. ✅ Completed gradle-expert skill -4. ✅ Completed kotlin-expert skill -5. ✅ Completed compose-expert skill -6. ✅ Completed desktop-expert skill -7. ✅ Completed android-expert skill -8. ✅ Completed nostr-expert skill -9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase) - -## Current Status: 7/8 Skills Completed - -**Completed Skills (Auto-loaded from `.claude/skills/`):** -1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual) -2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting) -3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders) -4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector) -5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs) -6. ✅ android-expert (Android platform APIs, navigation, permissions) -7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags) - -**Deferred:** -- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform) - -## Skill Loading - -**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory. - -To manually verify skills are loaded: -```bash -ls -1 .claude/skills/ -``` - -Should show: -- android-expert/ -- compose-expert/ -- desktop-expert/ -- gradle-expert/ -- kotlin-expert/ -- kotlin-multiplatform/ -- nostr-expert/ - ---- +# Amethyst Skill Library — History & Changelog + +> Historical record of how the `.claude/skills/` library was built and audited. +> The 8 original skills were created in 2025 using the skill-creator 10-step +> methodology (detailed per-skill progress logs pruned in 2026-06 — see git +> history of this file if you need them). For the current skill list and how +> the two skill layers (codebase-oriented vs technique-oriented) fit together, +> see the Skills section of `.claude/CLAUDE.md`. + +## Phase 1 (2025): Core skills created + +Eight hybrid domain skills (general expertise + Amethyst-specific patterns), +each with a SKILL.md plus bundled `references/` and `scripts/`: + +1. **kotlin-multiplatform** — KMP architecture, the jvmAndroid source-set pattern, expect/actual catalog +2. **gradle-expert** — build system, version catalog, dependency troubleshooting +3. **kotlin-expert** — Flow state, sealed hierarchies, @Immutable, DSL builders +4. **compose-expert** — shared composables, state management, Material3, ImageVector +5. **desktop-expert** — Desktop UX, window management, Compose Desktop APIs +6. **android-expert** — Android navigation, permissions, platform APIs +7. **nostr-expert** — Nostr protocol, Quartz implementation, NIPs, events, tags +8. **ios-expert** — ⏸️ deferred (iOS targets are mature, but no iOS-specific UI work has surfaced in this repo yet) ## Phase 2 (2026-04): Audit & Expansion After a full audit of the skill library, the following changes were made: ### Stale references fixed -- `CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20. -- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes. -- `desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout. +- `CLAUDE.md` tech-stack versions replaced with a pointer to `gradle/libs.versions.toml` as the source of truth. +- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp version notes. +- `desktop-expert` Main.kt line references rewritten to match current layout (NavigationRail moved to `ui/deck/SinglePaneLayout.kt`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout. ### Redundant files removed -- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). `quartz-kmp.md` kept as a small breadcrumb pointer. +- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). ### New references added to existing skills - `nostr-expert/references/nip19-bech32.md` — `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities. @@ -346,33 +44,44 @@ After a full audit of the skill library, the following changes were made: ### New skills created - **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store. - - `references/account-state-flow.md`, `references/local-cache.md` -- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`). - - `references/filter-assemblers.md`, `references/preloaders.md` +- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders. - **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`. - - `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md` -- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55). - - `references/nip46-remote-signer.md`, `references/nip55-android-signer.md` +- **`auth-signers/`** — `NostrSigner` abstraction across internal, NIP-46 remote, and NIP-55 external signers. -### Updated skills directory (Phase 2) -``` -- android-expert/ -- auth-signers/ (new) -- account-state/ (new) -- compose-expert/ -- desktop-expert/ -- feed-patterns/ (new) -- find-missing-translations/ -- find-non-lambda-logs/ -- gradle-expert/ -- kotlin-coroutines/ -- kotlin-expert/ -- kotlin-multiplatform/ -- nostr-expert/ -- quartz-integration/ -- relay-client/ (new) -- quartz-kmp.md (breadcrumb pointer) -``` +## Phase 3 (2026-06): Fable 5 config review -### Still deferred -- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo. +Instructions written to coach older models were removed now that the model +handles them natively; stale references fixed: + +- `CLAUDE.md`: deleted the 5-step skill-approval "Workflow" section + (skills auto-trigger; the approval loop blocked autonomous sessions); + condensed "Verify, Don't Guess" to the repo-specific tooling pointers and + dropped references to `/bugfix` / `/investigate` (never committed to this + repo); replaced the mandated emoji survey matrix with one-line guidance. +- `android-expert` and `desktop-expert` SKILL.md gained YAML frontmatter — + without it they were listed without trigger descriptions and never + auto-invoked. +- `commands/extract.md`: fixed stale `shared-ui/` module name → `commons/`. +- `skills/quartz-kmp.md` breadcrumb deleted (KMP migration long complete; + `quartz-integration` and `nostr-expert` cover its pointers). +- Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified + Kotlin files, so Q&A-only turns no longer pay a Gradle invocation. + +Second audit pass (every concrete claim checked against the code; `amy-expert`, +`find-missing-translations`, `find-non-lambda-logs`, and the vendored technique +skills verified clean): + +- `auth-signers`: bunker login entry point corrected — `NostrSignerRemote.fromBunkerUri(...)` + + `connect()`, not the nonexistent `RemoteSignerManager.connect(url)`. +- `nostr-expert`: NIP count 57 → 80+ packages; `Nip44v2.encrypt/decrypt` + static-object snippet replaced with the real `Nip44` facade + (returns `EncryptedInfo`, `encodePayload()` for event content); invented + `Nip19.npubEncode`/`Nip19Result` API replaced with the real `ByteArray` + extensions (`toNpub()`, …), entity `create()` helpers, and + `Nip19Parser.uriToRoute()?.entity`. +- `nostr-expert/references/nip-catalog.md`: heading count (60+8) replaced with + actual package counts (87 + 23 experimental) and a ground-truth pointer. +- `quartz-integration`: NIP-19 decode example rewritten for + `ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed); + Event Store section corrected from "Android only" to commonMain/all platforms + with the real `store.sqlite.EventStore` import and suspend generic `query`. diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 5b1409249a..d2e869fb85 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license" # Create local.properties if missing -REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")" +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")" LOCAL_PROPS="$REPO_ROOT/local.properties" if [ ! -f "$LOCAL_PROPS" ]; then echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS" diff --git a/.claude/hooks/stop-spotless.sh b/.claude/hooks/stop-spotless.sh new file mode 100755 index 0000000000..61666ba6ab --- /dev/null +++ b/.claude/hooks/stop-spotless.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Stop hook: format Kotlin sources, but only when the working tree actually +# has modified Kotlin files — skips the Gradle invocation on Q&A-only turns. +set -uo pipefail + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +if git status --porcelain 2>/dev/null | grep -qE '[.]kts?$'; then + ./gradlew spotlessApply 2>/dev/null +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index f44ad3a03c..15b897a70f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "./gradlew spotlessApply 2>/dev/null", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh", "timeout": 120 } ] diff --git a/.claude/skills/account-state/SKILL.md b/.claude/skills/account-state/SKILL.md index ec370630eb..528a2dd273 100644 --- a/.claude/skills/account-state/SKILL.md +++ b/.claude/skills/account-state/SKILL.md @@ -1,6 +1,6 @@ --- name: account-state -description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow. +description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow. --- # Account & Local Cache State @@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change │ ▼ - Account observes relevant kinds (3, 10002, 10000, …) + Account state objects pin the relevant addressable notes │ ▼ - Account StateFlow updates (followList, relays, mutes, …) + State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …) │ ▼ ViewModels collect @@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e Composables render ``` -`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering. +`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering. ## Key Files ### `Account.kt` (singleton-per-session) -- `class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind: - - `followListFlow` ← NIP-02 ContactList (kind 3) - - `relayListFlow` ← NIP-65 RelayList (kind 10002) - - `muteListFlow` ← NIP-51 Lists (kind 10000) - - `bookmarkListFlow` ← NIP-51 Lists (kind 10003) - - `topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc. - - Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, … -- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event. -- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop. -- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc. +- `class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind: + - `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3) + - `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, … + - `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000) + - `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, … + - Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders. +- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`. +- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event. +- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop. +- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc. ### `LocalCache.kt` @@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e Typical recipe: 1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list). -2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed. -3. In `Account.kt`: - - Add a private `MutableStateFlow`. - - Expose a `StateFlow` read view. - - Subscribe to the relay (via the relayClient subscription pattern — see `relay-client` skill). - - On event arrival, parse with the quartz event class and update the flow. - - Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes. -4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`. +2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one): + - Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`. + - Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`. + - Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed. +3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill). +4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`. ## `LocalCache` vs `Account` Flow — Which to Read? - **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`. -- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.`. +- **Are you rendering "my follows", "my mutes", "my relays"?** → `account..flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`). - **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable. ## Gotchas - **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache. - **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object. -- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it. +- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it. - **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state. - **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident. ## References -- `references/account-state-flow.md` — catalog of major `Account` StateFlow properties and their source kinds. +- `references/account-state-flow.md` — catalog of major `Account` state objects and their source kinds. - `references/local-cache.md` — `LocalCache` internals, insertion path, indexes. - Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events). diff --git a/.claude/skills/account-state/references/account-state-flow.md b/.claude/skills/account-state/references/account-state-flow.md index e9bf1256f3..d7e37c5524 100644 --- a/.claude/skills/account-state/references/account-state-flow.md +++ b/.claude/skills/account-state/references/account-state-flow.md @@ -1,93 +1,94 @@ -# Account StateFlow Catalog +# Account State-Object Catalog -`Account.kt` exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package. +`Account.kt` composes ~50 **feature state objects** (not raw StateFlow +properties). Each object pins its backing addressable note in `LocalCache`, +exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and +offers suspend mutation helpers that return signed events. Consumers read +`account..flow`. -(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.) +(Property and class names are exact as of the current `Account.kt`; if one has +been renamed, grep `Account.kt` for the class name.) ## Identity & Contacts -| Flow | Kind(s) | Source | Model package | -|------|---------|--------|---------------| -| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` | -| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` | -| `followersFlow` | derived | LocalCache scan | — | -| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` | -| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` | +| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` | +| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` | +| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` | +| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` | +| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` | +| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` | -## Relays & Connectivity +## Relay Lists -| Flow | Kind | Package | -|------|------|---------| -| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` | -| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` | -| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` | -| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` | -| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` | +| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` | +| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` | +| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` | +| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` | +| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` | +| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` | + +Derived relay views (merge several of the above): `homeRelays` +(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`, +`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`, +`defaultGlobalRelays`. ## Content Lists -| Flow | Kind | Package | -|------|------|---------| -| `bookmarkListFlow` | 10003 | `model/nip51Lists/` | -| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` | -| `topNavFeedsFlow` | custom | `model/topNavFeeds/` | -| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` | -| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` | -| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` | -| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` | +| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` | +| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` | +| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` | +| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` | +| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` | +| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` | +| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` | +| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` | +| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` | +| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` | -## Messaging +## Other Feature State -| Flow | Kind | Package | -|------|------|---------| -| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` | -| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` | -| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` | -| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` | +| Account property | State class | Purpose | Package | +|------------------|-------------|---------|---------| +| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` | +| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` | +| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` | +| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` | +| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — | -## Settings & UI - -| Flow | Source | Package | -|------|--------|---------| -| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` | -| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` | -| `privacyOptionsFlow` | local | `model/privacyOptions/` | -| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` | -| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` | - -## Advanced / Derived - -| Flow | Purpose | Package | -|------|---------|---------| -| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` | -| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` | -| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` | -| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` | -| `serverListFlow` | media/upload servers | `model/serverList/` | +Note the migration direction: newer/extracted state classes live in +`commons/src/commonMain/.../commons/model/`, the rest still in +`amethyst/src/main/java/.../model/`. Check both when looking for one. ## Publishing Mutations -Every flow has a corresponding mutation method on `Account` that: +State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`, +`BookmarkListState` add/remove) **build and sign** the updated replaceable +event via the quartz event class (`XEvent.add / remove / create`) and return +it. The caller (usually a method on `Account`) is responsible for sending it +through the client. Decryption results are cached in the paired +`…DecryptionCache` so re-renders don't re-decrypt. -1. Constructs the updated event using a `TagArrayBuilder`. -2. Signs through the injected `NostrSigner` (see `auth-signers` skill). -3. Publishes to the appropriate relay set. -4. Updates the local StateFlow *before* relay round-trip (optimistic). -5. Rolls back / reconciles on failure. +## When a State Object Doesn't Exist Yet -Examples of mutation methods (names may vary slightly in current code): -- `follow(pubKey)` / `unfollow(pubKey)` -- `addBookmark(noteId)` / `removeBookmark(noteId)` -- `mute(pubKey)` / `unmute(pubKey)` -- `updateRelayList(...)`, `updateDmRelayList(...)` -- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)` +If you're adding a new NIP that's user-scoped, follow the pattern (full recipe +in `SKILL.md`): -## When a Flow Doesn't Exist Yet - -If you're adding a new NIP that's user-scoped, follow the pattern: - -1. Create `model/nipXX…/` with an optional `ExtState`/builder class. -2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow = _xFlow.asStateFlow()` to `Account`. -3. Wire the relay subscription (see `relay-client` skill). -4. Add the mutation method that builds, signs, and publishes. -5. Update persistence if the setting is local-only (`AccountSettings.kt`). +1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or + `BookmarkListState` (plain). +2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose + `val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`. +3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if + private), and wire the relay subscription (see `relay-client` skill). +4. Add mutation helpers that build, sign, and return the event; publish from + the calling site. +5. Use `AccountSettings` for the local backup copy if the list must survive + relay loss. diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 1116915273..02b33e8158 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -1,3 +1,8 @@ +--- +name: android-expert +description: Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform. +--- + # android-expert Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture. @@ -739,14 +744,14 @@ fun SignerIntegration(accountViewModel: AccountViewModel) { ```gradle android { namespace = 'com.vitorpamplona.amethyst' - compileSdk = 36 + compileSdk = 37 // from libs.versions.toml android-compileSdk — check there, it drifts defaultConfig { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) - targetSdk = 36 // Android 15 + targetSdk = 37 // android-targetSdk in libs.versions.toml versionCode = 447 - versionName = "1.11.0" + versionName = generateVersionName(libs.versions.app.get(), rootDir) vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/auth-signers/SKILL.md b/.claude/skills/auth-signers/SKILL.md index 26b72b1b79..8b11a52d1b 100644 --- a/.claude/skills/auth-signers/SKILL.md +++ b/.claude/skills/auth-signers/SKILL.md @@ -75,7 +75,7 @@ Most feature code should go through `Account`'s mutation methods (`account.sendR Entry points: - **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`. -- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`. +- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake. - **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`. The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen. diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md index cbd3f5d48d..477880202d 100644 --- a/.claude/skills/desktop-expert/SKILL.md +++ b/.claude/skills/desktop-expert/SKILL.md @@ -1,3 +1,8 @@ +--- +name: desktop-expert +description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform. +--- + # Desktop Expert Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles. @@ -69,7 +74,7 @@ fun main() = application { - `rememberWindowState()` manages size/position - `onCloseRequest` handles window close -**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — `fun main()` at L172, `application {` at L186, top-level `Window` at L229, `MenuBar` at L234. +**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol). --- @@ -275,9 +280,9 @@ Row(Modifier.fillMaxSize()) { } ``` -**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces. +**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases. -**Why NavigationRail?** +**Why a left sidebar?** - Desktop has horizontal space (1200+ dp width) - Vertical sidebar is standard desktop pattern - Always visible (no tabs hidden) @@ -285,7 +290,7 @@ Row(Modifier.fillMaxSize()) { **Android comparison:** - Android: `BottomNavigationBar` (horizontal, bottom) -- Desktop: `NavigationRail` (vertical, left) +- Desktop: left vertical sidebar (`MainSidebar`) ### Multi-Pane Layouts diff --git a/.claude/skills/desktop-expert/references/desktop-navigation.md b/.claude/skills/desktop-expert/references/desktop-navigation.md index 38011f82cb..ad829582f0 100644 --- a/.claude/skills/desktop-expert/references/desktop-navigation.md +++ b/.claude/skills/desktop-expert/references/desktop-navigation.md @@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform. --- -## Desktop: NavigationRail +## Desktop: Left Sidebar ### Current Implementation -**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+). +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces. ```kotlin @Composable diff --git a/.claude/skills/feed-patterns/SKILL.md b/.claude/skills/feed-patterns/SKILL.md index 7526d5d34e..aada17ce3c 100644 --- a/.claude/skills/feed-patterns/SKILL.md +++ b/.claude/skills/feed-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: feed-patterns -description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. +description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. --- # Feed Patterns @@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong │ ◄── ChatroomFeedViewModel │ │ ◄── MarmotGroupFeedViewModel │ │ │ -│ FeedContentState — the flow the UI collects │ +│ │ +│ commons/.../ui/feeds/ (shared, KMP) │ +│ IFeedFilter / FeedFilter (abstract base) │ +│ IAdditiveFeedFilter / AdditiveFeedFilter │ +│ ChangesFlowFilter │ +│ FeedContentState, FeedState — the flow the UI collects │ └─────────────────────────────────────────────────────────────┘ ▲ │ uses │ ┌─────────────────────────────────────────────────────────────┐ -│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │ -│ FeedFilter (abstract) │ +│ amethyst/.../ui/dal/ (Android-only additions) │ │ AdditiveComplexFeedFilter │ -│ ChangesFlowFilter │ │ FilterByListParams │ -│ DefaultFeedOrder │ +│ DefaultFeedOrder (Note/Event/Card comparators) │ +│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │ +│ back-compat typealiases re-exporting commons) │ │ │ -│ Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter, │ -│ BookmarkListFeedFilter, NotificationFeedFilter, … │ +│ Concrete feeds: HomeNewThreadFeedFilter, │ +│ HashtagFeedFilter, NotificationFeedFilter, … live in │ +│ feature folders under ui/screen/loggedIn/*/dal/ │ └─────────────────────────────────────────────────────────────┘ ▲ │ reads │ ┌─────────────────────────────────────────────────────────────┐ -│ model/LocalCache.kt + Account. │ +│ model/LocalCache.kt + account..flow │ └─────────────────────────────────────────────────────────────┘ ``` @@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong - **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed. - **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing. -### Android DAL (the filters) +### Shared filter bases (commons) + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`: + +- **`FeedFilter.kt`** — `abstract class FeedFilter : IFeedFilter`. Has `feed(): List` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`. +- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter : FeedFilter(), IAdditiveFeedFilter`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything. +- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query. +- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects. + +### Android DAL (additions on top) `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`: -- **`FeedFilters.kt`** — `abstract class FeedFilter`. Has `feed(): List` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache). -- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything. -- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query. -- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters. -- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging). +- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`: like `AdditiveFeedFilter` but the incoming items (`Set`) are a different type than the list rows (`T`). +- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters. +- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`. +- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here. -Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`. +Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature `dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`. ## Adding a New Feed -1. **Define the filter.** Extend `AdditiveComplexFeedFilter>` (or plain `FeedFilter` if additivity doesn't matter). Implement: +1. **Define the filter.** Extend `AdditiveFeedFilter` (or plain `FeedFilter` if additivity doesn't matter; `AdditiveComplexFeedFilter` if incoming items differ in type from list rows). Implement: - `feedKey()` — stable identity (e.g. hashtag name, account pubkey). - `feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list. - `limit()` — pagination hint. - - If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set): Set` and `sort(collection: Set): List`. + - If additive: `applyFilter(collection: Set): Set` and `sort(collection: Set): List`. 2. **Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`. 3. **Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes. 4. **Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`. @@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, ## Filter Sharing (Android vs Desktop) -- `FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/` — **Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`. -- ViewModels are in `commons/commonMain/` — **shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared. -- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it. +- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**. +- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases. +- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules. ## Gotchas @@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, - **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates. - **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts. - **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan. -- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc. +- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams` exclusion paths rather than filtering post-hoc. ## References diff --git a/.claude/skills/feed-patterns/references/feed-filter-composition.md b/.claude/skills/feed-patterns/references/feed-filter-composition.md index 3f345b1b16..4c00329919 100644 --- a/.claude/skills/feed-patterns/references/feed-filter-composition.md +++ b/.claude/skills/feed-patterns/references/feed-filter-composition.md @@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt | If… | Use | |-----|-----| | Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter` | -| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter>` | +| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter` | +| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter` (Android-only) | | The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter` + `ListChangeFeedViewModel` | | The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) | -All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`. +The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`. ## 2. Write the Filter @@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`. class HashtagFeedFilter( private val accountViewModel: AccountViewModel, private val hashtag: String, -) : AdditiveComplexFeedFilter>() { +) : AdditiveFeedFilter() { override fun feedKey(): String = "Hashtag-$hashtag" @@ -28,7 +29,7 @@ class HashtagFeedFilter( override fun feed(): List { val params = FilterByListParams.create( excludeMuted = true, - hiddenUsers = accountViewModel.hiddenUsersFlow.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return LocalCache.hashtagIndex[hashtag] .orEmpty() @@ -69,7 +70,7 @@ class HashtagFeedViewModel( ) ``` -If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`. +If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`. ## 4. Wire Invalidation @@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan ```kotlin init { viewModelScope.launch { - accountViewModel.muteListFlow.collect { invalidateAll() } + account.muteList.flow.collect { invalidateAll() } } } ``` diff --git a/.claude/skills/gradle-expert/SKILL.md b/.claude/skills/gradle-expert/SKILL.md index 208caa11cc..04f67cf403 100644 --- a/.claude/skills/gradle-expert/SKILL.md +++ b/.claude/skills/gradle-expert/SKILL.md @@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro # Gradle Expert -Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. +Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. ## Build Architecture Mental Model -Think of this project as **4 layers**: +The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses): ``` ┌─────────────┬─────────────┐ @@ -165,11 +165,11 @@ implementation(libs.jna) **The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes. -**Current project config:** +**Current project config** (always re-check `gradle/libs.versions.toml` — these drift): ```toml -composeMultiplatform = "1.9.3" # Plugin + runtime -composeBom = "2025.12.01" # AndroidX Compose BOM -kotlin = "2.3.0" +composeMultiplatform = "1.11.0" # Plugin + runtime +composeBom = "2026.05.01" # AndroidX Compose BOM +kotlin = "2.3.21" ``` **Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 411f4d0233..8b07e1bced 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -3,46 +3,50 @@ ## Visual Hierarchy ``` -┌─────────────────────────────────────────────────────────┐ -│ Root Project │ -│ (Amethyst) │ -└─────────────────────────────────────────────────────────┘ - │ - ┌────────────────┼────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ :amethyst │ │ :desktopApp │ │ :benchmark │ -│ (Android) │ │ (JVM) │ │ (Android) │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ - │ │ │ - └────────────────┼────────────────┘ - │ - ▼ - ┌─────────────┐ - │ :commons │ - │ (KMP UI) │ - │ │ - │ jvmAndroid │ - │ / \ │ - │ jvm android│ - └─────────────┘ - │ - │ - ▼ - ┌─────────────┐ - │ :quartz │ - │(KMP Library)│ - │ │ - │ commonMain │ - │ │ │ - │ jvmAndroid │ - │ / | \ │ - │jvm and ios │ - └─────────────┘ + Apps / harnesses Libraries +┌─────────────┐ ┌─────────────┐ ┌────────────┐ +│ :amethyst │ │ :desktopApp │ │ :benchmark │ +│ (Android) │ │ (JVM) │ │ (Android) │ +└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘ + │ │ └───────┼────┐ │ │ │ + │ │ │ │ │ │ │ + │ ▼ ▼ │ │ ▼ │ + │ ┌────────────────┐ │ │ (androidTest │ + │ │ :commons │◄┼──┼──only) │ + │ │ (KMP UI) │ │ │ │ + │ └───────┬────────┘ │ │ │ + │ │ ▲ │ │ │ + ▼ │ │ │ │ │ +┌──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │ +│ :nestsClient │ │ │ :cli │ │:geode │ │ +│ (KMP, MoQ) │ │ │(JVM) │ │(JVM │ │ +└──┬────────┬──┘ │ └──┬───┘ │relay) │ │ + │ │ │ │ └───┬───┘ │ + ▼ │ │ │ │ │ +┌────────┐ │ │ │ │ │ +│ :quic │ │ │ │ │ │ +│ (KMP) │ │ │ │ │ │ +└───┬────┘ │ │ │ │ │ + │ ▲ │ │ │ │ │ + │ └── :quic-interop │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌──────────────────────────────┐ + │ :quartz │ + │ (KMP Library) │ + └──────────────────────────────┘ ``` +Verified edges (from each module's `build.gradle.kts`): + +- `:amethyst` → `:quartz`, `:commons`, `:nestsClient` +- `:desktopApp` → `:quartz`, `:commons` +- `:benchmark` → `:quartz`, `:commons` (androidTest only) +- `:cli` → `:quartz`, `:commons` +- `:geode` → `:quartz` (api + testFixtures) +- `:nestsClient` → `:quartz` (api), `:quic` +- `:quic` → `:quartz` (api) +- `:quic-interop` → `:quic` (project dir: `quic/interop`) + ## Module Details ### :quartz (KMP Nostr Library) @@ -86,11 +90,41 @@ **Type:** Android Library **Targets:** Android **Dependencies:** -- Modules: `:commons`, `:quartz` +- Modules: `:commons`, `:quartz` (androidTest only) - External: AndroidX Benchmark **Role:** Performance benchmarking for Android builds +### :cli (Amy CLI) +**Type:** JVM Application (no Compose) +**Dependencies:** `:quartz`, `:commons` + +**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill) + +### :geode (Relay Server) +**Type:** JVM Application (Ktor) +**Dependencies:** `:quartz` (api + testFixtures) + +**Role:** Standalone Nostr relay built on quartz's relay-server code + +### :quic (QUIC Transport) +**Type:** Kotlin Multiplatform Library +**Dependencies:** `:quartz` (api) + +**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ + +### :nestsClient (Audio Rooms) +**Type:** Kotlin Multiplatform Library +**Dependencies:** `:quartz` (api), `:quic` + +**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature + +### :quic-interop (Interop Harness) +**Type:** JVM Application (project dir `quic/interop`) +**Dependencies:** `:quic` + +**Role:** QUIC interop-runner test client + ## Dependency Flow Patterns ### Desktop Build Chain @@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant implementation(compose.ui) // Compose Multiplatform BOM implementation(compose.material3) -// Version catalog alignment -composeMultiplatform = "1.9.3" -composeBom = "2025.12.01" // AndroidX Compose +// Version catalog alignment (re-check libs.versions.toml — these drift) +composeMultiplatform = "1.11.0" +composeBom = "2026.05.01" // AndroidX Compose ``` **Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align diff --git a/.claude/skills/kotlin-expert/SKILL.md b/.claude/skills/kotlin-expert/SKILL.md index bc1d81bc06..619170dd3e 100644 --- a/.claude/skills/kotlin-expert/SKILL.md +++ b/.claude/skills/kotlin-expert/SKILL.md @@ -807,6 +807,5 @@ Passing lambda to function? --- -**Version:** 1.0.0 -**Last Updated:** 2025-12-30 -**Codebase Reference:** AmethystMultiplatform commit 258c4e011 +**Version:** 1.0.1 +**Last Updated:** 2026-06-10 diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index 373b14eb2b..c8c87c0cd7 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: nostr-expert -description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. +description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. --- # Nostr Protocol Expert (Quartz Implementation) @@ -313,26 +313,24 @@ class LocalSigner(private val privateKey: ByteArray) : ISigner { ### Encryption (NIP-44) ```kotlin -// Modern encryption (ChaCha20-Poly1305) -object Nip44v2 { - fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String - fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String +// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade +// (nip44Encryption/Nip44.kt — picks the current version, decrypts any) +object Nip44 { + fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo + fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String } // Usage -val encrypted = Nip44v2.encrypt( - plaintext = "Secret message", - privateKey = myPrivateKey, - pubKey = recipientPubKey -) +val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey) +val payload = encrypted.encodePayload() // base64 string for event content -val decrypted = Nip44v2.decrypt( - ciphertext = encrypted, - privateKey = myPrivateKey, - pubKey = senderPubKey -) +val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey) ``` +Most code should not call `Nip44` directly — go through +`signer.nip44Encrypt(plaintext, toPublicKey)` / `signer.nip44Decrypt(ciphertext, fromPublicKey)` +so remote/external signers keep working. + **Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD. ### NIP-04 (Deprecated) @@ -345,44 +343,34 @@ object Nip04 { } ``` -**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues. +**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues. ## Bech32 Encoding (NIP-19) +Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`); +TLV entities carry relay hints via `create()` helpers on the entity classes in +`nip19Bech32/entities/`. Decoding goes through `Nip19Parser`, whose +`uriToRoute()` returns a `ParseReturn?` wrapping the parsed `Entity`. + ```kotlin -object Nip19 { - // Encode - fun npubEncode(pubkey: HexKey): String // npub1... - fun nsecEncode(privateKey: ByteArray): String // nsec1... - fun noteEncode(eventId: HexKey): String // note1... - fun neventEncode(eventId: HexKey, relays: List = emptyList()): String - fun nprofileEncode(pubkey: HexKey, relays: List = emptyList()): String - fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List = emptyList()): String +// Encode simple entities: ByteArray extensions +val npub = pubkeyBytes.toNpub() // "npub1..." +val nsec = privKeyBytes.toNsec() // "nsec1..." +val note = eventIdBytes.toNote() // "note1..." - // Decode - fun decode(bech32: String): Nip19Result -} - -sealed class Nip19Result { - data class NPub(val hex: HexKey) : Nip19Result() - data class NSec(val hex: HexKey) : Nip19Result() - data class Note(val hex: HexKey) : Nip19Result() - data class NEvent(val hex: HexKey, val relays: List) : Nip19Result() - data class NProfile(val hex: HexKey, val relays: List) : Nip19Result() - data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List) : Nip19Result() -} +// Encode TLV entities with relay hints (relays: List) +val nevent = NEvent.create(eventIdHex, authorHex, kind, relays) +val nprofile = NProfile.create(pubkeyHex, relays) ``` **Usage**: ```kotlin -// Encode -val npub = Nip19.npubEncode(pubkeyHex) -// Output: "npub1..." - -// Decode -when (val result = Nip19.decode(npub)) { - is Nip19Result.NPub -> println("Pubkey: ${result.hex}") - is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}") +// Decode (also accepts nostr: URIs); entity types live in nip19Bech32.entities +when (val entity = Nip19Parser.uriToRoute(input)?.entity) { + is NPub -> println("Pubkey: ${entity.hex}") + is NEvent -> println("Event: ${entity.hex}, relays: ${entity.relay}") + is NAddress -> println("Address: ${entity.aTag()}") + null -> println("not a valid bech32 entity") else -> println("Other type") } ``` diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index 950f2cb1a1..a18731696f 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -1,4 +1,8 @@ -# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz +# NIP Catalog: Quartz NIP Packages + +As of 2026-06 Quartz has **87 standard `nip*` packages** plus **23 packages +under `experimental/`**. The categorized list below may lag behind — +`ls quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/` is ground truth. ## Standard NIPs by Category diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index ebc53886f7..3c1293679f 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -447,23 +447,28 @@ val textNote = Event.fromJson(json) as? TextNoteEvent ```kotlin import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -// Decode any bech32 entity -val result = Nip19Parser.uriToRoute("npub1abc...") -// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null - -when (val r = Nip19Parser.uriToRoute(input)) { - is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}") - is Nip19Parser.Return.Note -> println("event id: ${r.hex}") - is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}") - is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}") - is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}") - null -> println("not a valid bech32 entity") - else -> {} +// Decode any bech32 entity (plain or nostr:-prefixed). +// uriToRoute() returns Nip19Parser.ParseReturn? — the parsed Entity is in .entity +when (val entity = Nip19Parser.uriToRoute(input)?.entity) { + is NPub -> println("pubkey: ${entity.hex}") + is NNote -> println("event id: ${entity.hex}") + is NEvent -> println("event: ${entity.hex}, relays: ${entity.relay}") + is NProfile -> println("profile: ${entity.hex}") + is NAddress -> println("address: ${entity.aTag()}") + null -> println("not a valid bech32 entity") + else -> {} } -// The parser also handles nostr: URI scheme -val result = Nip19Parser.uriToRoute("nostr:npub1abc...") +// Encode: ByteArray extensions from nip19Bech32/ByteArrayExt.kt +val npub = pubkeyBytes.toNpub() // also toNsec(), toNote(), ... +// TLV entities with relay hints (relays: List) +val nevent = NEvent.create(eventIdHex, authorHex, kind, relays) ``` --- @@ -601,21 +606,22 @@ In Xcode: drag & drop the `.xcframework` into your project, then use from Swift --- -## 14. Event Store (Android only) +## 14. Event Store (SQLite, all platforms) -SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62): +SQLite-backed storage in `commonMain` (JVM, Android, iOS — uses the bundled +androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, +NIP-62). All operations are `suspend`: ```kotlin -import com.vitorpamplona.quartz.nip01Core.store.EventStore -import android.content.Context +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -val store = EventStore() +val store = EventStore() // default DB file "events.db" // Insert store.insert(event) // Query -val events = store.query( +val events = store.query( Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50) ) @@ -623,7 +629,7 @@ val events = store.query( val count = store.count(Filter(kinds = listOf(1))) // Full-text search (NIP-50) -val results = store.query(Filter(search = "bitcoin")) +val results = store.query(Filter(search = "bitcoin")) ``` --- diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md deleted file mode 100644 index 44c187e0be..0000000000 --- a/.claude/skills/quartz-kmp.md +++ /dev/null @@ -1,23 +0,0 @@ -# Quartz KMP (Legacy Skill — Migration Complete) - -> The KMP migration of Quartz is **complete**. This file is kept for historical reference. -> -> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead. -> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill. - -## What was migrated - -The Quartz library was successfully converted from Android-only to full KMP supporting: -- **commonMain** — All Nostr protocol logic, events, filters, tags -- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers -- **androidMain** — SQLite event store, NIP-55 Android signer -- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm) -- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`) - -## Current artifact - -``` -com.vitorpamplona.quartz:quartz:1.11.0 -``` - -See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index 8aaa258d05..0bdea82309 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -24,17 +24,23 @@ relayClient/ ├── assemblers/ # "Given these inputs, build this relay Filter" │ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys │ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids -│ └── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed +│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed +│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt ├── composeSubscriptionManagers/ │ ├── ComposeSubscriptionManager.kt # interface Subscribable │ ├── MutableComposeSubscriptionManager.kt # reference impl │ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls ├── eoseManagers/ # EOSE tracking per subscription +│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager +├── nip17Dm/ # gift-wrap DM plumbing +│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt ├── preload/ │ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting │ └── MetadataRateLimiter.kt # token-bucket-ish limiter └── subscriptions/ - └── KeyDataSourceSubscription.kt # "this set of keys drives this filter" + ├── KeyDataSourceSubscription.kt # "this set of keys drives this filter" + ├── LifecycleAwareKeyDataSourceSubscription.kt + └── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt ``` ## Core Concept: `Subscribable` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 092ea9e37d..7abc8593ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -227,16 +227,22 @@ jobs: :quartz:compileTestKotlinIosArm64 # :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the - # iOS plan. Compile-only for now — actual UI / lifecycle wiring will - # land with the iosApp module in Phase 3. The container that runs Claude - # Code can't extract the Kotlin/Native LLVM toolchain (sandbox limit), - # so this is the first place commonMain Compose code is actually - # type-checked against an Apple Native frontend. - - name: Compile Commons for iOS + # iOS plan. Actual UI / lifecycle wiring will land with the iosApp module + # in Phase 3, but the shared commonMain + commonTest sources are already + # built here against an Apple Native frontend. Same two-task shape as + # quartz above: + # - iosSimulatorArm64Test compiles AND runs the shared commonTest suite + # on the simulator. commonTest is built for every target, so a test + # reaching for a JVM-only API (JUnit, javaClass, @JvmStatic, or a + # jvmAndroid-only symbol) breaks the Apple build even though + # :commons:jvmTest stays green — this is the job that catches it. + # - compileTestKotlinIosArm64 catches device-only compile drift + # (iosArm64 = aarch64-apple-ios) without needing a physical device. + - name: Test Commons on iOS run: | ./gradlew \ - :commons:compileKotlinIosSimulatorArm64 \ - :commons:compileKotlinIosArm64 + :commons:iosSimulatorArm64Test \ + :commons:compileTestKotlinIosArm64 - name: Upload iOS Test Reports uses: actions/upload-artifact@v7 diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 5c99450c07..47db3d09d8 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -412,6 +412,11 @@ dependencies { implementation(libs.markdown.ui.material3) implementation(libs.markdown.commonmark) + // LaTeX math rendering ($...$ and $$...$$ inline equations) + implementation(libs.jlatexmath.android) + implementation(libs.jlatexmath.font.greek) + implementation(libs.jlatexmath.font.cyrillic) + // Language picker and Theme chooser implementation(libs.androidx.appcompat) @@ -469,9 +474,6 @@ dependencies { // EXIF metadata stripping implementation(libs.androidx.exifinterface) - // Voice anonymization DSP - implementation(libs.tarsosdsp) - // WebRTC for voice/video calls implementation(libs.stream.webrtc.android) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md new file mode 100644 index 0000000000..b93c13e2ec --- /dev/null +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -0,0 +1,332 @@ +# DM loading: live tail + per-relay history paging + +> **Status:** authoritative as of 2026-06-05. The "Current architecture" +> section below describes the code as it actually stands. The original +> time-slice design and the round-model history are kept at the bottom under +> **Design evolution (historical)** — they are superseded and no longer match +> the code; don't trust them for how it works today. + +## Problem + +The DM loaders used a single subscription whose `since` floor grew as the user +scrolled (`loadMore`: 7d → 14d → 28d → …). The filter carried **only `since`, +no `until`**, so every widen re-requested the whole window and the relay +re-streamed the entire history from the new floor. Traces showed this directly: + +``` +[giftwrap] load summary: 589 event(s) (14d) +[giftwrap] load summary: 1486 event(s) (28d) +[giftwrap] load summary: 2609 event(s) (56d) +``` + +Each step re-downloaded everything it already had plus the new slice — the +"getting all events over and over again" the owner reported. It also cascaded: +a few pixels of scroll walked the window to the 10-year backstop, because +widening pulls older *messages* but the rooms list is keyed by *conversation*, +so a handful of busy correspondents flood thousands of events without adding a +single new row, and the "scrolled near the oldest room" trigger never clears. + +--- + +## Current architecture + +### Two layers per protocol + +Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs +(kind 4) — is split into two independent responsibilities: + +1. **Live tail** — a fixed ~1-week floor with **no `until`**, open to the + future. Never widens. New messages always arrive here. Backed by the + **round model** (`WindowLoadTracker`): one REQ fanned to every relay, "done" + when all settle, drives the boot spinner. +2. **History** — everything *older* than the week floor, paged **backward by + `until`+`limit`, per relay, on demand**. Backed by the **per-relay model** + (`RelayLoadingCursors` + `PerRelayLoadTracker`), driven by on-screen markers. + +The two are disjoint in time, so re-issuing a history page never re-streams the +live tail, and consecutive history pages never re-stream each other. + +| Surface | Live-tail manager (round) | History manager (per-relay) | +|---|---|---| +| Account gift wraps (NIP-17) | `AccountGiftWrapsEoseManager` | `AccountGiftWrapsHistoryEoseManager` | +| Conversation NIP-04 | `ChatroomNip04SubAssembler` | `ChatroomNip04HistorySubAssembler` | +| Rooms-list NIP-04 | `ChatroomListNip04SubAssembler` | `ChatroomListNip04HistorySubAssembler` | + +Accessed from the UI via `accountViewModel.dataSources()` as +`.account.giftWrapsHistory`, `.chatroom.nip04History`, +`.chatroomList.nip04History`. + +### The history paging primitive: `RelayLoadingCursors` + +The time-window model can't tell "this relay is empty" from "this is a gap" — a +`since`/`until` slice that returns nothing might just be a quiet stretch above +older messages. Paging by `until`+`limit` removes that ambiguity: a relay +returns up to `limit` (**10000**) of its newest events older than the cursor, +**skipping gaps**, so an **empty page + EOSE is a gap-proof "nothing older"**. + +Per relay, two cursors are kept deliberately decoupled: + +- `requestedUntil` — the `until` the REQ carries. Moves **only** in `advance()`. + Leaving it untouched on EOSE is what makes paging demand-driven: a relay that + finished a page just **parks** at the same filter (no re-REQ) until advanced. +- `reachedUntil` — the oldest `created_at` actually delivered. Moves on EOSE. + The in-stream markers sit here; the next page starts at `reachedUntil − 1`. + +Stop signals: an empty page marks the relay **`done`**. A relay returning fewer +than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay +that returns events but none older than already reached (echoing its newest +events) is also treated as the bottom, so its marker can't re-request the same +window forever. Tested in `RelayLoadingCursorsTest.kt`. + +### The two completion models (and where each lives) + +**Round model — `WindowLoadTracker` (live tail only).** One REQ is fanned to all +relays; the window is "done" only when **every** expected relay reaches a +terminal signal (`settled ⊇ expected`), with backstops for stragglers (idle / +silence / connect-grace / absolute cap). `loading` starts **`true`**. This is a +*barrier*: nobody moves on until the cohort answers. It is the right shape for +the one-shot fixed-window backfill the live tail does. + +> Note: the silence + connect-grace backstops are gated behind `tracksReqSends`, +> and **none of the three live-tail managers pass `tracksReqSends = true`**, so +> in current use only the settle / idle / cap paths ever fire. The REQ-aware +> machinery is dormant in production — see "Things to scrutinize". + +**Per-relay model — `RelayLoadingCursors` + `PerRelayLoadTracker` (all history).** +Each relay advances to its next page the instant *it* EOSEs, independent of the +others; the subscription layer diffs per relay, so re-issuing only re-REQs the +relay whose cursor moved. `loading` starts **`false`** (a `true` start would +wedge the scroll loader's `!loading` gate on first open). Fast relays race to +the bottom in back-to-back pages; slow / auth-walled relays catch up at their +own pace and **none are abandoned** — a stalled relay keeps its subscription +open and resumes when re-advanced. This removes the round model's +slowest-relay coupling, which matters most on the conversation screen where the +fan-out includes correspondents' (often auth-walled, slow) relays. + +`exhausted` (per history manager) flips true when **every relay is `done` OR +`stalled`** — "nothing more reachable right now". A merely *parked* relay (more +to load, just not advancing) keeps it false. + +> All three history managers (`AccountGiftWrapsHistoryEoseManager`, +> `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`) +> were structurally the same per-relay loader, so that bookkeeping is now a +> single reusable engine — **`BackwardRelayPager`** (keyless, single-active). It +> does **not** hold the cursors: the per-relay `RelayLoadingCursors` live on the +> scope's own domain object (a `Chatroom` per conversation, a `ChatroomList` per +> account), so they share the cached messages' lifetime and survive an account +> switch. The orchestrator owns only the transient bits — in-flight + silence +> tracking, the stalled set, and the display flows — and +> `bind(cursors, scope, relaysFor)`s to whichever scope is active. Each manager +> supplies its REQ-filter builder, a `relaysFor` lookup, and the subscription +> wiring (forwards relay callbacks via `onEvent`/`onEose`/`onClosed`/`onCannotConnect`, +> re-issues filters after `advance`/`advanceAll`). The earlier round-model history +> (and the rooms-list "stall-gate") was fully removed — see Design evolution. + +### What drives `advance()`: on-screen markers, off viewport visibility + +History paging is demand-driven by **per-relay window-limit markers** placed in +the message stream, not by a scroll-position trigger: + +- **`RelayReachCursor`** — one per (protocol, relay): its `reachedUntil` depth, + its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the + `advance()` that pulls *that relay's* next page. Built in the feed views from + each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a + protocol drops out of the list once `exhausted`). +- **`RelayReachSentinels`** — the load *driver*, **hoisted above the + `LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit + gets one stable effect (keyed by `protocol:url`) that watches `listState` and + fires `advance()` when its gap is among the **currently visible rows** AND + either it just scrolled into view OR its `reachedUntil` moved (a page landed — + keep paging while visible). Driving off **viewport visibility** instead of row + composition is deliberate: an earlier version placed the sentinel *inside* the + hosting row, so any feed reorder (a live DM, a slow relay dribbling a page) + tore the effect down and re-fired `advance()` on a static screen — re-arming + stalled relays into a silence-watchdog storm. (commit `0394ec2a`) +- **`RelayReachMarkers` / `RelayReachMarker`** — pure UI (via the + `markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's + reached depth. Can be re-placed on every reorder without triggering paging. +- **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live + tail came back empty for a thread/list whose newest message is older than a + week) there are no rows to host markers, so this steps every relay one page at + a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until + messages appear and the markers take over, or the protocol exhausts. + +### NIP-04 per-relay filter scoping (`Nip04DmRelayRouting`) + +A conversation's NIP-04 filters previously named the whole participant set on +every relay, so a relay belonging to one correspondent was asked about all of +them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox +relays — which auth-walled relays reject outright ("all authors must be +authenticated"), stalling the load. + +`Nip04DmRelayRouting` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** +(`relay → which keys to name there`), built from the outbox model: + +- **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's + outbox carries only that correspondent. +- **from me** (`authors:[me]`) — my outbox carries the whole group; each + correspondent's inbox carries only that correspondent. + +So a relay only ever sees the keys that actually own it. The **conversation** +history manager scopes its REQ to the armed relays' key sets this way; the +**rooms-list** and **gift-wrap** history managers query only the account's *own* +relays (home outbox `from-me` + DM inbox `to-me`, via `filterNip04DMsFromMe` / +`filterNip04DMsToMe` and `filterGiftWrapsToPubkey`), which is why their fan-out +stays fast and reachable. + +### Status card terminal states (`DmHistoryLoadingCard`) + +One card per protocol at its oldest-loaded boundary. While paging it shows the +protocol tag, "N relays" being asked, and the reach-back date; it is tappable +into a per-relay popup (`DmHistoryRelayDialog`) listing every relay with +`✓` done / `…` stalled / `↓` reaching and how far back each paged. + +Because `exhausted` conflates `done` and `stalled`, the terminal state is split +on `stalledCount` so it can't overclaim (commit `813110cc`): + +- **caught up** (every relay `done`, `stalledCount == 0`) → "All caught up", + lingers ~2.2s then collapses. +- **incomplete** (≥1 stalled) → "Some relays didn't respond · N unreachable", + error-coloured `…`, **stays put** (no collapse), tappable to see which. + +### Reply placeholder (`LoadingReplyNote`) + +A reply whose target message hasn't been paged in yet isn't *missing*, it's +older than the loaded window (and for gift wraps the rumor id isn't even +queryable — only the outer 1059 wrap is). Instead of the generic `BlankNote` +("post not found"), `LoadingReplyNote` actively walks the relevant protocol's +history backward (kicking `advanceAll` each time a page settles) until the +target decrypts (the surrounding `WatchNoteEvent` crossfades the real message in +and disposes this) or the protocol exhausts. Its terminal state mirrors the +card: "Couldn't find this message" + an honest subtitle ("N relays unreachable · +tap to see which" when stalled, "Searched every relay · tap to see" when +genuinely done), tappable into the same per-relay popup. Wired via +`ChatMessageCompose.RenderReply` → `WatchNoteEvent(onBlank = …)`, with the pager +chosen by the parent event's protocol (`DmReplyProtocol.NIP17` / `NIP04`). + +### Diagnostics + +Everything logs under one tag, **`DMPagination`** (debug builds): +`DmRelayDiagnosticsLogger` folds the per-relay connection timeline (REQ sent, +connect/disconnect, CLOSED/NOTICE/OK-fail) into it; `DmRelayLog` prints the +"relays by source" breakdown (NIP-65 in/out, DM list, private storage, local) +per subscription so an unexpected relay can be traced to the list it leaks in +from; and each assembler logs its milestones (paging start, a relay reaching +the bottom / stalling with the reason, the "window settled" summary of +done-vs-still-trying, each marker fire). + +### Related fix: Tor guard-sample self-heal + +`TorService` gained a `noUsableGuards()` check that, on init, inspects Arti's +persisted `guards.json` and wipes the on-disk state if a non-empty guard set has +**zero** usable guards (all `disabled` / `unlisted`). This recovers the +long-standing "can't connect to Tor → relays permanently unreachable" wedge +(Arti disables guards past a 0.7 indeterminate-failure ratio, never re-enables +them, and can't replenish once the 60-slot sample is full). Orthogonal to +pagination, but it lived here because unreachable relays were part of the same +"DM history stuck / relays never answer" symptom this branch chased. + +--- + +## Component map (vs `origin/main`) + +**Paging primitives (quartz, `nip01Core/relay/client/paging/`, `commonMain`)** — +the pure, protocol-level paging *state*; iOS-clean, reusable by any KMP target. +- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor state + pinned floor; + held on the scope's domain object. *(+ `RelayLoadingCursorsTest` in amethyst; the + `until`+`limit` wire contract is covered by `UntilLimitPagingRelayTest` against + the quartz `jvmAndroidTest` geode relay)* +- `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay. + +**Paging orchestrators (commons, `relayClient/paging/`, `jvmAndroid`)** — the +StateFlow-backed, subscription-loading state holders. Moved out of amethyst **and** +out of quartz (per `commons/ARCHITECTURE.md`: the relay-subscription client + +`StateFlow` state holders live in commons) so desktop / CLI / any feed can reuse +them; `jvmAndroid` (uses `java.util.concurrent` + `@Synchronized`) → Android + +Desktop, not iOS. +- `BackwardRelayPager.kt` — the keyless single-active orchestrator the three + history managers `bind` to. *(+ `BackwardRelayPagerTest` state-machine in commons + `jvmTest`)* +- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. +- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ `WindowLoadTrackerIdleTest` in amethyst)* + +**Diagnostics (amethyst)** — `service/relayClient/eoseManagers/DmRelayLog.kt`, +`service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt` — the `DMPagination` logs. + +**Managers / assemblers** +- `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history). +- `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history). +- `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history). +- `FilterNip04DMs.kt` (per-relay `Nip04DmRelayRouting`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. +- `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers. + +**Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android + +Desktop (and any per-relay feed) render the same widgets; CMP `composeResources` +strings, no app-theme / `java.time` deps. +- `RelayReachMarker.kt` — `RelayReachCursor` + sentinels (the hoisted, visibility-driven + paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`. +- `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog + + `historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String` + so each platform supplies its locale date formatter. + +**Android UI (`amethyst/ui/screen/loggedIn/chats/`)** +- `feed/LoadingReplyNote.kt` — history-walking reply placeholder (uses the shared subtitle helpers/dialog). +- `feed/HistoryDateFormat.kt` — `formatHistoryReachDate`, the Android locale formatter passed into the shared card. +- `feed/ChatFeedView.kt` — `markersInGap` + `sentinels` slots. +- `feed/ChatMessageCompose.kt` — reply `onBlank` wiring. +- `privateDM/ChatroomView.kt`, `rooms/feed/ChatroomListFeedView.kt` — assemble cards/markers/sentinels, `BootstrapHistoryWhenEmpty`. +- `res/values/strings.xml` — `chats_reply_*` (the card's `chats_history_*` now live in commons). + +--- + +## Things to scrutinize (review notes) + +1. **`exhausted` conflates `done` + `stalled`** at the manager level. The cards + now distinguish them via `stalledCount`, but other consumers (the scroll + `!loading` gates, `LoadingReplyNote`'s advance loop) treat stalled as + terminal. Intentional (don't hammer dead relays), but confirm it's desired. +2. **`PerRelayLoadTracker.lastActivityMs` is global, not per-relay** — once the + chatty relays finish, a legitimately-slow relay gets the full 15s silence + window and can be marked stalled mid-delivery of a 10000-event page. +3. **"All caught up" can still be technically-true-but-misleading** when + `stalledCount == 0` yet a chat's messages live on a relay *not in the + account's NIP-17 inbox list* — an outbox-coverage gap the card can't detect. +4. **`WindowLoadTracker`'s REQ-aware backstops are dormant** in production + (no live-tail manager sets `tracksReqSends`). Either the live tail should + adopt them or the round model could be slimmer for its current role. +5. **`PAGE_LIMIT = 10000`** caps per-request volume but a single page can still + be a large payload on a dense relay. + +--- + +## Design evolution (historical — superseded, do not trust for current behavior) + +These sections describe earlier iterations, kept for context. The code has +moved past all of them. + +### v1 — time-slice history (superseded by `RelayLoadingCursors`) + +History was first loaded in bounded `since`+`until` **time slices** +(`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band +`[newFloor, previousFloor]`, with a NIP-17 ±2-day wrapper-timestamp margin on +the slice `since` for gift wraps. This bounded re-downloads but still couldn't +tell an empty relay from a gap (an empty slice might sit above older messages), +so the only stop was a 10-year `maxLookback`, and a wide late slice could pull a +20k-event firehose. Replaced by per-relay `until`+`limit` paging. + +### v2 — round-model history + rooms-list "stall-gate" (both removed) + +History paging once used the **round model** (`WindowLoadTracker`): each +`loadMore` issued one page to all active relays and waited for the slowest to +settle before the next — pacing every relay at the slowest one. The rooms list +additionally had a **stall-gate**: an auto-fill loop that widened only while it +brought in new private rooms, stopping once a widen added none (to avoid the +conversation-keyed cascade). + +Both are gone. All history paging is now per-relay independent +(`PerRelayLoadTracker`), and the rooms list pages to exhaustion off marker +visibility like the conversation (commit `98fb8720` dropped the stall-gate; +`60b8629a` / `9f0ecd54` moved gift-wrap and rooms-list history onto the per-relay +model). `WindowLoadTracker` survives **only** as the live-tail completion +barrier. An earlier revision of this doc ("Update 3") still claimed rooms-list +and gift-wrap history used the round model — that is no longer true. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bd30452fe1..2dfa6bd053 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -339,6 +339,7 @@ class AppModules( isPrimaryCoreRpc = true, ) } + NamecoinBackend.ELECTRUMX -> { // Custom servers first (if any). If the user only has the public // defaults configured, primary == defaultElectrumx and the @@ -511,6 +512,9 @@ class AppModules( val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null + // Focused timeline for the DM / gift-wrap loading path (tag: DMPagination). + // val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null + // Coordinates all subscriptions for the Nostr Client val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator( 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 e3dea0f4d1..4d9bd5df60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel @@ -651,6 +652,12 @@ class Account( } } + suspend fun changeAudioVisualizer(style: VisualizerStyle) { + if (settings.changeAudioVisualizer(style)) { + sendNewAppSpecificData() + } + } + suspend fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, 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 92b0224654..a899b593e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm @@ -325,6 +326,15 @@ class AccountSettings( return false } + fun changeAudioVisualizer(style: VisualizerStyle): Boolean { + if (syncedSettings.media.audioVisualizer.value != style) { + syncedSettings.media.audioVisualizer.tryEmit(style) + saveAccountSettings() + return true + } + return false + } + fun defaultNwcWallet(): NwcWalletEntryNorm? { val id = defaultNwcWalletId.value val wallets = nwcWallets.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index f10791a1d5..faf85a36dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList @@ -62,6 +63,10 @@ class AccountSyncedSettings( AccountVideoPlayerPreferences( MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()), ) + val media = + AccountMediaPreferences( + MutableStateFlow(VisualizerStyle.fromName(internalSettings.media.audioVisualizer)), + ) fun toInternal(): AccountSyncedSettingsInternal = AccountSyncedSettingsInternal( @@ -92,6 +97,7 @@ class AccountSyncedSettings( security.addClientTag.value, ), videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), + media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -160,6 +166,11 @@ class AccountSyncedSettings( if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) { videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems) } + + val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer) + if (media.audioVisualizer.value != newAudioVisualizer) { + media.audioVisualizer.tryEmit(newAudioVisualizer) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -253,6 +264,11 @@ class AccountLanguagePreferences( ): String? = languagePreferences.value["$source,$target"] } +@Stable +class AccountMediaPreferences( + val audioVisualizer: MutableStateFlow, +) + @Stable class AccountSecurityPreferences( val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index bdc1ab134e..4bcbf6b2df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -155,6 +155,7 @@ class AccountSyncedSettingsInternal( val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(), val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(), val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(), + val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(), ) @Serializable @@ -197,3 +198,9 @@ class AccountSecurityPreferencesInternal( var sendKind0EventsToLocalRelay: Boolean = false, var addClientTag: Boolean = true, ) + +@Serializable +class AccountMediaPreferencesInternal( + // Stored as VisualizerStyle.name; defaults to CLASSIC (the app's classic audio animation). + var audioVisualizer: String = "CLASSIC", +) 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 b316174452..6c8b5dc779 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -784,6 +785,13 @@ object LocalCache : ILocalCache, ICacheProvider { // Counts the replies replyTo.forEach { it.addReply(note) } + // NIP-18 quote reposts: a note carrying a `q` tag is a quote-repost of the + // quoted note. Count it as a boost so it shows in the quoted note's repost + // counter alongside kind:6/kind:16 reposts. The quoted note is deliberately + // kept out of `replyTo` so the quote still renders as a root post in the home + // feed (see Note.isNewThread); deletion cleanup lives in unlinkAndRemove. + addQuoteBoosts(event, note, replyTo) + refreshNewNoteObservers(note) true @@ -792,6 +800,25 @@ object LocalCache : ILocalCache, ICacheProvider { } } + /** + * Adds [note] as a boost of every event/address referenced by a NIP-18 `q` tag + * (a quote-repost). Targets already in [replyTo] are skipped so a note that both + * replies to and quotes the same note isn't counted twice, and self-quotes are + * ignored. + */ + private fun addQuoteBoosts( + event: Event, + note: Note, + replyTo: List, + ) { + event.taggedQuoteIds().forEach { quotedId -> + val quoted = checkGetOrCreateNote(quotedId) + if (quoted != null && quoted != note && quoted !in replyTo) { + quoted.addBoost(note) + } + } + } + fun consume( event: NipTextEvent, relay: NormalizedRelayUrl?, @@ -2645,20 +2672,67 @@ object LocalCache : ILocalCache, ICacheProvider { } chatroomList.forEach { userHex, room -> + // History floors are pinned per scope on first advance; null means that window never paged + // history, so its cursors hold no position to misalign and nothing needs rewinding. Only the + // bands strictly BELOW a floor are this window's responsibility — a pruned message newer than + // the floor is the always-on live tail's concern, and rewinding history for it would needlessly + // re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor + // filter when accumulating below. + val giftWrapFloor = room.giftWrapHistory.floor + val accountNip04Floor = room.nip04History.floor + room.rooms.map { key, chatroom -> val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() - toBeRemoved.forEach { - childrenToBeRemoved.addAll(removeIfWrap(it)) - unlinkAndRemove(it) + // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. + // Gift wraps page by the OUTER wrap time (from the rumor's host stub); NIP-04 by the event's + // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. + val giftWrapPruned = HashMap() + val accountNip04Pruned = HashMap() + val roomNip04Pruned = HashMap() + // chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a + // kind:4 message, so rooms that never paged conversation history pay nothing. + val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null - childrenToBeRemoved.addAll(it.clearChildLinks()) + toBeRemoved.forEach { note -> + when (val ev = note.event) { + is WrappedEvent -> + if (giftWrapFloor != null) { + val outerUntil = ev.host?.createdAt ?: ev.createdAt + if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } + } + is PrivateDmEvent -> { + val until = ev.createdAt + if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) } + if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) } + } + } + + childrenToBeRemoved.addAll(removeIfWrap(note)) + unlinkAndRemove(note) + + childrenToBeRemoved.addAll(note.clearChildLinks()) } unlinkAndRemove(childrenToBeRemoved) + // Realign the windows so a relay that already paged past (or `done` below) the dropped band + // re-requests it on the next demand-advance instead of skipping the hole. + if (giftWrapPruned.isNotEmpty()) { + room.giftWrapHistory.rewindTo(giftWrapPruned) + Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" } + } + if (accountNip04Pruned.isNotEmpty()) { + room.nip04History.rewindTo(accountNip04Pruned) + Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" } + } + if (roomNip04Pruned.isNotEmpty()) { + chatroom.nip04History.rewindTo(roomNip04Pruned) + Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" } + } + if (toBeRemoved.size > 1) { println( "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", @@ -2787,6 +2861,12 @@ object LocalCache : ILocalCache, ICacheProvider { val noteEvent = note.event + // Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so + // detach this note from every quoted note's boosts here. + noteEvent?.taggedQuoteIds()?.forEach { quotedId -> + getNoteIfExists(quotedId)?.removeBoost(note) + } + if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().forEach { getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquare.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquare.kt new file mode 100644 index 0000000000..6b99a14611 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquare.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * True when the inline-feed audio player should be sized as a square. + * + * Audio is detected two ways: the imeta [isAudioMime] (known up front, no resize) and the runtime + * [tracksAreAudio] (covers bare-URL audio once tracks resolve). Voice notes keep their seek-bar strip, + * and the full-screen dialog fills the screen, so both opt out. + */ +internal fun shouldSquareAudioPlayer( + isFullscreen: Boolean, + isVoiceNote: Boolean, + isAudioMime: Boolean, + tracksAreAudio: Boolean, +): Boolean = !isFullscreen && !isVoiceNote && (isAudioMime || tracksAreAudio) + +/** + * Maximum height of the square. The player is always full width; below this height it is a true + * square (1:1), and above it the height is capped (full width, shorter than square). + */ +internal val AUDIO_SQUARE_MAX_HEIGHT: Dp = 400.dp + +/** Square side in px: the full width, but never taller than the cap. */ +internal fun audioSquareSide( + widthPx: Int, + maxHeightPx: Int, +): Int = minOf(widthPx, maxHeightPx) + +/** + * Sizes the player as a square: full width, height = min(width, [maxHeight]). Only meaningful with a + * bounded incoming width (the feed cell); with an unbounded width it measures normally and does + * nothing, so it is safe to leave in the chain unconditionally if ever needed. + */ +internal fun Modifier.audioSquare(maxHeight: Dp = AUDIO_SQUARE_MAX_HEIGHT): Modifier = + layout { measurable, constraints -> + val width = constraints.maxWidth + // Unbounded width, or first layout pass before the cell has a width: measure normally and let + // the next pass impose the square once a real width arrives. + if (!constraints.hasBoundedWidth || width == 0) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } + val side = audioSquareSide(width, maxHeight.roundToPx()) + val placeable = measurable.measure(Constraints.fixed(width, side)) + layout(width, side) { placeable.placeRelative(0, 0) } + } 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 2cae673cab..30896bee0c 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 @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -38,6 +39,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame @@ -52,6 +54,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradie import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation +import com.vitorpamplona.amethyst.service.playback.composable.wavefront.rememberIsAudioTrack import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.ui.components.getDialogWindow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -129,9 +132,22 @@ fun RenderVideoPlayer( WatchPlaybackErrors(controllerState) + // Audio files have no video dimensions, so without this the player collapses to a thin strip and + // the controls get crammed. Size it square (capped) so the visualizer and controls get room. + // Voice notes keep their seek-bar strip; the full-screen dialog fills the screen. + val tracksAreAudio by rememberIsAudioTrack(controllerState.controller) + val squarePlayer = + shouldSquareAudioPlayer( + isFullscreen = isFullscreen, + isVoiceNote = mediaItem.src.waveformData != null, + isAudioMime = mediaItem.src.mimeType?.startsWith("audio/") == true, + tracksAreAudio = tracksAreAudio, + ) + val playerModifier = if (squarePlayer) borderModifier.audioSquare() else borderModifier + Box( modifier = - borderModifier + playerModifier .onSizeChanged { containerWidth[0] = it.width } .pointerInput(isLive, controllerState) { detectTapGestures( @@ -180,10 +196,14 @@ fun RenderVideoPlayer( modifier = Modifier.align(Alignment.Center), ) + val visualizerStyle by accountViewModel.audioVisualizerFlow().collectAsStateWithLifecycle() AudioPlayingAnimation( - controllerState, - mediaItem.src.waveformData, - Modifier.fillMaxSize().align(Alignment.Center), + controllerState = controllerState, + waveform = mediaItem.src.waveformData, + mediaId = mediaItem.src.videoUri, + style = visualizerStyle, + isAudio = tracksAreAudio, + modifier = Modifier.fillMaxSize().align(Alignment.Center), hasBlurhash = hasBlurhash, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt index 28afe97b33..05da0bf1c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt @@ -20,52 +20,173 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.wavefront +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State 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.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.Tracks +import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.audio.delayedByFrames import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.playback.playerPool.PcmTapRegistry +import kotlinx.coroutines.flow.emptyFlow fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_TYPE_VIDEO } +/** + * Observes whether the controller's current tracks are audio-only, updating live via the player's + * track-change listener. Shared by [AudioPlayingAnimation] (to decide what to draw) and the player + * container (to decide whether to size the player as a square). + */ +@Composable +fun rememberIsAudioTrack(controller: Player): State { + val isAudio = remember(controller) { mutableStateOf(controller.currentTracks.isAudio()) } + DisposableEffect(controller) { + val listener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + isAudio.value = tracks.isAudio() + } + } + controller.addListener(listener) + // Re-read after registering so a track change racing the initial remember{} read isn't missed. + isAudio.value = controller.currentTracks.isAudio() + onDispose { controller.removeListener(listener) } + } + return isAudio +} + +// Output-latency compensation. The spectrum is tapped upstream of the AudioTrack output buffer (and, +// over Bluetooth, the codec/transmission lag downstream of it), so the visual leads the speaker and +// must be delayed to match. Each hop is one 1024-sample FFT frame (~23 ms at 44.1 kHz). Tuned on a +// Pixel 9a against the 120-BPM beat of the synthetic demo clip (1 beat = 500 ms): wired/speaker wants +// ~20 hops, Bluetooth ~27 hops (the extra ~7 hops / ~160 ms is the BT codec latency). +// +// DEVICE/CODEC-SPECIFIC tuned constants. Android exposes no reliable output latency, and runtime +// auto-detection from player.currentPosition was tried and rejected (~3.4x off — it measures decode +// buffer depth, not the perceptual sync point). [rememberIsBluetoothOutput] only switches between +// these two presets by route; the BT figure is a per-codec average, so it may be off on other gear. +private const val FEED_VISUALIZER_DELAY_FRAMES = 20 +private const val BT_VISUALIZER_DELAY_FRAMES = 27 + @Composable fun AudioPlayingAnimation( controllerState: MediaControllerState, waveform: WaveformData?, + mediaId: String, + style: VisualizerStyle, + isAudio: Boolean, modifier: Modifier = Modifier, hasBlurhash: Boolean = false, ) { - if (hasBlurhash) return + if (!isAudio) return - var isAudio by remember { mutableStateOf(controllerState.controller.currentTracks.isAudio()) } + // Dim any blurhash/cover backdrop behind whatever we draw, for contrast and consistency across + // every visible style. OFF draws nothing, so the cover still shows cleanly there. + val drawModifier = if (hasBlurhash) modifier.background(Color.Black.copy(alpha = 0.45f)) else modifier - DisposableEffect(controllerState.controller) { - val listener = - object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - super.onTracksChanged(tracks) - isAudio = tracks.isAudio() - } - } - controllerState.controller.addListener(listener) - onDispose { controllerState.controller.removeListener(listener) } + if (waveform != null) { + // NIP-A0 voice notes etc. that ship a precomputed waveform keep their seek bar. + Waveform(waveform, controllerState, drawModifier) + return } - if (isAudio) { - if (waveform != null) { - Waveform(waveform, controllerState, modifier) - } else { - FakeWaveformAnimation( - mediaControllerState = controllerState, - modifier = modifier, - ) + when (style) { + // CLASSIC keeps its compact fixed-height wave (it does not stretch to fill the square). + VisualizerStyle.CLASSIC -> FakeWaveformAnimation(mediaControllerState = controllerState, modifier = drawModifier) + VisualizerStyle.STATIC -> { + // StaticRenderer ignores the flow and shows a frozen frame. + val empty = remember { emptyFlow() } + AudioVisualizer(style = VisualizerStyle.STATIC, spectrum = empty, modifier = drawModifier.audioVisualizerHeight()) + } + VisualizerStyle.OFF -> Unit + VisualizerStyle.BARS, + VisualizerStyle.WAVES, + VisualizerStyle.RADIAL, + VisualizerStyle.AURORA, + -> { + val bluetooth by rememberIsBluetoothOutput() + val delayFrames = if (bluetooth) BT_VISUALIZER_DELAY_FRAMES else FEED_VISUALIZER_DELAY_FRAMES + val spectrum = remember(mediaId, delayFrames) { PcmTapRegistry.spectrumFor(mediaId).delayedByFrames(delayFrames) } + AudioVisualizer(style = style, spectrum = spectrum, modifier = drawModifier.audioVisualizerHeight()) } } } + +/** + * Tracks whether audio is currently routed to a Bluetooth output, updating live as devices connect or + * disconnect. Bluetooth adds codec/transmission latency downstream of AudioTrack, so the visualizer + * needs a larger delay there. Keys off CONNECTED A2DP/LE output devices — a good proxy since A2DP + * captures media playback, and Android exposes no reliable "active media route" before API 31. + */ +@Composable +private fun rememberIsBluetoothOutput(): State { + val context = LocalContext.current + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } + val isBluetooth = remember { mutableStateOf(audioManager.isBluetoothOutput()) } + DisposableEffect(audioManager) { + val callback = + object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array?) { + isBluetooth.value = audioManager.isBluetoothOutput() + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + isBluetooth.value = audioManager.isBluetoothOutput() + } + } + audioManager?.registerAudioDeviceCallback(callback, null) + onDispose { audioManager?.unregisterAudioDeviceCallback(callback) } + } + return isBluetooth +} + +private fun AudioManager?.isBluetoothOutput(): Boolean { + if (this == null) return false + return getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device -> + device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + device.type == AudioDeviceInfo.TYPE_BLE_HEADSET || + device.type == AudioDeviceInfo.TYPE_BLE_SPEAKER + } +} + +/** + * Fills the available height only when the parent gives a bounded height clearly larger than + * [fallback] (the full-screen media dialog); otherwise uses the fixed [fallback] strip. This avoids + * filling a small bounded feed cell and avoids collapsing to zero under the feed's unbounded lazy + * height. Always fills width. + */ +private fun Modifier.audioVisualizerHeight(fallback: Dp = 72.dp): Modifier = + fillMaxWidth().layout { measurable, constraints -> + val fallbackPx = fallback.roundToPx() + val targetHeight = + if (constraints.hasBoundedHeight) { + // Fill a clearly-large cell (full-screen dialog); otherwise use the fallback strip but + // never exceed the cell, so a small bounded cell can't overflow onto its neighbors. + if (constraints.maxHeight >= fallbackPx * 2) constraints.maxHeight else minOf(fallbackPx, constraints.maxHeight) + } else { + // Unbounded (lazy feed): the fallback strip avoids collapsing to zero. + fallbackPx + } + val placeable = measurable.measure(constraints.copy(minHeight = targetHeight, maxHeight = targetHeight)) + layout(placeable.width, targetHeight) { placeable.placeRelative(0, 0) } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index eaa31e31e6..3a46279de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -22,10 +22,16 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import android.content.Context import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.TeeAudioProcessor import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher @@ -38,18 +44,48 @@ class ExoPlayerBuilder( val videoCache: VideoCache, val dataSourceFactory: DataSource.Factory, ) { - fun build(context: Context): ExoPlayer = - ExoPlayer - .Builder(context) + fun build(context: Context): ExoPlayer { + val sink = PcmTapRegistry.newSink() + + val renderersFactory = + object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink = + DefaultAudioSink + .Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(TeeAudioProcessor(sink))) + .build() + } + + return ExoPlayer + .Builder(context, renderersFactory) .apply { setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) setLoadControl(feedTunedLoadControl()) }.build() .apply { + PcmTapRegistry.registerPlayer(this, sink) + addListener( + object : Player.Listener { + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + PcmTapRegistry.bind(mediaItem?.mediaId, sink) + } + }, + ) + PcmTapRegistry.bind(currentMediaItem?.mediaId, sink) addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) } + } companion object { // Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index dbdc7f1012..5ab3f8cd7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -209,6 +209,7 @@ class ExoPlayerPool( coldPool.add(player) } } else { + PcmTapRegistry.unregisterPlayer(player) player.release() // Release if pool is full. } } @@ -223,8 +224,14 @@ class ExoPlayerPool( warmPool.clear() copy } - warmSnapshot.forEach { it.player.release() } - coldPool.forEach { it.release() } + warmSnapshot.forEach { + PcmTapRegistry.unregisterPlayer(it.player) + it.player.release() + } + coldPool.forEach { + PcmTapRegistry.unregisterPlayer(it) + it.release() + } coldPool.clear() } }.invokeOnCompletion { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt new file mode 100644 index 0000000000..8416f986db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.TeeAudioProcessor +import com.vitorpamplona.amethyst.commons.audio.AudioWindow +import com.vitorpamplona.amethyst.commons.audio.Fft +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.normalizeToPeakInPlace +import com.vitorpamplona.amethyst.commons.audio.toLogBins +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.ConcurrentHashMap + +/** + * A Media3 [TeeAudioProcessor.AudioBufferSink] that turns the decoded 16-bit PCM of the + * currently-playing track into a live [Spectrum] stream via FFT + log binning, emitting into the + * per-media-id flow [PcmTapRegistry] has bound it to. Only 16-bit PCM is handled; offload/passthrough + * bypass the processor chain so the flow simply stays empty (visualizer idles, never stale). + */ +@OptIn(UnstableApi::class) +class SpectrumAudioBufferSink( + private val fftSize: Int = 1024, + private val binCount: Int = 48, +) : TeeAudioProcessor.AudioBufferSink { + /** The per-media-id flow this sink currently feeds; set by [PcmTapRegistry.bind]. */ + @Volatile + internal var output: MutableSharedFlow? = null + + /** The media id this sink is currently bound to; protects its flow from eviction. */ + @Volatile + internal var boundMediaId: String? = null + + private val window = AudioWindow.hann(fftSize) + private val mono = ShortArray(fftSize) + private val scratch = FloatArray(fftSize) + private val re = DoubleArray(fftSize) + private val im = DoubleArray(fftSize) + private val mags = FloatArray(fftSize / 2 + 1) + private var filled = 0 + private var channels = 1 + private var encoding = C.ENCODING_PCM_16BIT + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flush( + sampleRateHz: Int, + channelCount: Int, + encoding: Int, + ) { + this.channels = channelCount.coerceAtLeast(1) + this.encoding = encoding + filled = 0 + output?.resetReplayCache() + } + + override fun handleBuffer(buffer: ByteBuffer) { + if (encoding != C.ENCODING_PCM_16BIT) return + val pcm = buffer.order(ByteOrder.LITTLE_ENDIAN) + while (pcm.remaining() >= 2 * channels) { + val sample = pcm.short // first channel of the frame + if (channels > 1) pcm.position(pcm.position() + (channels - 1) * 2) // skip remaining channels + mono[filled++] = sample + if (filled == fftSize) { + emitSpectrum() + filled = 0 + } + } + } + + // Reuses pre-allocated working buffers; the only per-frame allocation is the published bins. + private fun emitSpectrum() { + AudioWindow.shortsToWindowedInto(mono, window, scratch) + Fft.magnitudesInto(scratch, re, im, mags) + // Skip the DC bin (index 0): toLogBins ignores it, so letting a DC/offset component be the + // peak would scale every audible bin toward zero and wash the spectrum out. + mags.normalizeToPeakInPlace(fromIndex = 1) + output?.tryEmit(Spectrum(mags.toLogBins(binCount))) + } +} + +/** + * Routes each pooled player's decoded-PCM spectrum to a stable per-media-id flow the UI subscribes + * to by media URL. Flows are created on demand (so a UI subscriber can attach before playback binds + * the sink) and the map is bounded: past [MAX_TRACKED_FLOWS], the least-recently-used flows that no + * live sink is currently feeding are evicted, so a long feed session can't grow the map without limit. + */ +@OptIn(UnstableApi::class) +object PcmTapRegistry { + private const val MAX_TRACKED_FLOWS = 64 + + private val lock = Any() + + // access-order LinkedHashMap → eldest (least-recently-used) entries iterate first for eviction. + private val flowsByMediaId = LinkedHashMap>(16, 0.75f, true) + private val sinkByPlayer = ConcurrentHashMap() + + fun newSink(): SpectrumAudioBufferSink = SpectrumAudioBufferSink() + + fun registerPlayer( + playerKey: Any, + sink: SpectrumAudioBufferSink, + ) { + sinkByPlayer[playerKey] = sink + } + + /** Points [sink]'s output at the flow for [mediaId] (the item it now plays), or detaches it. */ + fun bind( + mediaId: String?, + sink: SpectrumAudioBufferSink, + ) { + // Mutate the sink's binding under the same lock that guards flow eviction (flowFor is + // reentrant), so the eviction guard never observes a half-updated boundMediaId. + synchronized(lock) { + sink.boundMediaId = mediaId + sink.output = mediaId?.let { flowFor(it) } + } + } + + fun unregisterPlayer(playerKey: Any) { + synchronized(lock) { + sinkByPlayer.remove(playerKey)?.let { + it.output = null + it.boundMediaId = null + } + } + } + + /** Stable spectrum stream for a media URL; frames arrive once a player binds to it and plays. */ + fun spectrumFor(mediaId: String): Flow = flowFor(mediaId) + + private fun flowFor(mediaId: String): MutableSharedFlow = + synchronized(lock) { + flowsByMediaId.getOrPut(mediaId) { + if (flowsByMediaId.size >= MAX_TRACKED_FLOWS) { + val iter = flowsByMediaId.entries.iterator() + while (iter.hasNext() && flowsByMediaId.size >= MAX_TRACKED_FLOWS) { + val entry = iter.next() + // Never evict a flow a live sink is currently feeding, nor one a composable is + // still collecting — otherwise a later bind() would create a fresh instance the + // sink feeds while the old subscriber keeps collecting the dead one (blank viz). + val fedByLiveSink = sinkByPlayer.values.any { it.boundMediaId == entry.key } + val stillCollected = entry.value.subscriptionCount.value > 0 + if (!fedByLiveSink && !stillCollected) iter.remove() + } + } + MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt new file mode 100644 index 0000000000..4aefae6ba6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.diagnostics + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +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 +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic connection listener for the DM loading path — both NIP-17 gift wraps + * (kind 1059 / 21059) and NIP-04 legacy DMs (kind 4). + * + * It folds the per-relay timeline — REQ sent, connect/disconnect, NOTICE / CLOSED + * rejection and OK failures — into the single `DMPagination` log tag with an + * elapsed-time prefix, so a slow cold boot can be attributed (connection? relay + * response?) and a silent failure to load (e.g. a relay answering CLOSED + * "auth-required" / "restricted") becomes visible. Per-event lines are omitted to + * keep the trail readable, but the NIP-42 AUTH handshake (challenge in, AUTH out, + * accept/reject) IS logged: an auth-walled relay's whole load hinges on whether + * that round-trip closes and a re-REQ follows, so it has to be attributable here. + * + * The connection listener fires for EVERY relay the app talks to (hundreds, under + * the outbox model). To keep this readable we only log relays that are part of the + * DM path: a relay is "learned" the first time we send it a kind:1059/21059/4 + * REQ, and only those relays' connect/auth/notice lines are emitted thereafter. + */ +class DmRelayDiagnosticsLogger( + val client: INostrClient, +) { + private val startMs = System.currentTimeMillis() + + private fun at() = System.currentTimeMillis() - startMs + + // Subscription ids whose REQ carried a DM kind, so we can attribute their EOSE/CLOSED. + private val dmSubIds = mutableSetOf() + + // Relays we've seen on the DM path, so connect/auth/notice noise from the + // hundreds of unrelated follow/outbox relays is filtered out. + private val dmPathRelays = mutableSetOf() + + // Ids of the AUTH events we've sent to DM relays, so the relay's OK can be tagged "AUTH accepted / + // REJECTED" (the OK that decides whether the post-auth re-REQ will actually be served) instead of + // being lost among ordinary event OKs. + private val authEventIds = mutableSetOf() + + private fun isDmRelay(relay: IRelayClient) = relay.url in dmPathRelays + + private val listener = + object : RelayConnectionListener { + override fun onConnecting(relay: IRelayClient) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] connecting ${relay.url.url}" } + } + + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + if (isDmRelay(relay)) { + Log.d(TAG) { "[+${at()}ms] connected ${relay.url.url} (ping ${pingMillis}ms${if (compressed) ", compressed" else ""})" } + } + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is AuthCmd) { + // Our reply to a relay's NIP-42 challenge. Remember the id so the relay's OK can be + // tagged as the auth result below. Only for relays already on the DM path. + if (isDmRelay(relay)) { + authEventIds.add(cmd.event.id) + Log.d(TAG) { "[+${at()}ms] AUTH -> ${relay.url.url} success=$success (id ${cmd.event.id.take(8)})" } + } + return + } + if (!isDmReq(cmdStr)) return + dmPathRelays.add(relay.url) + reqSubId(cmdStr)?.let { dmSubIds.add(it) } + Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is AuthMessage -> + // The relay's NIP-42 challenge. Without it (and the AUTH/OK that follow) an + // auth-walled relay can never serve, so it's the first link to look for. + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] AUTH challenge <- ${relay.url.url} '${msg.challenge.take(40)}'" } + + is NoticeMessage -> + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } + + is ClosedMessage -> + if (msg.subId in dmSubIds) { + Log.d(TAG) { "[+${at()}ms] CLOSED <- ${relay.url.url} sub=${msg.subId} reason='${msg.message}'" } + } + + is OkMessage -> + if (msg.eventId in authEventIds) { + // The auth result: "accepted" means a syncFilters re-REQ should now be served; + // "REJECTED" means this relay will keep refusing and never serve our DMs. + Log.d(TAG) { "[+${at()}ms] AUTH ${if (msg.success) "accepted" else "REJECTED"} <- ${relay.url.url} '${msg.message}'" } + } else if (!msg.success && isDmRelay(relay)) { + Log.d(TAG) { "[+${at()}ms] OK(fail) <- ${relay.url.url} '${msg.message}'" } + } + + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] disconnected ${relay.url.url}" } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] CANNOT CONNECT ${relay.url.url}: $errorMessage" } + } + } + + init { + client.addConnectionListener(listener) + } + + fun destroy() { + client.removeConnectionListener(listener) + } + + companion object { + private const val TAG = "DMPagination" + + // The kinds a DM-path REQ carries: NIP-17 gift wraps (1059 + 21059) and NIP-04 legacy DMs + // (4). Matched exactly against the filter's "kinds" array — never as a substring of the whole + // command, since a pubkey hex or timestamp can incidentally contain "1059" or "4". + private val DM_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, PrivateDmEvent.KIND) + + private val KINDS_ARRAY = Regex("\"kinds\":\\[([0-9,\\s]*)]") + + // Extracts the subscription id from a `["REQ","",{...}]` command string. + private val REQ_SUB_ID = Regex("^\\[\"REQ\",\"([^\"]+)\"") + + private fun reqSubId(cmdStr: String) = REQ_SUB_ID.find(cmdStr)?.groupValues?.get(1) + + /** True only when one of the REQ's `kinds` arrays actually contains a DM kind. */ + private fun isDmReq(cmdStr: String): Boolean = + KINDS_ARRAY.findAll(cmdStr).any { match -> + match.groupValues[1] + .split(',') + .any { it.trim().toIntOrNull() in DM_KINDS } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt new file mode 100644 index 0000000000..39d1370a16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic for "which account is bringing which relays into the DM filters". For each DM + * subscription it prints the account whose relays are used and breaks the relay set down by the + * source list each relay comes from (NIP-65 inbox/outbox, the DM-relay-list, the private-storage + * outbox, local relays). Use it to trace an unexpected relay — e.g. a write-only NIP-65 relay that + * only the NIP-04 (home+dm) path queries — back to the list it leaks in from. + */ +object DmRelayLog { + private const val TAG = "DMPagination" + + fun log( + label: String, + account: Account, + ) = Log.d(TAG) { + val pk = account.userProfile().pubkeyHex.take(8) + val inbox = account.nip65RelayList.inboxFlow.value + val outbox = account.nip65RelayList.outboxFlow.value + val dmList = account.dmRelayList.flow.value + val priv = account.privateStorageRelayList.flow.value + val local = account.localRelayList.flow.value + buildString { + append("[$label] account=$pk relays by source:") + appendSource("nip65In", inbox) + appendSource("nip65Out", outbox) + appendSource("dmList", dmList) + appendSource("private", priv) + appendSource("local", local) + } + } + + private fun StringBuilder.appendSource( + name: String, + relays: Collection, + ) { + if (relays.isNotEmpty()) append(" $name=${relays.map { it.url }}") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 6b28e608be..e654e9df0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot. import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -49,10 +50,17 @@ class AccountQueryState( class AccountFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // Live tail: the recent week of gift wraps, always open at the top for new messages. + val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys) + + // History: older gift wraps, loaded on demand in bounded one-shot slices. + val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys) + val group = listOf( AccountMetadataEoseManager(client, ::allKeys), - AccountGiftWrapsEoseManager(client, ::allKeys), + giftWraps, + giftWrapsHistory, AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), MarmotGroupEventsEoseManager(client, ::allKeys), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 8f5484a1af..1ddf8e8028 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -20,8 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -29,58 +33,68 @@ 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 com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * Always-on **live tail** for the account's NIP-17 gift wraps (kind 1059). It keeps a fixed + * one-week floor with no upper bound, so the messages list is usable on boot and new incoming + * messages always stream in. It deliberately never widens: pulling older history is the job of + * [AccountGiftWrapsHistoryEoseManager], which fetches the past in bounded, one-shot slices so a + * widen never re-streams what this tail already holds. + */ class AccountGiftWrapsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() + // The initial-load tracker drives the boot spinner: it stays true until every DM relay has + // settled (EOSE / CLOSED / cannot-connect) on the one-week tail. + private val windowLoad = WindowLoadTracker("giftwrap.live") + val loadingMore: StateFlow = windowLoad.loading + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // Only loads DMs if the account is writeable - return if (key.account.isWriteable()) { - val relays = key.account.dmRelays.flow.value - Log.d("MarmotDbg") { - "AccountGiftWrapsEoseManager.updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… " + - "subscribing kind:1059 on ${relays.size} dmRelay(s): ${relays.map { it.url }}" - } - relays.flatMap { relay -> - filterGiftWrapsToPubkey( - relay = relay, - pubkey = user(key).pubkeyHex, - since = since?.get(relay)?.time, - ) - } - } else { - Log.d("MarmotDbg") { "AccountGiftWrapsEoseManager.updateFilter: account not writeable, skipping" } - emptyList() + if (!key.account.isWriteable()) { + windowLoad.setExpectedRelays(emptySet()) + return emptyList() + } + val relays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays(relays.toSet()) + val sinceTime = DmHistoryTuning.recentBoundary() + DmRelayLog.log("giftwrap.live", key.account) + Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (no until) on ${relays.size} relay(s): ${relays.map { it.url }}" } + return relays.flatMap { relay -> + filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = sinceTime) } } - val userJobMap = mutableMapOf>() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } }, ) - return super.newSub(key) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) } override fun endSub( @@ -90,4 +104,8 @@ class AccountGiftWrapsEoseManager( super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } + + companion object { + private const val TAG = "DMPagination" + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt new file mode 100644 index 0000000000..93f94d2af0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +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.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 +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail + * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay, on demand**. + * + * There is no proactive walk: each relay advances exactly one page when the UI calls [advance] for it, + * and then **parks** at its window limit. The on-screen window-limit markers are the drivers — a relay + * pages only while its marker is visible, and keeps paging (page after page) as long as it stays visible + * (see the rooms-list / conversation feed views). So a spam-dense relay never floods: the user has to + * scroll through its messages to pull more, and nothing is fetched while its marker is off screen. + * + * The per-relay cursors live on the account's [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList] + * (so they share the lifetime of the cached gift-wraps); this class binds the single-active + * [BackwardRelayPager] orchestrator to them on [newSub], builds the gift-wrap REQ filters, and forwards + * relay callbacks into the pager. A relay is *done* once it answers an empty page; one that won't answer + * (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] flips once every relay + * is either done or stalled. + */ +class AccountGiftWrapsHistoryEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + private val pager = BackwardRelayPager("giftwrap.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + if (!key.account.isWriteable()) return emptyList() + // Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a + // page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't + // re-REQ it — it stays parked until the UI advances it again. + val relays = key.account.dmRelays.flow.value + val armed = pager.armedRelays(relays) + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("giftwrap.history", key.account) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + Log.d(TAG) { "[giftwrap.history] REQ ${relay.url} until ${daysAgo(until)}d, limit=${pager.pageLimit}" } + filterGiftWrapsToPubkey(relay = relay, pubkey = key.account.userProfile().pubkeyHex, since = null, until = until, limit = pager.pageLimit) + } + } + + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d(TAG) { "[giftwrap.history] advanceAll (empty-feed bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: AccountQueryState): Subscription { + // Repoint the single-active orchestrator at this account's gift-wrap cursors (on its ChatroomList) + // and the relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(key.account.chatroomList.giftWrapHistory, key.account.scope) { key.account.dmRelays.flow.value } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: AccountQueryState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.giftWrapHistory + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } + } + // No auto-advance: the relay parks here until its marker asks for the next page. + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } + + companion object { + private const val TAG = "DMPagination" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifter.kt new file mode 100644 index 0000000000..cb620f8294 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifter.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import com.vitorpamplona.amethyst.commons.audio.AudioWindow +import kotlin.math.roundToInt + +/** + * Pure-Kotlin pitch shifter for mono float PCM. + * + * Shifts pitch by a frequency [ratio][shift] while preserving the original + * duration, by combining two textbook DSP steps: + * + * 1. **WSOLA time-stretch** (Waveform Similarity Overlap-Add) stretches the + * signal in time by the pitch ratio without changing its pitch. Unlike plain + * overlap-add, WSOLA searches a small window around each analysis frame for + * the offset that best correlates with the natural continuation of the + * previous frame, which avoids the phase-discontinuity "warble" of OLA. + * 2. **Linear resampling** back to the original length raises (or lowers) the + * pitch by the same ratio and restores the duration. + * + * This is a clean-room implementation of the standard WSOLA algorithm; it + * carries no third-party code and keeps the app free of GPL-licensed DSP + * dependencies. Quality is more than sufficient for voice anonymization, whose + * pitch factors are modest (~0.7x–1.5x). + */ +class PitchShifter( + /** Analysis/synthesis window length in samples. ~46 ms at 44.1 kHz. */ + private val frameSize: Int = 2048, + /** Half-radius (in samples) of the WSOLA cross-correlation search. */ + private val seekWindow: Int = 256, +) { + private val synthesisHop = frameSize / 2 + private val overlap = frameSize - synthesisHop + private val window = AudioWindow.hann(frameSize) + + /** + * Returns [input] pitch-shifted by [frequencyRatio] with the same length. + * + * @param frequencyRatio output-to-input frequency multiplier. `> 1` raises + * the pitch, `< 1` lowers it, `1.0` is a no-op copy. + */ + fun shift( + input: FloatArray, + frequencyRatio: Double, + ): FloatArray { + if (input.isEmpty()) return FloatArray(0) + if (frequencyRatio == 1.0) return input.copyOf() + + // Stretch in time by the ratio (pitch unchanged, length scaled), then + // resample back to the original length (pitch scaled by the ratio). + val stretched = timeStretch(input, frequencyRatio) + return resampleLinear(stretched, input.size) + } + + /** + * WSOLA time-stretch. Output length is approximately `input.size * stretch`. + */ + private fun timeStretch( + input: FloatArray, + stretch: Double, + ): FloatArray { + if (input.size < frameSize + seekWindow) { + // Too short for the overlap machinery; resampling alone still yields + // the requested length change without crashing on tiny clips. + return resampleLinear(input, (input.size * stretch).roundToInt().coerceAtLeast(1)) + } + + val analysisHop = synthesisHop / stretch + val outLength = (input.size * stretch).roundToInt() + frameSize + val out = FloatArray(outLength) + val norm = FloatArray(outLength) + + // Integer start of the current analysis frame inside the input. + var analysisStart = 0 + // Fractional nominal position; the next frame is sought around it. + var nominal = 0.0 + var synthesisPos = 0 + + while (analysisStart + frameSize < input.size && synthesisPos + frameSize < outLength) { + // Overlap-add the windowed analysis frame at the synthesis position. + for (i in 0 until frameSize) { + val w = window[i] + out[synthesisPos + i] += input[analysisStart + i] * w + norm[synthesisPos + i] += w + } + + synthesisPos += synthesisHop + nominal += analysisHop + + // The samples that should naturally follow what we just wrote: the + // tail of the current frame advanced by one synthesis hop. + val naturalStart = analysisStart + synthesisHop + if (naturalStart + overlap >= input.size) break + + // Search around the nominal next analysis position for the offset + // whose head best correlates with that natural continuation. + val center = nominal.roundToInt() + analysisStart = bestMatchOffset(input, naturalStart, center) + } + + // Normalize where windows overlapped to keep unity gain at the edges. + for (i in out.indices) { + if (norm[i] > 1e-6f) out[i] /= norm[i] + } + // Trim the padding tail to the expected stretched length. + val expected = (input.size * stretch).roundToInt() + return if (expected < out.size) out.copyOf(expected) else out + } + + /** + * Finds, within ±[seekWindow] of [center], the input offset whose + * `overlap`-length head best cross-correlates with the `overlap`-length + * segment starting at [naturalStart]. Returns a bounds-safe offset. + */ + private fun bestMatchOffset( + input: FloatArray, + naturalStart: Int, + center: Int, + ): Int { + val low = (center - seekWindow).coerceAtLeast(0) + val high = (center + seekWindow).coerceAtMost(input.size - frameSize - 1) + if (high <= low) return low.coerceIn(0, input.size - frameSize - 1) + + var bestOffset = low + var bestCorr = Double.NEGATIVE_INFINITY + for (offset in low..high) { + var corr = 0.0 + for (i in 0 until overlap) { + corr += input[naturalStart + i].toDouble() * input[offset + i] + } + if (corr > bestCorr) { + bestCorr = corr + bestOffset = offset + } + } + return bestOffset + } + + /** Linear-interpolation resample of [input] to exactly [targetLength] samples. */ + private fun resampleLinear( + input: FloatArray, + targetLength: Int, + ): FloatArray { + if (targetLength <= 0 || input.isEmpty()) return FloatArray(0) + if (input.size == 1) return FloatArray(targetLength) { input[0] } + + val out = FloatArray(targetLength) + val step = (input.size - 1).toDouble() / (targetLength - 1).coerceAtLeast(1) + for (i in 0 until targetLength) { + val pos = i * step + val idx = pos.toInt() + val frac = pos - idx + val a = input[idx] + val b = if (idx + 1 < input.size) input[idx + 1] else a + out[i] = (a + (b - a) * frac).toFloat() + } + return out + } +} 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 f9fd2fc65a..0a53b6412e 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,19 +25,14 @@ import android.media.MediaCodecInfo import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMuxer -import be.tarsos.dsp.AudioDispatcher -import be.tarsos.dsp.AudioEvent -import be.tarsos.dsp.AudioProcessor -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.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.io.File +import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.abs @@ -57,9 +52,10 @@ data class AnonymizedResult( /** * Processes audio files to alter voice characteristics for privacy. * - * Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with - * rate transposition to shift pitch while preserving duration. Note that in TarsosDSP, - * pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch. + * Uses the in-house [PitchShifter] (WSOLA time-stretch + resampling) to shift pitch + * while preserving duration. Preset pitch factors work inversely: factor < 1 raises + * pitch, factor > 1 lowers pitch — so the shifter receives the reciprocal as its + * output-to-input frequency ratio. */ class VoiceAnonymizer { companion object { @@ -73,7 +69,7 @@ class VoiceAnonymizer { * * The process involves three stages: * 1. Decode input audio to PCM (0-30% progress) - * 2. Apply pitch shifting with TarsosDSP (30-70% progress) + * 2. Apply pitch shifting with [PitchShifter] (30-70% progress) * 3. Encode processed audio to AAC (70-100% progress) * * @param inputFile Source audio file (supports formats decodable by MediaCodec) @@ -101,10 +97,8 @@ class VoiceAnonymizer { onProgress(progress * 0.3f) } - val processedPcm = - processPcmWithTarsos(pcmData, preset, sampleRate) { progress -> - onProgress(0.3f + progress * 0.4f) - } + val processedPcm = applyPitchShift(pcmData, preset) + onProgress(0.7f) val waveform = extractWaveform(processedPcm, sampleRate) @@ -114,6 +108,10 @@ class VoiceAnonymizer { onProgress(1f) Result.success(AnonymizedResult(outputFile, waveform, duration)) + } catch (e: CancellationException) { + // Let cancellation propagate so structured concurrency works (e.g. the + // upload screen being dismissed cancels this job). + throw e } catch (e: Exception) { Log.e(TAG, "Failed to anonymize audio", e) Result.failure(e) @@ -176,7 +174,10 @@ class VoiceAnonymizer { val inputBufferIndex = decoder.dequeueInputBuffer(10000) if (inputBufferIndex < 0) return - val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!! + val inputBuffer = + requireNotNull(decoder.getInputBuffer(inputBufferIndex)) { + "Decoder input buffer $inputBufferIndex was null (codec in async mode?)" + } val sampleSize = extractor.readSampleData(inputBuffer, 0) if (sampleSize < 0) { @@ -232,7 +233,10 @@ class VoiceAnonymizer { } private fun extractPcmSamples(outputBufferIndex: Int) { - val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!! + val outputBuffer = + requireNotNull(decoder.getOutputBuffer(outputBufferIndex)) { + "Decoder output buffer $outputBufferIndex was null (codec in async mode?)" + } val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() while (shortBuffer.hasRemaining()) { pcmSamples.add(shortBuffer.get() / 32768f) @@ -292,11 +296,9 @@ class VoiceAnonymizer { release() } - private fun processPcmWithTarsos( + private fun applyPitchShift( pcmData: FloatArray, preset: VoicePreset, - sampleRate: Int, - onProgress: (Float) -> Unit, ): FloatArray { val baseFactor = preset.pitchFactor val factor = @@ -311,75 +313,11 @@ class VoiceAnonymizer { baseFactor } } - val totalSamples = pcmData.size - val processedSamples = ArrayList(totalSamples) - val wsola = - WaveformSimilarityBasedOverlapAdd( - WaveformSimilarityBasedOverlapAdd.Parameters.musicDefaults( - factor, - sampleRate.toDouble(), - ), - ) - val rateTransposer = RateTransposer(factor) - - val bufferSize = wsola.inputBufferSize - val overlap = wsola.overlap - - val tarsosDspFormat = - TarsosDSPAudioFormat( - sampleRate.toFloat(), - 16, - 1, - true, - false, - ) - - val collector = - object : AudioProcessor { - override fun process(audioEvent: AudioEvent): Boolean { - val buffer = audioEvent.floatBuffer - for (i in 0 until audioEvent.bufferSize) { - processedSamples.add(buffer[i]) - } - return true - } - - override fun processingFinished() { - // No-op: no cleanup needed - } - } - - val dispatcher = - AudioDispatcher( - FloatArrayAudioInputStream(pcmData, tarsosDspFormat, pcmData.size.toLong()), - bufferSize, - overlap, - ) - - wsola.setDispatcher(dispatcher) - dispatcher.addAudioProcessor(wsola) - dispatcher.addAudioProcessor(rateTransposer) - dispatcher.addAudioProcessor(collector) - - var samplesProcessed = 0 - val progressProcessor = - object : AudioProcessor { - override fun process(audioEvent: AudioEvent): Boolean { - samplesProcessed += audioEvent.bufferSize - onProgress((samplesProcessed.toFloat() / totalSamples).coerceIn(0f, 1f)) - return true - } - - override fun processingFinished() { - // No-op: no cleanup needed - } - } - dispatcher.addAudioProcessor(progressProcessor) - - dispatcher.run() - - return processedSamples.toFloatArray() + // Presets express factor inversely (factor > 1 lowers pitch), so the + // output-to-input frequency ratio is the reciprocal. + val frequencyRatio = 1.0 / factor + return PitchShifter().shift(pcmData, frequencyRatio) } private fun extractWaveform( @@ -421,7 +359,10 @@ class VoiceAnonymizer { val inputBufferIndex = encoder.dequeueInputBuffer(10000) if (inputBufferIndex < 0) return - val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!! + val inputBuffer = + requireNotNull(encoder.getInputBuffer(inputBufferIndex)) { + "Encoder input buffer $inputBufferIndex was null (codec in async mode?)" + } inputBuffer.clear() val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset) @@ -445,7 +386,7 @@ class VoiceAnonymizer { private fun queueSampleData( inputBufferIndex: Int, - inputBuffer: java.nio.ByteBuffer, + inputBuffer: ByteBuffer, samplesToWrite: Int, ) { writePcmSamplesToBuffer(inputBuffer, samplesToWrite) @@ -456,7 +397,7 @@ class VoiceAnonymizer { } private fun writePcmSamplesToBuffer( - inputBuffer: java.nio.ByteBuffer, + inputBuffer: ByteBuffer, samplesToWrite: Int, ) { for (i in 0 until samplesToWrite) { @@ -497,13 +438,16 @@ class VoiceAnonymizer { } private fun processOutputBuffer(outputBufferIndex: Int) { - val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!! + val outputBuffer = + requireNotNull(encoder.getOutputBuffer(outputBufferIndex)) { + "Encoder output buffer $outputBufferIndex was null (codec in async mode?)" + } writeToMuxerIfReady(outputBuffer) encoder.releaseOutputBuffer(outputBufferIndex, false) checkForEndOfStream() } - private fun writeToMuxerIfReady(outputBuffer: java.nio.ByteBuffer) { + private fun writeToMuxerIfReady(outputBuffer: ByteBuffer) { if (isMuxerStarted && bufferInfo.size > 0) { outputBuffer.position(bufferInfo.offset) outputBuffer.limit(bufferInfo.offset + bufferInfo.size) @@ -524,7 +468,7 @@ class VoiceAnonymizer { setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) } - private fun encodePcmToAac( + private suspend fun encodePcmToAac( pcmData: FloatArray, sampleRate: Int, outputFile: File, @@ -541,7 +485,7 @@ class VoiceAnonymizer { val inputFeeder = EncoderInputFeeder(encoder, pcmData, sampleRate, onProgress) val outputDrainer = EncoderOutputDrainer(encoder, muxer) - while (!outputDrainer.isDone) { + while (!outputDrainer.isDone && currentCoroutineContext().isActive) { inputFeeder.feedInput() outputDrainer.drainOutput() } @@ -559,44 +503,3 @@ class VoiceAnonymizer { release() } } - -private class FloatArrayAudioInputStream( - private val floatArray: FloatArray, - private val format: TarsosDSPAudioFormat, - private val frameLength: Long, -) : be.tarsos.dsp.io.TarsosDSPAudioInputStream { - private var position = 0 - - override fun getFormat(): TarsosDSPAudioFormat = format - - override fun getFrameLength(): Long = frameLength - - override fun read( - buffer: ByteArray, - offset: Int, - length: Int, - ): Int { - val converter = TarsosDSPAudioFloatConverter.getConverter(format) - val floatBuffer = FloatArray(length / 2) - val samplesToRead = minOf(floatBuffer.size, floatArray.size - position) - - if (samplesToRead <= 0) return -1 - - System.arraycopy(floatArray, position, floatBuffer, 0, samplesToRead) - position += samplesToRead - - converter.toByteArray(floatBuffer, samplesToRead, buffer, offset) - return samplesToRead * 2 - } - - override fun skip(bytesToSkip: Long): Long { - val samplesToSkip = (bytesToSkip / 2).toInt() - val actualSkip = minOf(samplesToSkip, floatArray.size - position) - position += actualSkip - return actualSkip.toLong() * 2 - } - - override fun close() { - // No-op: no cleanup needed - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt new file mode 100644 index 0000000000..d986429475 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.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.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier + +/** + * Strategy for rendering a note quoted inline in another note's text (a + * `nostr:nevent1...`/`nostr:note1...` mention with preview budget left, or a + * legacy `#[index]` event tag). + * + * Both rich-text paths (plain and markdown) funnel through [DisplayFullNote], + * which reads [LocalInlineQuoteRenderer], so providing a different renderer + * re-skins inline quotes for a whole subtree without threading a parameter + * through the parser stack. Chat bubbles use this to keep a quoted chat + * message in the chat reply design instead of the default quoted-note card + * (see `chatInlineQuoteRenderer`). + * + * A renderer draws only the quoted note itself; any leftover characters + * around the mention stay with the caller ([DisplayFullNote]). + */ +fun interface InlineQuoteRenderer { + @Composable + fun Render( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, + ) +} + +/** The quoted-note card used everywhere outside chats. */ +val DefaultInlineQuoteRenderer = + InlineQuoteRenderer { note, quotesLeft, backgroundColor, accountViewModel, nav -> + NoteCompose( + baseNote = note, + modifier = MaterialTheme.colorScheme.innerPostModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.LINE, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + +val LocalInlineQuoteRenderer = compositionLocalOf { DefaultInlineQuoteRenderer } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt new file mode 100644 index 0000000000..ea0f43dffc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +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.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.richtext.MathParser +import ru.noties.jlatexmath.JLatexMathDrawable + +/** + * Renders a LaTeX formula (the inner text of a `$...$` / `$$...$$` span) as an + * image, tinted to follow the current text color and sized to the current font. + * + * JLaTeXMath throws on malformed input, so anything it can't parse falls back to + * the raw delimited text rather than crashing the feed. + */ +@Composable +fun LatexEquation( + latex: String, + displayMode: Boolean, + leading: String = "", + trailing: String = "", +) { + val color = LocalContentColor.current.toArgb() + val density = LocalDensity.current + val fontSize = LocalTextStyle.current.fontSize + + // Inline math has no legitimate `\\` line break, so a doubled backslash is an + // over-escaped command (`\\ldots` → `\ldots`). Without this JLaTeXMath would + // read `\\` as a line break and render `ldots`/`cdots` as literal letters. + val formula = if (displayMode) latex else MathParser.collapseDoubledBackslashes(latex) + + // Display math renders a touch larger than the surrounding prose, matching + // the visual weight KaTeX gives block equations. + val textSizePx = + with(density) { + val base = if (fontSize.isUsable()) fontSize.toPx() else 16.sp.toPx() + if (displayMode) base * 1.2f else base + } + + val drawable = + remember(formula, color, textSizePx) { + runCatching { + JLatexMathDrawable + .builder(formula) + .textSize(textSizePx) + .color(color) + .align(JLatexMathDrawable.ALIGN_LEFT) + .build() + }.getOrNull() + } + + if (drawable == null) { + // Couldn't parse — show the raw delimited source so nothing is lost. + Text(leading + (if (displayMode) "$$$latex$$" else "$$latex$") + trailing) + return + } + + // Baseline alignment. The parent FlowRow centers each word vertically, so a + // tight equation bitmap floats above the line — most of a formula's mass sits + // above the baseline, so centering its box lifts the baseline above the text. + // We pad the box so that, once centered, the equation's baseline coincides + // with a surrounding text word's baseline. With text ascent/descent A/Dsc and + // icon total height H and depth-below-baseline Dp, the padded box satisfies + // `baseline-from-center = (A - Dsc) / 2` — the same value a Text box has — when + // pad = (A - Dsc) - H + 2*Dp (on top if positive, on bottom if negative). + val resolvedStyle = LocalTextStyle.current.let { if (fontSize.isUsable()) it else it.copy(fontSize = 16.sp) } + val measurer = rememberTextMeasurer() + val (drawTopPx, boxHeightPx) = + remember(drawable, resolvedStyle) { + val text = measurer.measure("Mg", resolvedStyle) + val ascent = text.firstBaseline + val descent = text.size.height - ascent + val iconHeight = drawable.intrinsicHeight.toFloat() + val iconDepth = drawable.icon().iconDepth.toFloat() + val pad = (ascent - descent) - iconHeight + 2f * iconDepth + maxOf(pad, 0f) to (iconHeight + maxOf(pad, 0f) + maxOf(-pad, 0f)) + } + + val widthDp = with(density) { drawable.intrinsicWidth.toDp() } + val boxHeightDp = with(density) { boxHeightPx.toDp() } + + // Wide display equations can overflow the column; allow them to scroll + // horizontally instead of being clipped. + val equationSize = Modifier.size(widthDp, boxHeightDp) + val equationModifier = if (displayMode) Modifier.horizontalScroll(rememberScrollState()).then(equationSize) else equationSize + + // Row keeps leading/trailing punctuation (the `(` and `)` in `($x$)`) hugging + // the equation rather than getting a word-gap from the parent FlowRow. + Row(verticalAlignment = Alignment.CenterVertically) { + if (leading.isNotEmpty()) { + Text(leading) + } + Canvas(modifier = equationModifier) { + drawIntoCanvas { canvas -> + val native = canvas.nativeCanvas + val checkpoint = native.save() + // Position the icon's baseline on the text baseline within the padded box. + native.translate(0f, drawTopPx) + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(native) + native.restoreToCount(checkpoint) + } + } + if (trailing.isNotEmpty()) { + Text(trailing) + } + } +} + +private fun TextUnit.isUsable(): Boolean = this != TextUnit.Unspecified && this.type == TextUnitType.Sp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 9822346a0b..e7145aa8cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.MathSegment import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment import com.vitorpamplona.amethyst.commons.richtext.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PdfSegment @@ -107,10 +108,8 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.toShortDisplay -import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType 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 @@ -118,7 +117,6 @@ import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder -import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri import kotlinx.coroutines.Dispatchers @@ -394,6 +392,10 @@ fun RenderTextParagraph( FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(spaceWidth), + // Center items on the cross axis so a taller item (an equation, whose image + // is taller than a text line) sits centered on the line instead of hanging + // below the baseline. No-op for the common all-text row. + itemVerticalAlignment = Alignment.CenterVertically, ) { paragraph.words.forEach { word -> renderWord(word) @@ -505,6 +507,8 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) + is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) + is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) @@ -547,6 +551,7 @@ private fun RenderWordWithPreview( is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) + is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) is HashTagSegment -> HashTag(word, nav) @@ -714,22 +719,9 @@ fun DisplayFullNote( accountViewModel: AccountViewModel, nav: INav, ) { - NoteCompose( - baseNote = note, - modifier = MaterialTheme.colorScheme.innerPostModifier, - isQuotedNote = true, - unPackReply = ReplyRenderType.LINE, - quotesLeft = quotesLeft - 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + LocalInlineQuoteRenderer.current.Render(note, quotesLeft, backgroundColor, accountViewModel, nav) - extraChars?.let { - Text( - it, - ) - } + extraChars?.let { Text(it) } } @Composable @@ -987,16 +979,7 @@ private fun DisplayNoteFromTag( nav: INav, ) { if (canPreview && quotesLeft > 0) { - NoteCompose( - baseNote = baseNote, - modifier = MaterialTheme.colorScheme.innerPostModifier, - isQuotedNote = true, - unPackReply = ReplyRenderType.LINE, - quotesLeft = quotesLeft - 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + LocalInlineQuoteRenderer.current.Render(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } else { ClickableTextPrimary( text = "@${baseNote.idNote().toShortDisplay()}", 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 b05ec5d244..95264c003d 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 @@ -170,6 +170,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsS import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen 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.AudioVisualizerSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen @@ -375,6 +376,7 @@ fun BuildNavigation( composableFromEnd { ComposeSettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } 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 f0faa2f41e..9c592b4e8a 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 @@ -325,6 +325,8 @@ sealed class Route { @Serializable object ReactionsSettings : Route() + @Serializable object AudioVisualizerSettings : Route() + @Serializable object BottomBarSettings : Route() @Serializable object HomeTabsSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index cfa7304b62..dc8a448dce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +46,7 @@ import androidx.compose.ui.text.AnnotatedString 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.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User @@ -52,6 +56,7 @@ 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.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize @@ -116,13 +121,23 @@ fun WatchResponses( val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) if (suggestions.isNotEmpty()) { + // Snapshot once per result list, not per row. + val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } + LazyColumn( contentPadding = PaddingValues(top = 10.dp), modifier = modifier, state = listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - UserLine(item, accountViewModel, trailingContent) { onSelect(item) } + val trailing = + trailingContent + ?: if (item.pubkeyHex in priority) { + { InThisChatChip() } + } else { + null + } + UserLine(item, accountViewModel, trailing) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, ) @@ -133,6 +148,22 @@ fun WatchResponses( } } +@Composable +private fun InThisChatChip() { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringRes(R.string.user_suggestion_in_this_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + @Composable fun UserLine( baseUser: User, 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 f066303433..76858b0776 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 @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client @@ -58,10 +59,36 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** + * Moves users whose pubkey is in [priority] to the top of [found], + * preserving the relative order everywhere else (stable sort). Reorders + * only — it never adds or removes entries, so priority keys whose users + * didn't match the search have no effect. + */ +fun rankPriorityFirst( + found: List, + priority: Set, +): List = + if (priority.isEmpty()) { + found + } else { + found.sortedByDescending { it.pubkeyHex in priority } + } + +/** + * Drives the @-mention autocomplete dropdown: searches the local cache, + * relays, and NIP-05 identifiers for the word currently being typed. + * + * [priorityPubkeys] is a live supplier of pubkeys to rank first in the + * results — pass the current conversation's participants (NIP-17 room + * users, public-chat authors, MLS group members, …) so they beat + * network-wide matches. Ranking only; it never filters anyone out. + */ @Stable class UserSuggestionState( val account: Account, val nip05Client: INip05Client, + val priorityPubkeys: () -> Set = { emptySet() }, ) { val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") @@ -158,7 +185,10 @@ class UserSuggestionState( } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { - account.cache.findUsersStartingWith(prefix, account) + rankPriorityFirst( + account.cache.findUsersStartingWith(prefix, account), + priorityPubkeys(), + ) } } else { emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt index be91881bb0..791bbfc67a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt @@ -36,10 +36,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.utils.RandomInstance @Composable fun ZapPollField(postViewModel: ShortNotePostViewModel) { - val optionsList = postViewModel.zapPollOptions + // Shares the same `pollOptions` text fields as the regular poll so switching poll types keeps the text. + val optionsList = postViewModel.pollOptions Column( modifier = Modifier.fillMaxWidth(), ) { @@ -55,8 +58,7 @@ fun ZapPollField(postViewModel: ShortNotePostViewModel) { Button( onClick = { - // postViewModel.pollOptions[postViewModel.pollOptions.size] = "" - optionsList[optionsList.size] = "" + optionsList[optionsList.size] = OptionTag(RandomInstance.randomChars(6), "") }, border = BorderStroke( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt index ded27476c9..64a5e979cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt @@ -47,7 +47,7 @@ fun ZapPollOption( val deleteIcon: @Composable (() -> Unit) = { IconButton( onClick = { - pollViewModel.removeZapPollOption(optionIndex) + pollViewModel.removePollOption(optionIndex) }, ) { Icon( @@ -59,9 +59,9 @@ fun ZapPollOption( OutlinedTextField( modifier = Modifier.weight(1F), - value = pollViewModel.zapPollOptions[optionIndex] ?: "", + value = pollViewModel.pollOptions[optionIndex]?.label ?: "", onValueChange = { - pollViewModel.updateZapPollOption(optionIndex, it) + pollViewModel.updatePollOption(optionIndex, it) }, label = { Text( 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 956d8e8f97..fc1b395991 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 @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -1309,6 +1311,13 @@ class AccountViewModel( account.changeVideoPlayerButtonItems(items) } + fun audioVisualizerFlow(): StateFlow = account.settings.syncedSettings.media.audioVisualizer + + fun changeAudioVisualizer(style: VisualizerStyle) = + launchSigner { + account.changeAudioVisualizer(style) + } + fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, @@ -1619,6 +1628,11 @@ class AccountViewModel( replyToInnerEventId: HexKey? = null, replyToInnerAuthorPubKey: HexKey? = null, ) { + // Rewrites @npub…/@nprofile… mentions into nostr: URIs and collects + // the referenced users as p-tags. Lives here (not in the composer) so + // every send path gets mention handling. + val tagger = NewMessageTagger(text, null, null, this) + tagger.run() // Inner event construction lives on MarmotManager so CLI and UI don't drift. // persistOwn=false because Account.sendMarmotGroupMessage routes the outer // event through LocalCache which already handles own-message display. @@ -1626,10 +1640,11 @@ class AccountViewModel( account.marmotManager ?.buildTextMessage( nostrGroupId = nostrGroupId, - text = text, + text = tagger.message, replyToEventId = replyToInnerEventId, replyToAuthorPubKey = replyToInnerAuthorPubKey, persistOwn = false, + mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) ?: return val relays = account.marmotGroupRelays(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt index 637f0f0d49..0d4c791244 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -118,6 +117,7 @@ class NewCalendarEventViewModel : ViewModel() { participants.clear() participants.addAll(existing.participants().map { it.pubKey }) } + is CalendarDateSlotEvent -> { isAllDay.value = true title.value = existing.title().orEmpty() @@ -162,7 +162,7 @@ class NewCalendarEventViewModel : ViewModel() { if (!::account.isInitialized) return false isUploadingImage.value = true try { - val server = account.settings.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] + val server = account.settings.defaultFileServer val result = UploadOrchestrator().upload( uri = uri, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 9fc8f3aa96..dd6384eb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -58,8 +58,23 @@ fun RefreshingChatroomFeedView( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, scrollStateKey: String? = null, + // Opt-in hook handed the feed's scroll state, so a specific screen (e.g. private DMs) can + // attach scroll-driven loading. No-op for the public-chat / channel callers that don't paginate. + listStateObserver: @Composable (LazyListState) -> Unit = {}, + // Optional footer rendered at the oldest end of the thread (a "load more" / spinner affordance). + // Null for callers that load their whole history at once (public chats / channels). + olderBoundary: (@Composable () -> Unit)? = null, + // Optional per-gap hook: invoked between each message and its next-older neighbour with their + // createdAt bounds, so a caller (private DMs) can draw per-relay paging markers at the depth each + // relay has reached. No-op for callers without per-relay progress. + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + // Optional hoisted load driver: handed the loaded message list and its scroll state once (above the + // LazyColumn), so a caller (private DMs) can drive demand-driven paging off viewport visibility + // rather than per-row composition. No-op for callers that don't paginate. + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> + listStateObserver(listState) RenderChatFeedView( feedContentState, accountViewModel, @@ -69,6 +84,9 @@ fun RefreshingChatroomFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + olderBoundary, + markersInGap, + sentinels, ) } } @@ -83,6 +101,9 @@ fun RenderChatFeedView( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -110,6 +131,9 @@ fun RenderChatFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + olderBoundary, + markersInGap, + sentinels, ) } } @@ -126,9 +150,16 @@ fun ChatFeedLoaded( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() + // Hoisted load driver (above the LazyColumn): pages each relay off viewport visibility, so feed + // reorders no longer re-fire paging. The per-gap markers below are pure UI. + sentinels?.invoke(items.list, listState) + LaunchedEffect(items.list.firstOrNull()) { if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) @@ -169,7 +200,24 @@ fun ChatFeedLoaded( ) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) + + // Per-relay paging markers belonging in the gap toward the next-older message. With the + // reverse layout this draws just above the message (the older side), so a relay's marker + // appears right below the oldest message it has reached and slides down as it pages. + markersInGap?.invoke( + item.event?.createdAt, + items.list + .getOrNull(index + 1) + ?.event + ?.createdAt, + ) } } + + // Reverse layout: a trailing item sits at the highest index, i.e. the visual TOP (oldest end). + // That's where the caller's "load more" affordance / spinner lives. + if (olderBoundary != null) { + item(key = "olderBoundary") { olderBoundary() } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt new file mode 100644 index 0000000000..23f2fcbcbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.DefaultInlineQuoteRenderer +import com.vitorpamplona.amethyst.ui.components.InlineQuoteRenderer +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent + +/** + * Inline-quote renderer provided by [ChatroomMessageCompose] so that a chat + * message quoted inside another chat message's text keeps the chat reply + * design ([ChatroomMessageCompose] with `innerQuote = true`, the same renderer + * the reply row uses) instead of the default quoted-note card. Non-chat events + * (and quotes whose event hasn't arrived yet) fall through to + * [DefaultInlineQuoteRenderer]. + * + * The callbacks are the host bubble's, so tapping a quote of a message in the + * same room scrolls to it, exactly like tapping a reply quote. + */ +fun chatInlineQuoteRenderer( + onWantsToReply: (Note) -> Unit, + onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)?, +) = InlineQuoteRenderer { note, quotesLeft, backgroundColor, accountViewModel, nav -> + WatchNoteEvent( + baseNote = note, + onNoteEventFound = { + if (isChatEvent(note.event)) { + ChatroomMessageCompose( + baseNote = note, + routeForLastRead = null, + innerQuote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, + ) + } else { + DefaultInlineQuoteRenderer.Render(note, quotesLeft, backgroundColor, accountViewModel, nav) + } + }, + onBlank = { + DefaultInlineQuoteRenderer.Render(note, quotesLeft, backgroundColor, accountViewModel, nav) + }, + accountViewModel = accountViewModel, + ) +} + +private fun isChatEvent(event: Event?) = + event is ChatroomKeyable || + event is ChatEvent || + event is IsInPublicChatChannel || + event is LiveActivitiesChatMessageEvent || + event is EphemeralChatEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index d0ac5a38e1..000fbc6ca8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -43,6 +44,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -81,9 +83,11 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -106,8 +110,19 @@ fun ChatroomMessageCompose( onScrollToNote: ((Note) -> Unit)? = null, shouldHighlight: Boolean = false, onHighlightFinished: (() -> Unit)? = null, + // Replaces the generic "post not found" blank while baseNote's event hasn't loaded. Used for + // reply quotes inside a DM, where the target is simply older than the loaded window (see + // LoadingReplyNote). Null keeps the default blank for every other caller. + onBlank: (@Composable () -> Unit)? = null, ) { - WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { + // Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted + // chat message renders with the chat reply design instead of the quoted-note card. + val inlineQuoteRenderer = + remember(onWantsToReply, onWantsToEditDraft, onScrollToNote) { + chatInlineQuoteRenderer(onWantsToReply, onWantsToEditDraft, onScrollToNote) + } + + val onFound: @Composable () -> Unit = { WatchBlockAndReport( note = baseNote, showHiddenWarning = false, @@ -140,6 +155,14 @@ fun ChatroomMessageCompose( } } } + + CompositionLocalProvider(LocalInlineQuoteRenderer provides inlineQuoteRenderer) { + if (onBlank != null) { + WatchNoteEvent(baseNote = baseNote, onNoteEventFound = onFound, onBlank = onBlank, accountViewModel = accountViewModel) + } else { + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav, onNoteEventFound = onFound) + } + } } @Composable @@ -372,11 +395,28 @@ fun RenderReplyRow( onWantsToEditDraft: (Note) -> Unit, onScrollToNote: ((Note) -> Unit)? = null, ) { - if (!innerQuote && note.replyTo?.lastOrNull() != null) { + val replyTo = note.replyTo?.lastOrNull() + if (!innerQuote && replyTo != null && !isCitedInContent(note, replyTo)) { RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote) } } +/** + * Whether the reply target is already mentioned (`nostr:...` citation) in the middle of + * the message text. If it is, the inline quote renderer draws it at that spot, so the + * reply row would show the same message twice. Happens in MLS kind-9 chats, where the + * `q`-tagged parent of a quote is also cited inline — `Note.replyTo` keeps both replies + * and quotes for that kind. + */ +@Composable +private fun isCitedInContent( + note: Note, + replyTo: Note, +): Boolean = + remember(note.event, replyTo) { + (note.event as? BaseNoteEvent)?.findCitations()?.contains(replyTo.idHex) == true + } + @Composable private fun RenderReply( note: Note, @@ -396,9 +436,23 @@ private fun RenderReply( } } - replyTo.value?.let { note -> + replyTo.value?.let { replyNote -> + // For a DM, a reply target that hasn't arrived isn't lost — it's older than the loaded + // window (and for gift wraps can't be fetched by id). Swap the generic blank for one that + // walks history backward until it surfaces. Pick the pager by the PARENT's protocol; leave + // public chats / marmot groups (not a DM event here) on the default blank. + val replyBlank: (@Composable () -> Unit)? = + when (note.event) { + is ChatMessageEvent, is ChatMessageEncryptedFileHeaderEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP17, accountViewModel) } + } + is PrivateDmEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP04, accountViewModel) } + } + else -> null + } ChatroomMessageCompose( - baseNote = note, + baseNote = replyNote, routeForLastRead = null, innerQuote = true, parentBackgroundColor = bgColor, @@ -407,6 +461,7 @@ private fun RenderReply( onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, onScrollToNote = onScrollToNote, + onBlank = replyBlank, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt new file mode 100644 index 0000000000..7b6ab94391 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.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.ui.screen.loggedIn.chats.feed + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The Android locale date formatter for the shared [com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard] + * — passed in so the shared (KMP) card carries no `java.time` dependency. Formats a paging reach point + * (epoch seconds) to a short month-year label, e.g. "Jun 2026". + */ +fun formatHistoryReachDate(epochSeconds: Long): String = SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(epochSeconds * 1000)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt new file mode 100644 index 0000000000..9c5e3e024e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryRelayDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.historySubtitle +import com.vitorpamplona.amethyst.commons.ui.feeds.incompleteSubtitle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +/** Which DM history pager backs the conversation an unloaded reply belongs to. */ +enum class DmReplyProtocol { + // NIP-17 gift wraps. The rumor id of the reply target is NOT queryable on relays (only the outer + // 1059 wrap id is), so the only way to surface it is to keep paging gift-wrap history until the wrap + // carrying it is decrypted — hence we drive the account-wide gift-wrap history pager. + NIP17, + + // NIP-04 legacy DMs (kind 4). Paged per relay for the open conversation. + NIP04, +} + +/** + * The inner-quote placeholder for a reply whose target message has not been paged in yet — used in + * place of the generic [com.vitorpamplona.amethyst.ui.note.BlankNote] ("post not found") that the main + * feeds show. A reply target inside a conversation isn't *missing*, it's simply older than the window + * loaded so far; for gift wraps it can't even be fetched by id. So instead of declaring it lost, this + * card actively walks the conversation's history backward — kicking the protocol's `loadMore` each time + * the previous page settles — until either the target decrypts (the surrounding + * [com.vitorpamplona.amethyst.ui.note.WatchNoteEvent] crossfades the real message in and disposes this) + * or that protocol's history runs dry, at which point it settles into the terminal "not found" text. + * + * It runs regardless of scroll position (no oldest-end gate like the scroll-driven loader) precisely so + * that opening a thread and seeing a reply to something off-screen pulls that something in on its own. + * The drive loop is idempotent and gated on the pager's own `loadingMore`/`exhausted`, so several + * unloaded replies on screen — and the scroll loader — all coalesce onto the same paging window. + */ +@Composable +fun LoadingReplyNote( + protocol: DmReplyProtocol, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + + val loadingFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.loadingMore + DmReplyProtocol.NIP04 -> nip04History.loadingMore + } + val statusFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.status + DmReplyProtocol.NIP04 -> nip04History.status + } + val protocolTag = + when (protocol) { + DmReplyProtocol.NIP17 -> "NIP-17" + DmReplyProtocol.NIP04 -> "NIP-04" + } + + // One snapshot collector instead of five; the fields below are plain reads off it (downstream unchanged). + val status by statusFlow.collectAsStateWithLifecycle() + val exhausted = status.exhausted + val relayCount = status.relayCount + val stalledCount = status.stalledCount + val reachedBack = status.reachedBack + val relayProgress = status.relayProgress + + LaunchedEffect(protocol, loadingFlow, statusFlow) { + // Step the next, older page whenever the previous one has settled and history isn't exhausted. + // The target may surface mid-page (this composable then leaves composition and cancels us); if + // not, we keep walking until the protocol bottoms out and the filter stops passing. + combine(loadingFlow, statusFlow) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { + Log.d("DMPagination") { "reply blank: widen → $protocol advanceAll (searching for unloaded reply)" } + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.advanceAll() + DmReplyProtocol.NIP04 -> nip04History.advanceAll() + } + } + } + + // Tapping opens the same per-relay popup the history card uses, so when the search gives up the user + // can see exactly which relays were reached and which stalled. Empty progress keeps it non-interactive. + var showRelays by remember { mutableStateOf(false) } + if (showRelays) { + DmHistoryRelayDialog(protocolTag, relayProgress, ::formatHistoryReachDate) { showRelays = false } + } + + // Same chrome as DmHistoryLoadingCard (the older-history status card at the oldest end) so an + // unloaded reply reads as the same kind of "reaching back into history" state, just inline in the + // quote: rounded translucent surface, a spinner-in-a-box, then the status line. + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .then(if (relayProgress.isNotEmpty()) Modifier.clickable { showRelays = true } else Modifier), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 2.dp, + ) { + // When the walk gives up it splits the same way the history card does: some relays stalled + // (couldn't reach them — the message may still be out there) vs every relay genuinely bottomed + // out (it really isn't in your history). Either way we say what happened instead of a bare glyph. + val stalledOut = exhausted && stalledCount > 0 + Crossfade(targetState = exhausted, animationSpec = tween(500), label = "loadingReplyState") { isExhausted -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + when { + stalledOut -> + // Stalled-out: same red "…" the per-relay dialog and history card use for unreachable. + Text( + "…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + isExhausted -> + // Genuinely searched everything and it isn't there. + Text( + "✕", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = + if (isExhausted) { + stringRes(R.string.chats_reply_not_found) + } else { + stringRes(R.string.chats_reply_searching_history) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // While loading: which protocol, how many relays, how far back. When it gives up: either + // "N relays unreachable · tap to see which" (stalled) or "searched every relay · tap to see". + Text( + text = + when { + stalledOut -> incompleteSubtitle(stalledCount) + isExhausted -> stringRes(R.string.chats_reply_searched) + else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, ::formatHistoryReachDate) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index b5936e8f2c..24d1dc92d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -27,16 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,16 +46,19 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote @@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList @@ -96,19 +96,15 @@ fun MarmotGroupChatView( WatchLifecycleAndUpdateModel(feedViewModel) - val chatroom = - remember(nostrGroupId) { - accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) - } + val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel") + newMessageModel.init(accountViewModel) + newMessageModel.load(nostrGroupId) DisposableEffect(nostrGroupId) { - chatroom.markAsRead() + newMessageModel.chatroom?.markAsRead() onDispose { } } - val messageState = remember(nostrGroupId) { TextFieldState() } - val replyTo = remember(nostrGroupId) { mutableStateOf(null) } - // Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS // message in the Notifications screen) into the actual Note once it has // landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids. @@ -116,14 +112,14 @@ fun MarmotGroupChatView( LaunchedEffect(replyToInnerNote) { val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote) if (parent != null) { - replyTo.value = parent + newMessageModel.reply(parent) } } } if (draftMessage != null) { LaunchedEffect(draftMessage) { - messageState.setTextAndPlaceCursorAtEnd(draftMessage) + newMessageModel.editFromDraft(draftMessage) } } @@ -139,7 +135,7 @@ fun MarmotGroupChatView( accountViewModel = accountViewModel, nav = nav, routeForLastRead = "MarmotGroup/$nostrGroupId", - onWantsToReply = { note -> replyTo.value = note }, + onWantsToReply = { note -> newMessageModel.reply(note) }, onWantsToEditDraft = { }, ) } @@ -148,8 +144,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, - messageState = messageState, - replyTo = replyTo, + newMessageModel = newMessageModel, accountViewModel = accountViewModel, nav = nav, onMessageSent = { @@ -162,49 +157,59 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, - messageState: TextFieldState, - replyTo: MutableState, + newMessageModel: MarmotNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, onMessageSent: suspend () -> Unit, ) { val scope = rememberCoroutineScope() - val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } + val canPost by remember { derivedStateOf { newMessageModel.canPost() } } val context = LocalContext.current var isUploading by remember { mutableStateOf(false) } - val uploadState = - remember { - ChatFileUploadState( - defaultServer = accountViewModel.account.settings.defaultFileServer, - defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload, - ) - } - // Upload dialog - uploadState.multiOrchestrator?.let { - MarmotGroupFileUploadDialog( - nostrGroupId = nostrGroupId, - state = uploadState, - accountViewModel = accountViewModel, - nav = nav, - onUpload = { onMessageSent() }, - onCancel = uploadState::reset, - ) + DisposableEffect(nostrGroupId) { + onDispose { newMessageModel.userSuggestions?.reset() } } - replyTo.value?.let { + // Upload dialog + newMessageModel.uploadState?.let { uploadState -> + uploadState.multiOrchestrator?.let { + MarmotGroupFileUploadDialog( + nostrGroupId = nostrGroupId, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } + } + + newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { - replyTo.value = null + newMessageModel.clearReply() } } Column(modifier = EditFieldModifier) { + newMessageModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + newMessageModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, + ) + } + ThinPaddingTextField( - state = messageState, + state = newMessageModel.message, + onTextChanged = { newMessageModel.onMessageChanged() }, onContentReceived = { uri, mimeType -> - uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) + newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType))) }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), modifier = Modifier.fillMaxWidth(), shape = EditFieldBorder, placeholder = { @@ -216,9 +221,7 @@ fun MarmotGroupMessageComposer( leadingIcon = { MarmotGalleryLeadingIcon( isUploading = isUploading, - onImageChosen = { selectedMedia -> - uploadState.load(selectedMedia) - }, + onImageChosen = newMessageModel::pickedMedia, ) }, trailingIcon = { @@ -226,33 +229,18 @@ fun MarmotGroupMessageComposer( isActive = canPost, modifier = EditFieldTrailingIconModifier, ) { - val text = messageState.text.toString().trim() - if (text.isNotEmpty()) { - // Capture id+pubKey snapshot under the value? guard so - // a slow send doesn't race a user-cleared reply state. - val parentEvent = replyTo.value?.event - val replyId = parentEvent?.id - val replyAuthor = parentEvent?.pubKey - scope.launch(Dispatchers.IO) { - try { - accountViewModel.sendMarmotGroupMessage( - nostrGroupId = nostrGroupId, - text = text, - replyToInnerEventId = replyId, - replyToInnerAuthorPubKey = replyAuthor, - ) - messageState.clearText() - replyTo.value = null - onMessageSent() - } catch (e: Exception) { - launch(Dispatchers.Main) { - Toast - .makeText( - context, - "Failed to send message: ${e.message}", - Toast.LENGTH_SHORT, - ).show() - } + scope.launch(Dispatchers.IO) { + try { + newMessageModel.sendPost() + onMessageSent() + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to send message: ${e.message}", + Toast.LENGTH_SHORT, + ).show() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt new file mode 100644 index 0000000000..d5501a39cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.chats.marmotGroup.send + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList + +/** + * Composition state for the Marmot/MLS group message field, mirroring the + * structure of the other chat composers (ChatNewMessageViewModel, + * ChannelNewMessageViewModel, NestNewMessageViewModel): @-mention + * suggestions, reply state, and file-upload state. Sending goes through + * AccountViewModel.sendMarmotGroupMessage, which owns mention tagging. + */ +@Stable +open class MarmotNewMessageViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var nostrGroupId: HexKey? = null + var chatroom: MarmotGroupChatroom? = null + + val message = TextFieldState() + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + var userSuggestions: UserSuggestionState? = null + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + + this.userSuggestions?.reset() + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { chatroom?.members?.value?.mapTo(mutableSetOf()) { it.pubkey } ?: emptySet() }, + ) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + open fun load(nostrGroupId: HexKey) { + if (this.nostrGroupId != nostrGroupId) { + this.nostrGroupId = nostrGroupId + this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + this.message.clearText() + this.replyTo.value = null + } + } + + fun reply(note: Note) { + replyTo.value = note + } + + fun clearReply() { + replyTo.value = null + } + + fun editFromDraft(draftMessage: String) { + message.setTextAndPlaceCursorAtEnd(draftMessage) + } + + fun canPost() = message.text.isNotBlank() + + fun onMessageChanged() { + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestions?.reset() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { + it.replaceCurrentWord(message, message.currentWord(), item) + it.reset() + } + } + + fun pickedMedia(media: ImmutableList) { + uploadState?.load(media) + } + + /** Sends the field's text. Mention rewriting and p-tagging happen in + * AccountViewModel.sendMarmotGroupMessage. Throws on send failure so + * the caller can surface the error. */ + suspend fun sendPost() { + val groupId = nostrGroupId ?: return + val text = message.text.toString().trim() + if (text.isEmpty()) return + + // Capture id+pubKey snapshot before suspending so a slow send + // doesn't race a user-cleared reply state. + val parentEvent = replyTo.value?.event + + accountViewModel.sendMarmotGroupMessage( + nostrGroupId = groupId, + text = text, + replyToInnerEventId = parentEvent?.id, + replyToInnerAuthorPubKey = parentEvent?.pubKey, + ) + + message.clearText() + replyTo.value = null + userSuggestions?.reset() + } +} 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 06d9bf0532..4632448358 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,12 +26,28 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -40,15 +56,23 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Composable @@ -134,6 +158,47 @@ fun ChatroomView( ) } +/** + * Bootstraps history when the conversation has no messages yet (the live tail came back empty for a + * thread whose newest message is older than a week). There's nothing on screen to host the per-relay + * window-limit markers that normally drive paging, so while the feed is empty we step every relay one + * page at a time (each protocol independently, gated on its own loader) until messages appear — at which + * point the on-screen markers take over — or the protocol is exhausted. Once the feed is Loaded this + * does nothing; paging is then purely demand-driven by the markers' visibility. + */ +@Composable +private fun BootstrapHistoryWhenEmpty( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + // Empty only (never the transient Loading navigation flashes through), and debounced below, so + // re-opening a conversation that has messages doesn't kick a hunt. + val needsBootstrap = feedState is FeedState.Empty + + LaunchedEffect(needsBootstrap, giftWrapsHistory) { + if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(giftWrapsHistory.loadingMore, giftWrapsHistory.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { giftWrapsHistory.advanceAll() } + } + LaunchedEffect(needsBootstrap, nip04History) { + if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(nip04History.loadingMore, nip04History.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { nip04History.advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before messages re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + @Composable fun ChatroomViewUI( room: ChatroomKey, @@ -145,6 +210,50 @@ fun ChatroomViewUI( WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) + DisposableEffect(room) { + Log.d("DMPagination") { "convo: OPEN room=${room.hashCode()}" } + onDispose { Log.d("DMPagination") { "convo: CLOSE room=${room.hashCode()}" } } + } + + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() + // One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six + // separate collectors — the status card and the per-relay markers read all of it together anyway. + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val user = accountViewModel.userProfile() + + // Both protocols' per-relay window limits, each carrying the advance() that pulls its own next page. + // Placed in the stream as sentinels (see RelayReachMarkers): a relay pages only while its + // marker is on screen, and keeps paging while it stays there. A protocol drops out once exhausted. + val limits = + remember(nip04Status, giftWrapsStatus, user) { + buildList { + if (!giftWrapsStatus.exhausted) { + giftWrapsStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) + } + } + if (!nip04Status.exhausted) { + nip04Status.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) + } + } + } + } + val nip17Name = stringResource(R.string.chats_history_proto_nip17) + val nip04Name = stringResource(R.string.chats_history_proto_nip04) + + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the detail popup. + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + + BootstrapHistoryWhenEmpty(feedViewModel.feedState, accountViewModel) + Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -163,6 +272,32 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + // One status card per protocol at the oldest end: each shows what it's reaching for + // while it pages and crossfades to "All caught up" when that protocol runs dry. + olderBoundary = { + Column { + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate) + } + }, + // Each relay's window-limit marker, placed at its reached cursor (pure UI). Hidden once + // both protocols are exhausted. + markersInGap = + if (limits.isEmpty()) { + null + } else { + { newer, older -> RelayReachMarkers(limits, newer, older) { syncDetail = it } } + }, + // The hoisted load driver that pulls each relay's next page while its marker is on screen, + // off viewport visibility (see RelayReachSentinels) so feed reorders don't re-page. + sentinels = + if (limits.isEmpty()) { + null + } else { + { items, listState -> + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + } + }, ) } @@ -183,3 +318,16 @@ fun ChatroomViewUI( ) } } + +private fun reachState(p: RelayPagingProgress): RelayReachState = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index b5cc945fcd..9366082074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -36,9 +36,16 @@ class ChatroomQueryState( class ChatroomFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomNip04HistorySubAssembler(client, ::allKeys) + val group = listOf( - ChatroomFilterSubAssembler(client, ::allKeys), + nip04, + nip04History, ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt new file mode 100644 index 0000000000..4cbe77bffb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +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.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 +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads older NIP-04 DMs (kind 4) for one conversation by **`until`+`limit` paging, per relay, on + * demand**. Each relay advances exactly one page when the conversation's on-screen window-limit marker + * for that relay asks ([advance]); otherwise it parks. Nothing is walked proactively — a relay pages + * only while its marker is visible and keeps paging while it stays visible. + * + * The per-relay cursors live on the conversation's [Chatroom][com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom] + * (so reopening the room keeps its progress); this class binds the single-active [BackwardRelayPager] + * orchestrator to the open room's cursors on [newSub], builds the (per-relay scoped) NIP-04 REQ + * filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page; + * one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] + * flips once every relay is either done or stalled. + */ +class ChatroomNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + private val pager = BackwardRelayPager("convo.nip04.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun user(key: ChatroomQueryState) = key.account.userProfile() + + override fun list(key: ChatroomQueryState) = key.listId + + // This conversation's persistent paging cursors, held on its Chatroom (per account + room). + private fun cursorsFor(key: ChatroomQueryState) = + key.account.chatroomList + .getOrCreatePrivateChatroom(key.room) + .nip04History + + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ): List? { + val relays = nip04DmRelayRouting(key.room.users, key.account) + if (!key.account.isWriteable() || relays == null) return emptyList() + + // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked + // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. + val armed = pager.armedRelays(relays.all).toSet() + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("convo.nip04.history", key.account) + val scoped = + Nip04DmRelayRouting( + toMeRelays = relays.toMeRelays.filterKeys { it in armed }, + fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, + ) + return filterNip04DMsHistory(key.account, scoped, pager.pageLimit) { relay -> + pager.requestedUntilFor(relay) + } + } + + /** Steps a single [relay] to its next, older page for the open conversation. Driven by its marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For a thread too short to scroll. */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d("DMPagination") { "[convo.nip04.history] advanceAll (empty-thread bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: ChatroomQueryState): Subscription { + // Repoint the single-active orchestrator at this conversation's cursors (on its Chatroom) and the + // relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(cursorsFor(key), key.account.scope) { nip04DmRelayRouting(key.room.users, key.account)?.all } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ChatroomQueryState): SubscriptionListener { + // A just-backgrounded room's subscription can still deliver after the orchestrator rebinds to + // another room; gate the pager (single-active) on whether it's still bound to THIS room's cursors + // so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway. + val myCursors = cursorsFor(key) + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } + } + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt new file mode 100644 index 0000000000..f7c8bfb686 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +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 com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Always-on **live tail** for one conversation's NIP-04 DMs (kind 4). A fixed one-week floor, no + * upper bound, never widens — older history is loaded by [ChatroomNip04HistorySubAssembler] in + * bounded slices that follow the gift-wrap history window. + */ +class ChatroomNip04SubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("convo.nip04.live") + val loadingMore: StateFlow = windowLoad.loading + + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ): List? = + if (key.account.isWriteable()) { + val sinceTime = DmHistoryTuning.recentBoundary() + val filters = filterNip04DMs(key.room.users, key.account, sinceTime) + windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) + DmRelayLog.log("convo.nip04.live", key.account) + Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (no until) on ${filters?.size ?: 0} relay-filter(s): ${filters?.map { it.relay.url }?.distinct()}" } + filters + } else { + windowLoad.setExpectedRelays(emptySet()) + emptyList() + } + + override fun user(key: ChatroomQueryState) = key.account.userProfile() + + override fun list(key: ChatroomQueryState) = key.listId + + override fun newSub(key: ChatroomQueryState): Subscription { + windowLoad.startLoading(key.account.scope) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index ec277266de..5072ce8db0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -30,19 +29,55 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -fun filterNip04DMs( +/** + * Where a conversation's NIP-04 DMs flow, resolved via the outbox model and **scoped per relay** so + * every filter only names the keys that actually own the relay it is sent to. + * + * Each map is `relay -> the counterpart keys to name in that relay's filter`: + * - [toMeRelays] queries messages **to me** (`authors=[those keys], #p=[me]`). A relay appears here + * when it is my inbox (then the key set is the whole group) and/or a counterpart's outbox (then + * the key set is just the counterparts who publish there). + * - [fromMeRelays] queries messages **from me** (`authors=[me], #p=[those keys]`). A relay appears + * here when it is my outbox (then the key set is the whole group) and/or a counterpart's inbox + * (then the key set is just the counterparts who read there). + * + * Scoping the key set per relay is what keeps us from sending, e.g., `authors=[bob]` to a relay that + * is only charlie's — a filter that relay has no reason to serve. + */ +class Nip04DmRelayRouting( + val toMeRelays: Map>, + val fromMeRelays: Map>, +) { + val all: Set get() = toMeRelays.keys + fromMeRelays.keys +} + +private fun addAll( + map: MutableMap>, + relays: Collection, + keys: Collection, +) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) } + +fun nip04DmRelayRouting( group: Set?, account: Account?, - since: SincePerRelayMap?, -): List? { +): Nip04DmRelayRouting? { if (group.isNullOrEmpty() || account == null) return null val userOutboxRelays = account.homeRelays.flow.value val userInboxRelays = account.dmRelays.flow.value - val groupOutboxRelays = mutableSetOf() - val groupInboxRelays = mutableSetOf() + // relay -> counterpart keys whose messages-to-me we ask that relay for (authors set, #p=[me]). + val toMe = mutableMapOf>() + // relay -> counterpart keys whose copy of my messages we ask that relay for (#p set, authors=[me]). + val fromMe = mutableMapOf>() + // My own relays carry the whole conversation: my inbox holds everyone's messages to me, my outbox + // holds all of mine. Both filters name the full group on these relays. + addAll(toMe, userInboxRelays, group) + addAll(fromMe, userOutboxRelays, group) + + // Each counterpart's own relays only get a filter naming that counterpart: their outbox (where + // they publish their messages to me) and their inbox (where they keep my messages to them). group.forEach { val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) @@ -53,42 +88,81 @@ fun filterNip04DMs( ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } ?: emptyList() - groupOutboxRelays.addAll(outbox) - val inbox = authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } ?: LocalCache.getUserIfExists(it)?.allUsedRelaysOrNull() ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } ?: emptyList() - groupInboxRelays.addAll(inbox) + addAll(toMe, outbox, listOf(it)) + addAll(fromMe, inbox, listOf(it)) } - val toMeRelays = (userInboxRelays + groupOutboxRelays) - val fromMeRelays = (userOutboxRelays + groupInboxRelays) - - return toMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = group.toList(), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = since?.get(it)?.time, - ), - ) - } + - fromMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to group.toList()), - since = since?.get(it)?.time, - ), - ) - } + return Nip04DmRelayRouting(toMe, fromMe) } + +private fun toMeFilter( + relay: NormalizedRelayUrl, + authors: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = authors.toList(), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = since, + until = until, + limit = limit, + ), +) + +private fun fromMeFilter( + relay: NormalizedRelayUrl, + pTags: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to pTags.toList()), + since = since, + until = until, + limit = limit, + ), +) + +/** Live-tail filters: everything since [windowStart], open-ended at the top (new messages keep arriving). */ +fun filterNip04DMs( + group: Set?, + account: Account?, + windowStart: Long, +): List? { + if (group.isNullOrEmpty() || account == null) return null + val relays = nip04DmRelayRouting(group, account) ?: return null + return relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = windowStart, until = null, limit = null) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = windowStart, until = null, limit = null) } +} + +/** + * History filters: a bounded backward page per relay. Each relay is asked for [limit] events older + * than [untilFor]`(relay)` (no `since`), so it can be paged down to empty independently. The author / + * `#p` key set per relay comes straight from [relays], so each relay only sees the keys it owns. + */ +fun filterNip04DMsHistory( + account: Account, + relays: Nip04DmRelayRouting, + limit: Int, + untilFor: (NormalizedRelayUrl) -> Long?, +): List = + relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = null, until = untilFor(relay), limit = limit) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = null, until = untilFor(relay), limit = limit) } 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 45d481b0af..8bbc1f2fe6 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 @@ -261,7 +261,12 @@ class ChatNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { room.value?.users ?: emptySet() }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) 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 f826189c6d..6a89bb8fe3 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 @@ -188,7 +188,16 @@ open class ChannelNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + // Public channels have no membership; recent posters are the + // closest thing. The cutoff also bounds the note scan. + channel?.participatingAuthors(TimeUtils.oneMonthAgo())?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 7cf22d65e9..5feb61404b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -122,7 +122,7 @@ class ChatroomListNewFeedFilter( if (room != null && ( newNote.author?.pubkeyHex != me.pubkeyHex && - room.senderIntersects(followingKeySet) && + !room.senderIntersects(followingKeySet) && !account.chatroomList.hasSentMessagesTo(roomKey) ) && !account.isAllHidden(roomKey.users) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 2b9c534502..489b241971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -35,9 +35,16 @@ class ChatroomListState( class ChatroomListFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomListNip04HistorySubAssembler(client, ::allKeys) + val group = listOf( - DMsFromUserFilterSubAssembler(client, ::allKeys), + nip04, + nip04History, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt new file mode 100644 index 0000000000..73a31dab16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +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.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 +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads older NIP-04 DMs (kind 4) for the rooms list by **`until`+`limit` paging, per relay, on + * demand** — the same model as the gift-wrap history loader + * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager]), + * across the account's home (outbox, *from me*) + DM (inbox, *to me*) relays. Each relay advances one + * page when its on-screen window-limit marker asks ([advance]); otherwise it parks. Nothing is walked + * proactively. The per-relay cursors live on the account's + * [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList]; the single-active + * [BackwardRelayPager] orchestrator binds to them on [newSub]. + */ +class ChatroomListNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + private fun allRelays(account: Account) = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + + private val pager = BackwardRelayPager("rooms.nip04.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun user(key: ChatroomListState) = key.account.userProfile() + + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? { + val user = user(key) + if (!key.account.isWriteable()) return emptyList() + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + val armed = pager.armedRelays((homeRelays + dmRelays).toSet()) + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("rooms.nip04.history", key.account) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + buildList { + if (relay in homeRelays) add(filterNip04DMsFromMe(user, relay, since = null, until = until, limit = pager.pageLimit)) + if (relay in dmRelays) add(filterNip04DMsToMe(user, relay, since = null, until = until, limit = pager.pageLimit)) + } + } + } + + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d("DMPagination") { "[rooms.nip04.history] advanceAll (empty-feed bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: ChatroomListState): Subscription { + // Repoint the single-active orchestrator at this account's rooms-list NIP-04 cursors (on its + // ChatroomList) and the relays it fans out to, refreshing the flows from the restored progress. + pager.bind(key.account.chatroomList.nip04History, key.account.scope) { allRelays(key.account) } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ChatroomListState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.nip04History + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } + } + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt index 410e03d59b..cd712abcaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -20,60 +20,79 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager 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 com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -class DMsFromUserFilterSubAssembler( +/** + * Always-on **live tail** for the account's NIP-04 DMs (kind 4) in the rooms list. Mirrors the + * gift-wrap live tail: a fixed one-week floor, no upper bound, never widens. Older NIP-04 history is + * loaded by [ChatroomListNip04HistorySubAssembler] in bounded slices. + */ +class ChatroomListNip04SubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("rooms.nip04.live") + val loadingMore: StateFlow = windowLoad.loading + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - key.account.homeRelays.flow.value.map { - filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) - } + - key.account.dmRelays.flow.value.map { - filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) - } + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + val sinceTime = DmHistoryTuning.recentBoundary() + DmRelayLog.log("rooms.nip04.live", key.account) + Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (no until) fromMe(outbox)=${homeRelays.map { it.url }} toMe(inbox)=${dmRelays.map { it.url }}" } + homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sinceTime) } + + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sinceTime) } } else { + windowLoad.setExpectedRelays(emptySet()) emptyList() } override fun user(key: ChatroomListState) = key.account.userProfile() - val userJobMap = mutableMapOf>() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: ChatroomListState): Subscription { val user = user(key) + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( key.account.scope.launch(Dispatchers.IO) { - key.account.homeRelays.flow.collectLatest { - invalidateFilters() - } + key.account.homeRelays.flow + .collectLatest { invalidateFilters() } }, key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } }, ) - return super.newSub(key) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) } override fun endSub( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index de96b9f2aa..c000c246d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -30,6 +30,8 @@ fun filterNip04DMsFromMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +40,7 @@ fun filterNip04DMsFromMe( kinds = listOf(PrivateDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, + until = until, + limit = limit, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index dcfdab1602..4b1148e066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -30,6 +30,8 @@ fun filterNip04DMsToMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +40,7 @@ fun filterNip04DMsToMe( kinds = listOf(PrivateDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, + until = until, + limit = limit, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 300f36eda9..a7612c4402 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -25,15 +25,29 @@ import androidx.compose.foundation.layout.Row 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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -44,15 +58,25 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import java.io.Serializable @Composable @@ -62,6 +86,10 @@ fun ChatroomListFeedView( accountViewModel: AccountViewModel, nav: INav, ) { + DisposableEffect(Unit) { + Log.d("DMPagination") { "rooms.list: OPEN" } + onDispose { Log.d("DMPagination") { "rooms.list: CLOSE" } } + } RefresheableBox(feedContentState, true) { SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> CrossFadeState(feedContentState, listState, accountViewModel, nav) @@ -78,6 +106,24 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + // History is exhausted only once BOTH DM protocols have paged to their end (each stops when a + // round of until+limit pages brings nothing back). Until then an empty feed means "still filling", + // not "no conversations" — keep the spinner up rather than flash empty. + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val historyExhausted = giftWrapsStatus.exhausted && nip04Status.exhausted + + // A *genuinely* empty list has no rows to host the per-relay window-limit markers, so we step every + // relay one page at a time to hunt for the first rooms. Gated on FeedState.Empty only (never the + // transient Loading that navigation flashes through) and debounced, so re-opening Messages with rooms + // already loaded does NOT kick a hunt. Once rooms appear the markers take over, demand-driven. + val user = accountViewModel.userProfile() + val bootstrap = feedState is FeedState.Empty + BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.status) { giftWrapsHistory.advanceAll() } + BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.status) { nip04History.advanceAll() } + CrossfadeIfEnabled( targetState = feedState, animationSpec = tween(durationMillis = 100), @@ -85,7 +131,11 @@ private fun CrossFadeState( ) { state -> when (state) { is FeedState.Empty -> { - FeedEmpty { feedContentState.invalidateData() } + if (historyExhausted) { + FeedEmpty { feedContentState.invalidateData() } + } else { + LoadingFeed() + } } is FeedState.FeedError -> { @@ -114,14 +164,60 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() + // One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six + // separate collectors — the status card and the per-relay markers read all of it together anyway. + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val user = accountViewModel.userProfile() + val nip17Name = stringResource(R.string.chats_history_proto_nip17) + val nip04Name = stringResource(R.string.chats_history_proto_nip04) + val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } + val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } + + // Each relay's window limit, carrying the advance() that pulls its OWN next page. Placed in the list + // at its reached depth as a sentinel (see RelayReachMarkers): a relay pages only while its + // marker is on screen and keeps paging while it stays there, so a spam-dense relay never floods — + // you have to scroll through its messages to pull more. A protocol drops out once exhausted. + val limits = + remember(giftWrapsStatus, nip04Status, user) { + buildList { + if (!giftWrapsStatus.exhausted) { + giftWrapsStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) + } + } + if (!nip04Status.exhausted) { + nip04Status.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) + } + } + } + } + + // Hoisted load driver: pulls each relay's next page off viewport visibility, so feed reorders + // (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI. + RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } + + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the per-relay popup so the + // terse divider isn't a dead end — every count/name is one tap from the full breakdown (which relays, + // protocol, how far back each paged). + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - items( + itemsIndexed( items.list, - key = { item -> chatroomLazyKey(item, myPubKey) }, - ) { item -> + key = { _, item -> chatroomLazyKey(item, myPubKey) }, + ) { index, item -> Row(Modifier.fillMaxWidth()) { ChatroomHeaderCompose( item, @@ -133,10 +229,69 @@ private fun FeedLoaded( HorizontalDivider( thickness = DividerThickness, ) + + // Rendered unconditionally at the protocol's oldest room so the card can run its own + // "All caught up" crossfade-and-collapse when that protocol exhausts. + if (index == oldestNip17Index) { + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate) + } + if (index == oldestNip04Index) { + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate) + } + + // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: + // each pulls its relay's next page while it's on screen. olderCreatedAt is null past the + // oldest loaded room, so relays that have reached the bottom of the list sit there. + RelayReachMarkers( + limits, + item.createdAt(), + items.list.getOrNull(index + 1)?.createdAt(), + ) { syncDetail = it } } } } +/** + * Bootstraps history while the rooms list is genuinely empty: steps every relay one page at a time, + * gated only on its own loader, until rooms appear or the protocol exhausts. Once rooms load this stops + * and the per-relay window-limit markers drive paging on demand. + * + * Leads with a debounce so the brief Empty/Loading flash that navigation passes through does NOT trigger + * a hunt; if [active] drops before it elapses (rooms loaded) the effect cancels and nothing pages. + */ +@Composable +private fun BootstrapHistoryWhenEmpty( + active: Boolean, + loadingMore: StateFlow, + status: StateFlow, + advanceAll: () -> Unit, +) { + LaunchedEffect(active, loadingMore, status) { + if (!active) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(loadingMore, status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before the rooms re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + +private fun reachState(p: RelayPagingProgress) = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') + // Stable per-chatroom key — derived from chatroom identity, not the latest // message id, so reorders move the row instead of recreating it. Compose // stores LazyColumn item keys in a SaveableStateHolder, which on Android 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 150353320c..19852adb1f 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 @@ -257,6 +257,8 @@ open class ShortNotePostViewModel : get() = voiceAnonymization.processingPreset // Polls + // Both regular polls and zap polls share the same `pollOptions` text fields so that switching + // between the two poll types never hides or discards the text the user already typed. var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) var pollOptions: SnapshotStateMap = newStateMapPollOptions() @@ -266,7 +268,6 @@ open class ShortNotePostViewModel : // ZapPolls var canUseZapPoll by mutableStateOf(false) var wantsZapPoll by mutableStateOf(false) - var zapPollOptions: SnapshotStateMap = newStateMapZapPollOptions() var zapPollValueMaximum by mutableStateOf(null) var zapPollValueMinimum by mutableStateOf(null) var zapPollConsensusThreshold: Int? = null @@ -794,7 +795,8 @@ open class ShortNotePostViewModel : wantsZapPoll = polls.isNotEmpty() polls.forEach { tag -> - zapPollOptions[tag.index] = tag.descriptor + val current = pollOptions[tag.index] + pollOptions[tag.index] = OptionTag(current?.code ?: RandomInstance.randomChars(6), tag.descriptor) } zapPollValueMinimum = draftEvent.minAmount() @@ -1011,7 +1013,7 @@ open class ShortNotePostViewModel : imetas(usedAttachments) } } else if (wantsZapPoll) { - val options = zapPollOptions.map { PollOptionTag(it.key, it.value) } + val options = pollOptions.map { PollOptionTag(it.key, it.value.label) } if (options.isEmpty()) return null ZapPollEvent.build(tagger.message, options) { @@ -1230,7 +1232,6 @@ open class ShortNotePostViewModel : closedAt = TimeUtils.oneDayAhead() wantsZapPoll = false - zapPollOptions = newStateMapZapPollOptions() zapPollValueMaximum = null zapPollValueMinimum = null zapPollConsensusThreshold = null @@ -1356,12 +1357,6 @@ open class ShortNotePostViewModel : 1 to OptionTag(RandomInstance.randomChars(6), ""), ) - private fun newStateMapZapPollOptions(): SnapshotStateMap = - mutableStateMapOf( - 0 to "", - 1 to "", - ) - fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) if (voiceMetadata != null || voiceRecording != null) { @@ -1385,7 +1380,8 @@ open class ShortNotePostViewModel : ( !wantsZapPoll || ( - zapPollOptions.values.all { it.isNotEmpty() } && + pollOptions.isNotEmpty() && + pollOptions.all { it.value.label.isNotEmpty() } && isValidValueMinimum.value && isValidValueMaximum.value ) @@ -1620,35 +1616,6 @@ open class ShortNotePostViewModel : } } - fun removeZapPollOption(optionIndex: Int) { - zapPollOptions.removeOrderedZapPoll(optionIndex) - draftTag.newVersion() - } - - private fun MutableMap.removeOrderedZapPoll(index: Int) { - val keyList = keys - val elementList = values.toMutableList() - run stop@{ - for (i in index until elementList.size) { - val nextIndex = i + 1 - if (nextIndex == elementList.size) return@stop - elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } - } - } - elementList.removeAt(elementList.size - 1) - val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) } - this.clear() - this.putAll(newEntries) - } - - fun updateZapPollOption( - optionIndex: Int, - text: String, - ) { - zapPollOptions[optionIndex] = text - draftTag.newVersion() - } - fun toggleMarkAsSensitive() { wantsToMarkAsSensitive = !wantsToMarkAsSensitive draftTag.newVersion() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 4357b1eda9..a9e7993a97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -194,7 +194,16 @@ open class NestNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + (room?.event as? MeetingSpaceEvent)?.let { space -> + space.participantKeys().toSet() + space.pubKey + } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index bcb315079c..579f6c07a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent -import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent class UserProfileGalleryFeedFilter( @@ -81,7 +81,7 @@ class UserProfileGalleryFeedFilter( ( noteEvent is PictureEvent || noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is AddressableVideoEvent && it is AddressableNote) || (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AudioVisualizerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AudioVisualizerSettingsScreen.kt new file mode 100644 index 0000000000..eb74ba6d58 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AudioVisualizerSettingsScreen.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SyntheticSpectrum +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.service.playback.composable.wavefront.FakeWaveformAnimation +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 kotlinx.coroutines.flow.Flow + +@Composable +fun AudioVisualizerSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.audio_visualizer_settings), nav) + }, + ) { padding -> + AudioVisualizerSettingsContent(accountViewModel, Modifier.padding(padding)) + } +} + +@Composable +fun AudioVisualizerSettingsContent( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val selected by accountViewModel.audioVisualizerFlow().collectAsStateWithLifecycle() + val previewSpectrum = remember { SyntheticSpectrum.flow(48) } + + LazyColumn(modifier = modifier.fillMaxWidth()) { + item { + Text( + text = stringRes(R.string.audio_visualizer_settings_description), + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + modifier = Modifier.padding(16.dp), + ) + } + items(VisualizerStyle.entries) { style -> + VisualizerStyleRow( + style = style, + selected = style == selected, + previewSpectrum = previewSpectrum, + onClick = { accountViewModel.changeAudioVisualizer(style) }, + ) + } + item { Spacer(Modifier.height(16.dp)) } + } +} + +@Composable +private fun VisualizerStyleRow( + style: VisualizerStyle, + selected: Boolean, + previewSpectrum: Flow, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RadioButton(selected = selected, onClick = onClick) + Column(Modifier.weight(1f)) { + Text(visualizerStyleName(style), style = MaterialTheme.typography.bodyLarge) + } + Box( + modifier = + Modifier + .size(width = 120.dp, height = 56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF0C0C10)), + ) { + if (style == VisualizerStyle.CLASSIC) { + val progress = remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + while (true) { + withFrameMillis { ms -> progress.floatValue = (ms % 1500L) / 1500f } + } + } + FakeWaveformAnimation(progress, 40, Modifier.fillMaxWidth().height(56.dp)) + } else { + // OFF → OffRenderer (nothing), STATIC → frozen bars, others → live preview. + AudioVisualizer(style = style, spectrum = previewSpectrum, modifier = Modifier.fillMaxWidth().height(56.dp)) + } + } + } +} + +@Composable +private fun visualizerStyleName(style: VisualizerStyle): String = + when (style) { + VisualizerStyle.CLASSIC -> stringRes(R.string.audio_visualizer_classic) + VisualizerStyle.OFF -> stringRes(R.string.audio_visualizer_off) + VisualizerStyle.BARS -> stringRes(R.string.audio_visualizer_bars) + VisualizerStyle.WAVES -> stringRes(R.string.audio_visualizer_waves) + VisualizerStyle.RADIAL -> stringRes(R.string.audio_visualizer_radial) + VisualizerStyle.AURORA -> stringRes(R.string.audio_visualizer_aurora) + VisualizerStyle.STATIC -> stringRes(R.string.audio_visualizer_static) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index d6c9909f5a..14bc2e4141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -71,6 +71,7 @@ fun buildSettingsCatalog( symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), symEntry(R.string.reactions, MaterialSymbols.FavoriteBorder, R.string.reactions_search_keywords, Route.UpdateReactionType), symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings), + symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings), symEntry(R.string.zaps, MaterialSymbols.Bolt, R.string.zaps_search_keywords, Route.UpdateZapAmount()), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), 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 6b27c59acc..b5233c6199 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -70,6 +71,67 @@ class TorService( } } + /** + * The on-disk guard sample Arti persists between runs. + * Path: `/arti/state/state/guards.json`. + */ + private fun guardsFile() = File(File(File(artiDataDir(), "state"), "state"), "guards.json") + + /** + * Detects the wedged-guard-sample state behind the long-standing "can't + * connect to Tor" bug. + * + * On a flaky network, Arti records circuit failures past the first hop as + * "indeterminate" (it can't tell whether the guard or a later hop was at + * fault). Once a guard's indeterminate ratio crosses 0.7, Arti + * *permanently* disables it (`TooManyIndeterminateFailures`). Disabled + * guards are never re-enabled and never removed from the sample (kept for + * the 60-day confirmed lifetime), and the sample is capped at + * `max_sample_size` (60). Arti normally refills usable guards from the + * network when they drop below `min_filtered_sample_size` (20), but once + * the sample is full of unusable guards there is no room to add more — so + * replenishment is permanently wedged and every circuit returns + * `AllGuardsDown`. The state persists in `guards.json`, and bootstrap still + * "succeeds" (it reads cached directory data), so none of the init-failure + * self-heal paths ever fire and Tor is stuck across restarts. + * + * A single usable guard is enough to keep building circuits, so we only + * recover at the last resort: when a non-empty guard set has *zero* usable + * guards. A guard is unusable on disk if it has been permanently + * `disabled` or dropped from the consensus (`unlisted_since` set); + * reachability is in-memory only and not persisted, so it can't be checked + * here. Returns true when at least one non-empty selection has no usable + * guard left. + */ + private fun noUsableGuards(): Boolean { + val file = guardsFile() + if (!file.exists()) return false + + return try { + val root = jacksonObjectMapper().readTree(file) + var wedged = false + // Each top-level field is a guard-set selection (e.g. "default"). + root.forEach { selection -> + val guards = selection.get("guards") ?: return@forEach + if (guards.isArray && guards.size() > 0) { + val usable = + guards.count { guard -> + val disabled = guard.get("disabled") + val unlisted = guard.get("unlisted_since") + val isDisabled = disabled != null && !disabled.isNull + val isUnlisted = unlisted != null && !unlisted.isNull + !isDisabled && !isUnlisted + } + if (usable == 0) wedged = true + } + } + wedged + } catch (e: Exception) { + Log.w("TorService") { "Could not inspect guards.json: ${e.message}" } + false + } + } + /** * Clears all Arti persistent data (state + cache). Used as a last resort * when initialization fails, to recover from corrupted state. @@ -122,6 +184,16 @@ class TorService( // fresh network data, preventing stale guards/circuits. clearArtiCache() + // Self-heal the wedged guard sample (see [noUsableGuards]): if + // the persisted sample has no usable guard left, Arti can + // neither build circuits nor replenish, and would return + // AllGuardsDown forever. Wipe the on-disk state so the next + // bootstrap rebuilds a fresh guard sample. + if (noUsableGuards()) { + Log.w("TorService") { "No usable Arti guards left on disk — wiping state to rebuild the guard sample" } + clearAllArtiData() + } + val dataDir = artiDataDir().absolutePath Log.d("TorService") { "Initializing Arti with data dir: $dataDir" } diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index b95debe68d..c146161773 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -190,6 +190,8 @@ إنشاء مفتاح جديد بإنتظار التحديثات جاري تحميل الحساب + + "خطأ في تحميل الردود:" المحاولة مرة اخرى لا توجد إشعارات حتى الآن. diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 396535ea16..c06255d481 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -179,6 +179,8 @@ একটি নতুন অ্যাকাউন্ট তৈরি করুন একটি নতুন চাবি তৈরি করুন পাতাটি লোড করা হচ্ছে + + "প্রতিউত্তরগুলি লোড করা যাচ্ছে না: " আবার চেষ্টা করুন পাতাটি খালি diff --git a/amethyst/src/main/res/values-ca-rES/strings.xml b/amethyst/src/main/res/values-ca-rES/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-ca-rES/strings.xml +++ b/amethyst/src/main/res/values-ca-rES/strings.xml @@ -1,5 +1,7 @@ + + + "Chyba při načítání odpovědí: " Zkusit znovu Zatím žádná oznámení. @@ -2111,6 +2113,15 @@ Přehrávat video v plovoucím okně (skryto, pokud není podporováno) Odeslat do zařízení Odeslat video do přijímače Chromecast ve vaší Wi-Fi (skryto pro místní soubory) + Vizualizér zvuku + Vyberte animaci zobrazenou při přehrávání zvukových poznámek. + Vypnuto + Spektrální pruhy + Color Waves + Radiální prstenec + Aurora Glow + Klasická křivka + Statický obrázek Obrázek profilu %1$s Relé %1$s Rozbalit seznam relací @@ -2862,6 +2873,20 @@ Tento cíl byl ukončen %1$s financováno z %2$s sats cíle + Končí %1$s + On-chain dar + + Birdex · %1$d druh + Birdex · %1$d druhy + Birdex · %1$d druhů + Birdex · %1$d druhů + + + %1$s +%2$d další + %1$s +%2$d další + %1$s +%2$d dalších + %1$s +%2$d dalších + Cílová částka (sats) 100000 Popište svůj cíl diff --git a/amethyst/src/main/res/values-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -1,5 +1,7 @@ + + + + "Fehler beim Laden der Antworten: " Erneut versuchen Noch keine Benachrichtigungen. @@ -2080,6 +2082,15 @@ anz der Bedingungen ist erforderlich Video in einem schwebenden Fenster abspielen (ausgeblendet, wenn nicht unterstützt) An Gerät streamen Sendet das Video an einen Chromecast-Empfänger in deinem WLAN (für lokale Dateien ausgeblendet) + Audio-Visualisierer + Wähle die Animation, die beim Abspielen von Audio-Notizen angezeigt wird. + Aus + Spektrum-Balken + Color Waves + Radialer Ring + Aurora Glow + Klassische Wellenform + Statisches Bild Profilbild von %1$s %1$s weiterleiten Rela-Liste erweitern @@ -2815,6 +2826,16 @@ anz der Bedingungen ist erforderlich Dieses Ziel wurde geschlossen %1$s finanziert von %2$s Sats Ziel + Endet %1$s + On-Chain-Spende + + Birdex · %1$d Art + Birdex · %1$d Arten + + + %1$s +%2$d weitere + %1$s +%2$d weitere + Zielbetrag (Sats) 100000 Beschreibe dein Ziel diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index b1e1948830..fd753e35b5 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -145,6 +145,8 @@ Δημιουργία νέου κλειδιού Φόρτωση ροής Φόρτωση λογαριασμού + + "Σφάλμα κατά τη φόρτωση απαντήσεων: " Προσπαθήστε ξανά Η Ροή είναι κενή. diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 0e294f4e13..fac52ded36 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -7,6 +7,8 @@ Scan QR Show Anyway 👀 + + + "Eraro ŝargante respondojn: " Provi denove Fluo estas malplena. diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index b20623aa6a..80563369c1 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -258,6 +258,8 @@ Generar una nueva clave Cargando el tablón… Cargando cuenta + + "Error al cargar las respuestas: " Intentar otra vez Aún no hay notificaciones. diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index bb031de66f..3a8ea6b97a 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -254,6 +254,8 @@ Generar una clave nueva Cargando feed Cargando cuenta + + "Error al cargar las respuestas: " Intentar de nuevo Aún no hay notificaciones. diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 531ffa9f3c..0fab3df107 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -254,6 +254,8 @@ Generar una clave nueva Cargando feed Cargando cuenta + + "Error al cargar las respuestas: " Intentar de nuevo Aún no hay notificaciones. diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index a683025027..f2f375bdce 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -196,6 +196,8 @@ ساختن جفت کلید جدید در حال بارگیری خبرنامه بارگذاری حساب کاربری + + " :خطا در بارگیری پاسخ ها" دوباره تلاش کنید هنوز هیچ اعلانی نیست. diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 35cc066299..c3450ebb64 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -147,6 +147,8 @@ Luo uusi avain Ladataan syöte Ladataan tiliä + + "Virhe ladattaessa vastauksia: " Yritä uudelleen Syöte on tyhjä. diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,5 +1,7 @@ + + + "Erreur lors du chargement des réponses : " Essayer à nouveau Aucune notification pour le moment. diff --git a/amethyst/src/main/res/values-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -1,5 +1,7 @@ + + + "प्रतिवचनों को प्राप्त करने में अपक्रम : " पुनः प्रयास करें अभी तक कोई सूचनाएँ नहीं। @@ -473,7 +475,7 @@ टीका विभेदक की अनुकृति की गई मतदान खुला - अवरुद्ध + आवृत पदक समुदाय नया समुदाय @@ -597,9 +599,9 @@ अभिलेख सुनें सन्देश प्रेषण असफल इस योजक को खोलने के लिए कोई क्रमक स्थापित नहीं। - क्या इस शाला को अवरोधित करें। - सभी उपस्थित वियोजित हो जाएँगे। शाला अवरुद्ध दिखेगा सूचनावली में। - शाला अवरोधन + क्या इस शाला को आवृत करें। + सभी उपस्थित वियोजित हो जाएँगे। शाला आवृत दिखेगा सूचनावली में। + शाला आवृत करें भविष्य आरम्भ समय का चयन। ध्वनि असफल : %1$s इस शाला के लिए ध्वनि उपलब्ध नहीं @@ -636,10 +638,10 @@ निलय छोडकर गए निर्गमन कया इस निलय को अवरुद्ध करें। - आप निमन्त्रक हैं। शाला अवरुद्ध करने से सभी वियोजित हो जाएँगे। \"केवल निर्गमन\" का चयन करें यदि आप लौटना चाहते हें कुछ समय पश्चात। शाला स्वतः अवरुद्ध होगी ८ घण्टों की निष्क्रियता पश्चात। - शाला अवरोधन + आप निमन्त्रक हैं। शाला आवृत करने से सभी वियोजित हो जाएँगे। \"केवल निर्गमन\" का चयन करें यदि आप लौटना चाहते हें कुछ समय पश्चात। शाला स्वतः आवृत होगी ८ घण्टों की निष्क्रियता पश्चात। + शाला आवृत करें केवल निर्गमन - शाला अवरुद्ध चिह्नित करने में असफल। तथापि निर्गमन चालू। शाला स्वतः अवरुद्ध हो जाएगी। + शाला आवृत चिह्नित करने में असफल। तथापि निर्गमन चालू। शाला स्वतः आवृत हो जाएगी। %1$d श्रोता %1$d श्रोतागण @@ -655,7 +657,7 @@ प्रतिक्रिया शाला सम्पादन अभिलेखन - शाला अवरोधन + शाला आवृत करें शाला क्रियाएँ उठे हुए हाथ स्वीकार @@ -895,7 +897,7 @@ के पश्चात समाप्त करें दिन निर्वाचन करने में असफल - मतदान अवरोधित है नये निर्वाचनों के लिए + मतदान आवृत है नये निर्वाचनों के लिए ज्साप मात्रा प्रत्येक उपयोगकर्ता को केवल एक निर्वाचन करने की अनुमति है इस प्रकार के मतदान में। "घटना %1$s के लिए खोज चल रहा है" @@ -1076,7 +1078,7 @@ चलचित्र चलचित्र अवरोहण के लिए टोर का प्रयोग अवश्य करें व्यापार - ज्साप लैटनिंग तथा काशयु व्यापार के लिए टोर का प्रयोग अवश्य करें + ज्साप लैटनिंग तथा काशयू व्यापार के लिए टोर का प्रयोग अवश्य करें नोस्ट्र पता सत्यापन निप॰-०५ पता सत्यापन के लिए टोर का प्रयोग अवश्य करें चित्र चलचित्र अभिलेख आरोहण @@ -1329,14 +1331,14 @@ अधिकतम सीमा (घटनाएँ प्रतिफलित) मूलविकल्प सीमा (घटनाएँ प्रतिफलित) अधिकतम ग्राहकताविभेदक लम्बाई - काशयु राशिखण्ड + काशयू अक्षरराशि चुकाएँ ज्साप धनकोष को भेजें - काशयु धनकोष में खोलें - राशिखण्ड की अनुकृति करें + काशयू धनकोष में खोलें + अक्षरराशि की अनुकृति करें अन्य क्रमक में खोलें कोई लैटनिंग पता स्थापित नहीं - राशिखण्ड की अनुकृति की गयी टाँकाफलक में + अक्षरराशि की अनुकृति की गयी टाँकाफलक में तत्क्षणप्रसार जालरहित समाप्त @@ -1346,7 +1348,7 @@ तत्क्षणप्रसार समाप्त खुला निजी - निबद्ध + आवृत समय निर्धारित आरम्भ %1$s निर्गमनांकन करने पर आपकी सारी स्थानीय जानकारी मिट जाएगी। सुनिश्चित करें कि आपके निजी कुंचिकाएँ सुरक्षित रखें हैं अपनी लेखा नहीं खोना चाहते हैं तो। क्या आप आगे बढना चाहते हैं? @@ -1580,12 +1582,12 @@ धनकोष संयोजन स्थापित नहीं कर पाए निप॰-४७ संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s। अपक्रम : %2$s निप॰-४७ संयोजन सूत्र परखने में अपक्रम। जाँच करें यदि यह सम्यक है अपने धनकोष सेवा प्रदाता के साथ : %1$s। - काशयु नहीं चुका पाए + काशयू नहीं चुका पाए टकसाल ने यह अपक्रम संदेश उपलब्ध किया : %1$s - काशयु राशिखण्ड का व्यय हो चुका। - काशयु प्राप्त + काशयू अक्षरराशि का व्यय हो चुका। + काशयू प्राप्त %1$s साट्स भेजे गये आपके धनकोष में। (शुल्क : %2$s साट्स) - कोई अनुकूल काशयु धनकोष उपलब्ध नहीं यन्त्र में + कोई अनुकूल काशयू धनकोष उपलब्ध नहीं यन्त्र में ग्राहक के सेवा संगणक से चालान नहीं लाया जा सका आपका धनकोष संयोजन प्रदाता ने यह अपक्रम लौटाया : %1$s टोर से जुड नहीं पाए @@ -1623,7 +1625,7 @@ निष्क्रिय संयुक्तप्रेषण क्रमक का चयन करें प्रेषित सूचना - स्थापित संयुक्तप्रेषण क्रमकों से + एक संयुक्तप्रेषण क्रमक का चयन करें सूचनाओं को भेजने के लिए जब अमेथिस्ट आवृत हो। कुछ भी नहीं प्रेषित सूचनाएँ अक्षम करता है %1$s क्रमक का प्रयोग करता है @@ -1767,10 +1769,10 @@ लैटनिंग॰ धनकोष का संयोजन (एनडबल्यूसी॰) बाहरी लैटनिंग॰ धनकोष से भुगतान नोस्टर धनकोष संयोजन के प्रयोग से। काशयू॰ धनकोष (निप॰६०) - एक क्रमकाभ्यन्तर ईकाश॰ धनकोष जो एक काशयू॰ टकसाल द्वारा समर्थित है। राशिखण्ड रहस्यकृत रूप में पुनःप्रसारकों में रखे जाते हैं। + एक क्रमकाभ्यन्तर ईकाश॰ धनकोष जो एक काशयू॰ टकसाल द्वारा समर्थित है। अक्षरराशि रहस्यकृत रूप में पुनःप्रसारकों में रखे जाते हैं। काशयू॰ धनकोष कोई काशयू॰ धनकोष नहीं अब तक - एक काशयू॰ धनकोष बनाएँ ईकाश॰ राशीखण्ड रखने के लिए तथा नटज्साप॰ प्राप्त करने के लिए। + एक काशयू॰ धनकोष बनाएँ ईकाश॰ अक्षरराशि रखने के लिए तथा नटज्साप॰ प्राप्त करने के लिए। आपके धनकोष की खोज में… निप॰६० धनकोषों का समचरणीकरण ग्राहकों के बीच होता है। यदि आपने अन्य क्रमक में एक बनाया है इस नोस्टर कुंचिका के साथ तो वह कुछ सेकण्डों में दृश्य होना चाहिए। शेष @@ -1802,13 +1804,13 @@ इस टकसाल की अनुशंसा करें प्राप्त करें भेजें लैटनिंग॰ - राशीखण्ड भेजें + अक्षरराशि भेजें चुकाएँ संख्या (साट्स) टकसाल का चयन करें ज्ञापक (विकल्पात्मक) लैटनिंग॰ चालान (बोल्ट११) - काशयू राशिखण्ड + काशयू अक्षरराशि चालान अनुकृति चालान अनुरोध चालान निकाल दें @@ -1824,7 +1826,7 @@ अपके द्वारा %1$s का प्रकार ३८००० अनुशंसा को मिटाने का अनुरोध प्रकाशित करें। पुनःप्रसारक जो निप॰०९ का सम्मान करते हैं उसे हटा देगा। टकसाल की अनुशंसा करें बीज से पुनःप्राप्त करें - प्रत्येक पूर्व रहस्य की पुनःव्युत्पत्ति करें तथा प्रत्येक टकसाल को अनुरोध करें कि जो भी अन्धा हस्ताक्षर अब भी अभिलेख में हों लौटा दें। यन्त्र खो देना अथवा दुष्ट पुनःप्रसारक द्वारा आपके राशीखण्ड घटनाओं को मिटाने के पश्चात उपयोगी। + प्रत्येक पूर्व रहस्य की पुनःव्युत्पत्ति करें तथा प्रत्येक टकसाल को अनुरोध करें कि जो भी अन्धा हस्ताक्षर अब भी अभिलेख में हों लौटा दें। यन्त्र खो देना अथवा दुष्ट पुनःप्रसारक द्वारा आपके अक्षरराशि घटनाओं को मिटाने के पश्चात उपयोगी। टकसालों की जाँच चालू… कोई पुनःप्राप्य प्रमाण प्राप्त नहीं। %1$s साट्स पुनःप्राप्त %2$s प्रमाणों में से। @@ -1840,7 +1842,7 @@ नटज्साप॰ असफल कोई प्राप्तकर्ता ख्याप्यकुंचिका नहीं टीका में घटना उद्धरण बनाने में अक्षम - राशीखण्ड बनाएँ + अक्षरराशि बनाएँ चुकाएँ हो गया टकसाल से चालान का अनुरोध… @@ -1852,7 +1854,7 @@ इस चालान का भुगतान टकसाल को %1$s साट्स ईकाश॰ में। %1$s साट्स प्राप्त। %1$s साट्स भुगतान किया गया (शुल्क : %2$s साट्स)। - %1$s साट्स का राशीखण्ड तत्पर। इसको प्राप्तकर्ता को बाँटें। + %1$s साट्स का अक्षरराशि तत्पर। इसको प्राप्तकर्ता को बाँटें। %1$s साट्स चुकाया गया आपके धनकोष में। nostr+walletconnect:// पता चिपकाएँ अभिलेखन करें @@ -2021,7 +2023,7 @@ विषयसूचक जोडें\u2026 अपना लेख लिखना आरम्भ करें\u2026 इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें - इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें + इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को आवृत करें उत्तर उस टीका तक जाएँ जिसका यह उत्तर है उद्धृत करें अथवा टीका लिखें @@ -2054,7 +2056,7 @@ उद्धृत करें पत्र पुनःप्रकाशित करें अथवा टीका लिखें प्रिय - प्रतिक्रिया करें इस पत्र पर चित्राक्षर द्वारा + प्रतिक्रिया करें इस पत्र पर भावचिह्न द्वारा ज्साप लेखक को लैटनिंग भुगतान भेजें बाँटें @@ -2200,7 +2202,7 @@ अनुकृति : खुला विलीन - अवरुद्ध + आवृत पाण्डुलिपि रूपरेखा विवरण समस्याएँ @@ -2498,8 +2500,8 @@ अनुचरण सूची मिटाए गए पाण्डुलिपियाँ - चित्राक्षर पोटलियाँ - चित्राक्षर पोटली सूची + भावचिह्न पोटलियाँ + भावचिह्न पोटली सूची अनभिलिखित चर्चा अस्थायी चर्चाशालाएँ अभिलेख शीर्षक @@ -2817,6 +2819,14 @@ %1$s वित्तपोषित %2$s साट्स उद्देश्य में से समाप्ति %1$s खण्डश्रृंखलाबद्ध दान + + बिर्डेक्स। %1$d प्रजाति + बिर्डेक्स। %1$d प्रजातियाँ + + + %1$s सं॰ %2$d अधिक + %1$s सं॰ %2$d अधिक + उद्देश्य संख्या (साट्स) १००००० आपके उद्देश्य का विवरण करें @@ -2940,14 +2950,14 @@ व्यावसायिक अधिक सीधा आघाती - चित्राक्षर युक्त + भावचिह्न युक्त - चित्राक्षर पोटलियाँ - चित्राक्षर सूची में जोडें - मेरे चित्राक्षर सूची में जोडें - मेरे चित्राक्षर सूची में से हटाएँ - नव्य चित्राक्षर पोटली - चित्राक्षर पोटली सम्पादन + भावचिह्न पोटलियाँ + भावचिह्न सूची में जोडें + मेरे भावचिह्न सूची में जोडें + मेरे भावचिह्न सूची में से हटाएँ + नव्य भावचिह्न पोटली + भावचिह्न पोटली सम्पादन छोटा लेखविधि (उ॰ :smile:) केवल अक्षर अंक योजकचिह्न तथा अधोरेखाएँ चित्र जालपता @@ -2956,27 +2966,27 @@ विवरण (विकल्पात्मक) बाहरी चित्र जालपता (वैकल्पिक) बाहरी चित्र आरोहण करें - एक वर्गाकार चित्र चुनें इस चित्राक्षर पोटली के प्रतिनिधि के रूप में। - आपका कोई चित्राक्षर पोटली नहीं अब तक - %1$d चित्राक्षर - मेरी चित्राक्षर सूची - चित्राक्षर पोटलियाँ आपने जोडे हैं आपके चयन में (निप॰५१ प्रकार १००३०) - आपने कोई चित्राक्षर पोटलियाँ जोडे नहीं आपकी सूची में अब तक + एक वर्गाकार चित्र चुनें इस भावचिह्न पोटली के प्रतिनिधि के रूप में। + आपका कोई भावचिह्न पोटली नहीं अब तक + %1$d भावचिह्न + मेरी भावचिह्न सूची + भावचिह्न पोटलियाँ आपने जोडे हैं आपके चयन में (निप॰५१ प्रकार १००३०) + आपने कोई भावचिह्न पोटलियाँ जोडे नहीं आपकी सूची में अब तक %1$s द्वारा - चित्राक्षर जोडें - विशिष्ट चित्राक्षर जोडें + भावचिह्न जोडें + विशिष्ट भावचिह्न जोडें क्या इसे हटाएँ :%1$s:। - चित्राक्षर हटाने के लिए उसे दीर्घतः दबाएँ - \"%1$s\" आपकी चित्राक्षर सूची में है - \"%1$s\" आपकी चित्राक्षर सूची में नहीं है - चित्राक्षर पोटली क्रियाएँ - मेरे चित्राक्षर पोटलियाँ - चित्राक्षर - चित्राक्षर समुच्चय वीक्षण + भावचिह्न हटाने के लिए उसे दीर्घतः दबाएँ + \"%1$s\" आपकी भावचिह्न सूची में है + \"%1$s\" आपकी भावचिह्न सूची में नहीं है + भावचिह्न पोटली क्रियाएँ + मेरे भावचिह्न पोटलियाँ + भावचिह्न + भावचिह्न समुच्चय वीक्षण निजी - निजी चित्राक्षर - सार्वजनिक चित्राक्षर सब के लिए दृश्य हैं तथा आपके प्रतिक्रिया विकल्पसूची में उपस्थित होते हैं तथा \":\" स्वतःसम्पन्न चयनकर्ता में जब यह पोटली आपकी चित्राक्षर सूची में है। - निजी चित्राक्षर रहस्यीकृत रूप में रखे जाते हैं पुनःप्रसारकों में तथा केवल आप के लिए दृश्य हैं। आपके प्रतिक्रिया विकल्पसूची में उपस्थित होते हैं तथा \":\" स्वतःसम्पन्न सुविधा में सार्वजनिक चित्राक्षरों के जैसे ही। + निजी भावचिह्न + सार्वजनिक भावचिह्न सब के लिए दृश्य हैं तथा आपके प्रतिक्रिया विकल्पसूची में उपस्थित होते हैं तथा \":\" स्वतःसम्पन्न चयनकर्ता में जब यह पोटली आपकी भावचिह्न सूची में है। + निजी भावचिह्न रहस्यीकृत रूप में रखे जाते हैं पुनःप्रसारकों में तथा केवल आप के लिए दृश्य हैं। आपके प्रतिक्रिया विकल्पसूची में उपस्थित होते हैं तथा \":\" स्वतःसम्पन्न सुविधा में सार्वजनिक भावचिह्नों के जैसे ही। जिफ॰ समुदाय नियभ उल्लंघन diff --git a/amethyst/src/main/res/values-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -1,5 +1,7 @@ + + + Nem sikerült megtalálni ezt az üzenetet + + Minden átjátszó le lett kérve · érintse meg a megtekintéshez "Hiba a válaszok betöltésekor: " Próbálja újra Még nincsenek értesítések. @@ -1273,7 +1280,7 @@ Adatvédelmi irányelvek Általános szerződési feltételek Gyermekbiztonsági szabványok - Névjegy & Jogi információk + Névjegy és jogi információk Nem érhető el Hibák és megjegyzések ettől az átjátszótól Átjátszófigyelési jelentések @@ -2080,6 +2087,15 @@ Videó lejátszása lebegő ablakban (rejtett, ha a rendszer nem támogatja) Közvetítés eszközre Videó küldése a Wi-Fi hálózaton található Chromecast eszközre (helyi fájloknál nem érhető el) + Hangvizualizáló + Válassza ki a hangjegyzetek lejátszása közben megjelenő animációt. + Ki + Spektrumsávok + Színes hullámok + Radiális gyűrű + Sarkifény + Klasszikus hullámforma + Statikus kép %1$s profilképe %1$s átjátszó Átjátszólista kibontása @@ -2204,7 +2220,7 @@ Vázlat Áttekintés Hibajegyek - Foltok & Beolvasztási kérések + Foltok és beolvasztási kérések Névjegy Hivatkozások Karbantartók @@ -2815,6 +2831,16 @@ Ez a gyűjtési cél lezárult %1$s gyűlt össze a(z) %2$s satoshis célból + Véget ér ekkor: %1$s + Láncon belüli adomány + + Madárnapló · %1$d faj + Madárnapló · %1$d faj + + + %1$s +%2$d további + %1$s +%2$d további + Célösszeg (satoshiban) 100 000 Cél leírása, bemutatása diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 684b46676c..0732223618 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -143,6 +143,8 @@ Buat kunci baru Memuat umpan Memuat akun + + "Gagal memuat balasan: " Coba lagi Umpan kosong. diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 33031a4ce4..3f71cd6e97 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -138,6 +138,8 @@ Accesso Genera una nuova chiave Caricamento del feed + + "Errore durante il caricamento delle risposte: " Prova di nuovo Il feed è vuoto. diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -1,5 +1,7 @@ + + + "リプライ読み込みエラー: " 後ほどお試しください フィードが空です diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -1,5 +1,7 @@ + + + + + + Atsvaidzināt aprakstu uz un attēlu uz diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -1,5 +1,7 @@ + + + "Fout bij laden reacties:" Opnieuw proberen Nog geen meldingen. diff --git a/amethyst/src/main/res/values-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -1,5 +1,7 @@ + + + Nie można znaleźć tej wiadomości + + Przeszukano wszystkie transmitery · kliknij, by wyświetlić "Błąd wczytywania odpowiedzi: " Spróbuj ponownie Brak powiadomień. @@ -2102,6 +2109,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Odtwórz wideo w pływającym oknie (opcja ukryta, jeśli nie jest obsługiwana) Przenieś na urządzenie Prześlij film do urządzenia Chromecast podłączonego do sieci Wi-Fi (opcja ukryta w przypadku plików lokalnych) + Wizualizator dźwięku + Wybierz animację wyświetlaną podczas odtwarzania plików audio. + Wyłączone + Spektrogram + Barwa fali + Pierścień promieniowy + Aurora Glow + Fala klasyczna + Obraz statyczny Zdjęcie profilowe %1$s Transmiter %1$s Rozwiń listę transmiterów @@ -2855,6 +2871,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Sfinansowano %1$s z %2$s satoszów Kończy się %1$s Darowizna on-chain + + Birdex · %1$d gatunek + Birdex · %1$d gatunków + Birdex · %1$d gatunków + Birdex · %1$d gatunki + + + %1$s +%2$d więcej + %1$s +%2$d więcej + %1$s +%2$d więcej + %1$s +%2$d więcej + Kwota zbiorki (w satoszach) 100000 Opisz cel zbiórki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 00d678614d..8a79361319 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -263,6 +263,8 @@ Gerar uma nova chave Carregando feed Carregando conta + + "Erro ao carregar respostas" Tente novamente Ainda não há notificações. @@ -2075,6 +2077,15 @@ Reproduzir o vídeo em uma janela flutuante (oculto se não suportado) Transmitir para dispositivo Envia o vídeo para um receptor Chromecast na sua Wi-Fi (oculto para arquivos locais) + Visualizador de áudio + Escolha a animação exibida enquanto as notas de áudio são reproduzidas. + Desligado + Barras de espectro + Color Waves + Anel radial + Aurora Glow + Forma de onda clássica + Imagem estática Foto de perfil de %1$s Repassar %1$s Expandir lista de repasse @@ -2810,6 +2821,16 @@ Esta meta foi encerrada %1$s financiado de %2$s sats da meta + Termina %1$s + Doação on-chain + + Birdex · %1$d espécie + Birdex · %1$d espécies + + + %1$s +%2$d a mais + %1$s +%2$d a mais + Valor da meta (sats) 100000 Descreva sua meta diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1,5 +1,7 @@ + + + "Не удалось загрузить ответы: " Повторить Уведомлений пока нет. diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1,5 +1,7 @@ + + + + "Napaka pri nalaganju odgovorov: " Poskusi ponovno Ni še obvestil. diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 4affe42d0f..c880fc82b7 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -33,6 +33,8 @@ Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте могли да појачате постове Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове Нема подешавања износа Зап. Дуго притисните да бисте променили + + + "Det gick inte att läsa in svar: " Försök igen Inga aviseringar ännu. @@ -2074,6 +2076,15 @@ Spela upp videon i ett flytande fönster (dolt om det inte stöds) Casta till enhet Skicka videon till en Chromecast-mottagare på ditt Wi-Fi (dolt för lokala filer) + Ljudvisualiserare + Välj animeringen som visas när ljudanteckningar spelas upp. + Av + Spektrumstaplar + Color Waves + Radiell ring + Aurora Glow + Klassisk vågform + Statisk bild %1$s Profilbild Relä %1$s Expandera relälistan @@ -2809,6 +2820,16 @@ Detta mål har avslutats %1$s finansierat av %2$s sats mål + Slutar %1$s + On-chain-donation + + Birdex · %1$d art + Birdex · %1$d arter + + + %1$s +%2$d till + %1$s +%2$d till + Målbelopp (sats) 100000 Beskriv ditt mål diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index 4b889c105f..4508fd4127 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -181,6 +181,8 @@ Ingia Tengeneza Ufunguo Mpya Inapakia malisho + + "Kosa katika kupakia majibu: " Jaribu tena Malisho ni tupu. diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -1,5 +1,7 @@ + + + "பதில்களை ஏற்றுவதில் பிழை: " மறுமுயற்சி செய் ஊட்டம் காலியாக உள்ளது. diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index c6da3a7878..2e6469c6e8 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -177,6 +177,8 @@ สร้าง key ใหม่ กำลังดาวน์โหลดฟีด กำลังโหลดข้อมูลบัญชี + + "ไม่สามารถโหลดการตอบกลับ: " ลองอีกครั้ง ฟีดนี้ว่างเปล่า diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 5a2f412eaa..9e6f62ce6d 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -133,6 +133,8 @@ Giriş Yap Yeni anahtar oluştur Akış yükleniyor + + "Yanıtları yüklemekte hata: " Tekrar dene Akış boş diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index ca3a4d855c..6b9adb96aa 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -153,6 +153,8 @@ Згенерувати ключ Завантаження стрічки Завантаження облікового запису + + "Не вдалося завантажити відповіді: " Повторити Стрічка порожня. diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index ac4bb59b92..9cd665cb7a 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -68,6 +68,8 @@ " va " "bu kanalda: " Profil banneri + + + + 找不到这条消息 + + 搜索了每个中继 · 点击查看 "加载回复出错:" 重试 暂无通知 @@ -2062,6 +2069,15 @@ 在浮动窗口播放视频(不支持时隐藏) 投射到设备 发送视频到您Wi-Fi上的 Chromecast 接收器(本地文件隐藏) + 音频可视化 + 选择音频笔记播放时显示的动画。 + 关 + 柱状频谱 + 彩色波形 + 辐射环 + 极光 + 经典波形 + 静态图像 %1$s 的个人头像 中继器 %1$s 展开中继列表 @@ -2791,6 +2807,12 @@ 设定目标为 %2$s sats,筹集到 %1$s 结束 %1$s 链上捐助 + + Birdex · %1$d 个物种 + + + %1$s + 另%2$d + 目标金额 (sats) 100000 描述您的目标 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 500bbcfa12..e3a43968b6 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -135,6 +135,8 @@ 登錄 生成新密鑰 正在加載 + + "加載回覆出錯:" 重試 信息流為空。 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index d0d35d48d7..5e03e4fdf5 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1,5 +1,7 @@ + + + "加載回覆出錯:" 重試 信息流為空。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index debeb95ff7..ad5044f7e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -214,6 +214,7 @@ Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners. User does not have a lightning address set up to receive sats "reply here… " + In this chat Copies the Note ID to the clipboard for sharing in Nostr Copy Channel ID (Note) to the Clipboard Edits the Channel Metadata @@ -274,6 +275,13 @@ Generate a new key Loading feed Loading account + encrypted + legacy + Looking for the original message… + + Couldn\'t find this message + + Searched every relay · tap to see "Error loading replies: " Try again No notifications yet. @@ -1526,6 +1534,7 @@ dvm, data vending machine, algo, algorithm, feeds emoji, like, reaction video, player, playback, autoplay, mute + audio, visualizer, spectrum, bars, waves, radial, aurora, animation zap split, split, recipients, forward zaps webrtc, video call, voice call, calls language, translate, locale @@ -2336,6 +2345,16 @@ Cast to Device Send the video to a Chromecast receiver on your Wi-Fi (hidden for local files) + Audio Visualizer + Choose the animation shown while audio notes play. + Off + Spectrum Bars + Color Waves + Radial Ring + Aurora Glow + Classic Waveform + Static Image + Profile Picture of %1$s Relay %1$s Expand relay list diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt new file mode 100644 index 0000000000..fed8c9f63b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.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 + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.rankPriorityFirst +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Locks in the @-mention priority semantics: priority pubkeys only move + * users that already matched the search to the top of the list — they + * never inject new entries, never remove any, and never disturb the + * search's relevance order within the priority / non-priority groups. + */ +class UserSuggestionPriorityRankingTest { + // User eagerly pins a few addressable note shells on construction; + // empty shells are enough since the ranking never reads them. + private val noContext = UserContext { addr -> AddressableNote(addr) } + + private fun user(hex: String) = User(hex, noContext) + + private val alice = user("aa".repeat(32)) + private val bob = user("bb".repeat(32)) + private val carol = user("cc".repeat(32)) + private val dave = user("dd".repeat(32)) + + @Test + fun emptyPriorityKeepsTheListUntouched() { + val found = listOf(alice, bob, carol) + + assertSame(found, rankPriorityFirst(found, emptySet())) + } + + @Test + fun priorityUsersMoveToTheTop() { + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex)) + + assertEquals(listOf(carol, alice, bob, dave), ranked) + } + + @Test + fun relativeOrderIsPreservedWithinBothGroups() { + // findUsersStartingWith returns relevance order; the stable sort + // must keep alice-before-carol (priority) and bob-before-dave (rest). + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(alice.pubkeyHex, carol.pubkeyHex)) + + assertEquals(listOf(alice, carol, bob, dave), ranked) + } + + @Test + fun priorityKeysThatDidNotMatchTheSearchAreNotInjected() { + val found = listOf(alice, bob) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex, dave.pubkeyHex)) + + assertEquals(found, ranked) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquareTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquareTest.kt new file mode 100644 index 0000000000..f86315bc67 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquareTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioPlayerSquareTest { + @Test + fun squareWhenMimeSaysAudio() { + assertTrue(shouldSquareAudioPlayer(isFullscreen = false, isVoiceNote = false, isAudioMime = true, tracksAreAudio = false)) + } + + @Test + fun squareWhenTracksResolveAudio() { + assertTrue(shouldSquareAudioPlayer(isFullscreen = false, isVoiceNote = false, isAudioMime = false, tracksAreAudio = true)) + } + + @Test + fun noSquareForVideo() { + assertFalse(shouldSquareAudioPlayer(isFullscreen = false, isVoiceNote = false, isAudioMime = false, tracksAreAudio = false)) + } + + @Test + fun noSquareForVoiceNoteEvenIfAudio() { + assertFalse(shouldSquareAudioPlayer(isFullscreen = false, isVoiceNote = true, isAudioMime = true, tracksAreAudio = true)) + } + + @Test + fun noSquareInFullscreen() { + assertFalse(shouldSquareAudioPlayer(isFullscreen = true, isVoiceNote = false, isAudioMime = true, tracksAreAudio = true)) + } + + @Test + fun sideIsWidthBelowCap() { + assertEquals(360, audioSquareSide(widthPx = 360, maxHeightPx = 1000)) + } + + @Test + fun sideIsCappedAboveCap() { + assertEquals(1000, audioSquareSide(widthPx = 1800, maxHeightPx = 1000)) + } + + @Test + fun sideEqualsCapAtBoundary() { + assertEquals(1000, audioSquareSide(widthPx = 1000, maxHeightPx = 1000)) + } + + @Test + fun sideIsZeroWhenWidthIsZero() { + assertEquals(0, audioSquareSide(widthPx = 0, maxHeightPx = 1000)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt new file mode 100644 index 0000000000..6e9ce2ea35 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SpectrumAudioBufferSinkTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.PI +import kotlin.math.sin + +@OptIn(UnstableApi::class) +class SpectrumAudioBufferSinkTest { + private val fftSize = 64 + private val binCount = 16 + + private fun sineShorts( + k: Int, + n: Int, + ): ShortArray = ShortArray(n) { (sin(2.0 * PI * k * it / n) * 30000).toInt().toShort() } + + private fun monoPcm(samples: ShortArray): ByteBuffer { + val bb = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN) + for (s in samples) bb.putShort(s) + bb.flip() + return bb + } + + private fun interleavedStereoPcm( + left: ShortArray, + right: ShortArray, + ): ByteBuffer { + val bb = ByteBuffer.allocate(left.size * 4).order(ByteOrder.LITTLE_ENDIAN) + for (i in left.indices) { + bb.putShort(left[i]) + bb.putShort(right[i]) + } + bb.flip() + return bb + } + + private fun maxBin(spectrumBins: FloatArray): Int = spectrumBins.indices.maxByOrNull { spectrumBins[it] } ?: -1 + + @Test + fun lowFrequencySineLightsLowBins() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out + sink.flush(48000, 1, C.ENCODING_PCM_16BIT) + sink.handleBuffer(monoPcm(sineShorts(k = 2, n = fftSize))) + + val bins = + out.replayCache + .last() + .bins + assertEquals(binCount, bins.size) + assertTrue("expected low bin to dominate, got ${maxBin(bins)}", maxBin(bins) < binCount / 2) + } + + @Test + fun highFrequencySineLightsHighBins() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out + sink.flush(48000, 1, C.ENCODING_PCM_16BIT) + sink.handleBuffer(monoPcm(sineShorts(k = 28, n = fftSize))) + + val bins = + out.replayCache + .last() + .bins + assertTrue("expected high bin to dominate, got ${maxBin(bins)}", maxBin(bins) >= binCount / 2) + } + + @Test + fun stereoIsDownmixedFromFirstChannel() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out + sink.flush(48000, 2, C.ENCODING_PCM_16BIT) + sink.handleBuffer(interleavedStereoPcm(sineShorts(k = 2, n = fftSize), ShortArray(fftSize))) + + val bins = + out.replayCache + .last() + .bins + assertTrue(maxBin(bins) < binCount / 2) + } + + @Test + fun emitsOnlyAfterAFullFftWindowAccumulates() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out + sink.flush(48000, 1, C.ENCODING_PCM_16BIT) + val full = sineShorts(k = 4, n = fftSize) + + sink.handleBuffer(monoPcm(full.copyOfRange(0, fftSize / 2))) + assertTrue("no frame should emit before a full window", out.replayCache.isEmpty()) + + sink.handleBuffer(monoPcm(full.copyOfRange(fftSize / 2, fftSize))) + assertEquals(1, out.replayCache.size) + } + + @Test + fun nonPcm16EncodingEmitsNothing() { + val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount) + val out = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + sink.output = out + sink.flush(48000, 1, C.ENCODING_PCM_FLOAT) + sink.handleBuffer(monoPcm(sineShorts(k = 4, n = fftSize))) + + assertTrue("non-16-bit PCM must not emit a spectrum", out.replayCache.isEmpty()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt new file mode 100644 index 0000000000..b297dabc9f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RelayLoadingCursorsTest { + private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.relay")!! + private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.relay")!! + private val start = 1_000L + + @Test + fun unarmedRelayIsNotRequestedAndSitsAtTheFloor() { + val cursors = RelayLoadingCursors() + // never advanced, so it carries no REQ + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) + // marker sits at the floor until it delivers + assertEquals(start, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun firstAdvanceRequestsTheFloorThenSubsequentPagesStepBelowReached() { + val cursors = RelayLoadingCursors() + + assertTrue(cursors.advance(relayA, start)) + assertEquals(start, cursors.requestedUntilFor(relayA)) + + // page returns events; oldest seen = 800 + cursors.onEvent(relayA, 900) + cursors.onEvent(relayA, 800) + cursors.onEose(relayA) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) + // EOSE does NOT move the requested cursor — the relay parks at the same filter + assertEquals(start, cursors.requestedUntilFor(relayA)) + + // next advance steps to reached - 1 + assertTrue(cursors.advance(relayA, start)) + assertEquals(799L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun emptyPageMarksRelayDoneAndBlocksFurtherAdvance() { + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEose(relayA) // no events + assertTrue(cursors.isDone(relayA)) + assertFalse(cursors.advance(relayA, start)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) + } + + @Test + fun aPageThatDoesNotStepOlderEndsTheRelayInsteadOfLooping() { + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEvent(relayA, 800) + cursors.onEose(relayA) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) + + // misbehaving relay: next page echoes an event no older than what we already reached + cursors.advance(relayA, start) // requested = 799 + cursors.onEvent(relayA, 900) // newer than reached(800) — not strictly older + cursors.onEose(relayA) + assertTrue("a non-advancing page should end the relay, not re-loop", cursors.isDone(relayA)) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun relaysAreTrackedIndependently() { + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEvent(relayA, 500) + cursors.onEose(relayA) + // B never advanced + assertEquals(listOf(relayA), cursors.armedRelays(listOf(relayA, relayB))) + assertEquals(500L, cursors.reachedUntilFor(relayA, start)) + assertEquals(start, cursors.reachedUntilFor(relayB, start)) + // deepest reached across both = A's 500 (B counts as the floor) + assertEquals(500L, cursors.deepestReached(listOf(relayA, relayB), start)) + } + + @Test + fun deepestReachedIsNullWhenNoRelays() { + val cursors = RelayLoadingCursors() + assertEquals(null, cursors.deepestReached(emptyList(), start)) + } + + // ── rewindTo: realign the window after the cache prunes messages out of it ── + + @Test + fun rewindReopensThePrunedBandAndResumesFromItOnNextAdvance() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + // page deep: floor 1000 → reached 200 + cursors.advance(relayA, start) + cursors.onEvent(relayA, 900) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + + // prune drops everything older than 700 (newest pruned = 700) + cursors.rewindTo(mapOf(relayA to 700L)) + + // reached pulled up to just above the pruned band, not done, and un-armed (demand-driven) + assertEquals(701L, cursors.reachedUntilFor(relayA, start)) + assertFalse(cursors.isDone(relayA)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) + + // the next advance resumes at the boundary and re-requests the pruned band (until = 700), + // NOT from the floor (which would re-stream the still-held tail above 700) + assertTrue(cursors.advance(relayA, start)) + assertEquals(700L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun rewindClearsDoneSoAnExhaustedRelayCanReFetch() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + cursors.advance(relayA, start) + cursors.onEvent(relayA, 300) + cursors.onEose(relayA) // reached 300 + cursors.advance(relayA, start) + cursors.onEose(relayA) // empty page → done + assertTrue(cursors.isDone(relayA)) + + cursors.rewindTo(mapOf(relayA to 500L)) + + assertFalse("a pruned relay must be re-fetchable even after it reached the bottom", cursors.isDone(relayA)) + assertEquals(501L, cursors.reachedUntilFor(relayA, start)) + assertTrue(cursors.advance(relayA, start)) + assertEquals(500L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun rewindNeverClimbsAboveTheFloor() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + cursors.advance(relayA, start) + cursors.onEvent(relayA, 300) + cursors.onEose(relayA) // reached 300 + + // a boundary at/above the floor clamps to the floor (history lives strictly below it) + cursors.rewindTo(mapOf(relayA to start)) + assertEquals(start, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun rewindSkipsRelaysWithoutACursorOrShallowerThanThePrunedBand() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + // A delivered to 200; B never paged + cursors.advance(relayA, start) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + + // B has no cursor (never paged) → skipped, no entry minted; A's reach (200) is already shallower + // than a boundary of 150 (target 151), so it needs no rewind either. + cursors.rewindTo(mapOf(relayB to 500L, relayA to 150L)) + + // B: untouched (still at the floor, unarmed) + assertEquals(start, cursors.reachedUntilFor(relayB, start)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayB))) + // A: unchanged + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun rewindIsANoOpWhenTheWindowNeverPagedHistory() { + val cursors = RelayLoadingCursors() + // floor is null (never advanced any history page) + cursors.advance(relayA, start) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + + cursors.rewindTo(mapOf(relayA to 150L)) + + // unchanged: with no pinned floor there is no history window to realign + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt new file mode 100644 index 0000000000..d78a1d0d6c --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.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.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +/** + * Real-time tests for the **idle** backstop: when a relay streams stored events but never sends EOSE, + * the window can't complete on "every relay settled", so the idle timer finishes it once the stream + * goes quiet — but only after every still-pending relay has been *heard from*, so a slow connect (a + * relay that hasn't answered yet) is never mistaken for a stream that has gone quiet. + */ +class WindowLoadTrackerIdleTest { + private val good = NormalizedRelayUrl("wss://vitor.nostr1.com/") + private val streamer = NormalizedRelayUrl("wss://relay.damus.io/") + + @Test + fun idleBackstopCompletesARelayThatStreamsButNeverEoses() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val tracker = WindowLoadTracker(name = "test", idleTimeout = 50.milliseconds) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, streamer)) + tracker.onRelaySettled(good) // `good` finishes with an EOSE + // `streamer` keeps delivering stored events but never sends EOSE — so "every relay settled" + // can never complete this window. Its events keep it "heard from" (idle gate satisfied). + tracker.onRelayEvent(streamer) + tracker.onRelayEvent(streamer) + + // The only way out is the idle backstop, once the stream stays quiet for idleTimeout. + withTimeout(3000) { tracker.loading.first { !it } } + scope.cancel() + } + + @Test + fun idleBackstopWaitsUntilEveryPendingRelayHasBeenHeardFrom() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val tracker = WindowLoadTracker(name = "test", idleTimeout = 50.milliseconds) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, streamer)) + tracker.onRelaySettled(good) + // `streamer` has NOT been heard from yet (still connecting). The idle gate must hold the window + // open — a connection gap is not a quiet stream. Wait well past idleTimeout AND a watchdog tick. + Thread.sleep(800) + assertTrue("idle must not fire while a pending relay has never been heard from", tracker.loading.value) + + // Once it delivers something (now heard-from) and the stream goes quiet, idle completes it. + tracker.onRelayEvent(streamer) + withTimeout(3000) { tracker.loading.first { !it } } + scope.cancel() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifterTest.kt new file mode 100644 index 0000000000..318f7690b5 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifterTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.PI +import kotlin.math.sin + +class PitchShifterTest { + private val sampleRate = 44100 + + /** One second of a clean mono sine at [freq] Hz, amplitude 0.8. */ + private fun sine( + freq: Double, + seconds: Double = 1.0, + ): FloatArray { + val n = (sampleRate * seconds).toInt() + return FloatArray(n) { i -> (0.8 * sin(2.0 * PI * freq * i / sampleRate)).toFloat() } + } + + /** + * Estimates the fundamental frequency of a (near-)periodic signal via + * autocorrelation over a stable middle window. Dependency-free and robust + * for sine inputs. + */ + private fun estimateFrequency(signal: FloatArray): Double { + // Use the central 60% to avoid edge ramp-up/ramp-down artifacts. + val start = (signal.size * 0.2).toInt() + val end = (signal.size * 0.8).toInt() + val chunk = signal.copyOfRange(start, end) + + val minFreq = 50.0 + val maxFreq = 2000.0 + val minLag = (sampleRate / maxFreq).toInt() + val maxLag = (sampleRate / minFreq).toInt().coerceAtMost(chunk.size - 1) + + var bestLag = minLag + var bestCorr = Double.NEGATIVE_INFINITY + for (lag in minLag..maxLag) { + var corr = 0.0 + for (i in 0 until chunk.size - lag) { + corr += chunk[i] * chunk[i + lag] + } + if (corr > bestCorr) { + bestCorr = corr + bestLag = lag + } + } + return sampleRate.toDouble() / bestLag + } + + @Test + fun `ratio of one preserves length`() { + val input = sine(440.0) + val output = PitchShifter().shift(input, 1.0) + // Duration must be preserved (within a frame of tolerance). + assertEquals(input.size.toDouble(), output.size.toDouble(), 4096.0) + } + + @Test + fun `shift up one octave roughly doubles the fundamental`() { + val output = PitchShifter().shift(sine(440.0), 2.0) + val measured = estimateFrequency(output) + assertEquals(880.0, measured, 880.0 * 0.06) + } + + @Test + fun `shift down one octave roughly halves the fundamental`() { + val output = PitchShifter().shift(sine(440.0), 0.5) + val measured = estimateFrequency(output) + assertEquals(220.0, measured, 220.0 * 0.06) + } + + @Test + fun `preserves duration for the deep preset ratio`() { + // DEEP preset lowers pitch: frequencyRatio = 1 / 1.4. + val input = sine(300.0) + val output = PitchShifter().shift(input, 1.0 / 1.4) + assertEquals(input.size.toDouble(), output.size.toDouble(), 4096.0) + val measured = estimateFrequency(output) + assertEquals(300.0 / 1.4, measured, (300.0 / 1.4) * 0.06) + } + + @Test + fun `preserves duration for the high preset ratio`() { + // HIGH preset raises pitch: frequencyRatio = 1 / 0.75. + val input = sine(300.0) + val output = PitchShifter().shift(input, 1.0 / 0.75) + assertEquals(input.size.toDouble(), output.size.toDouble(), 4096.0) + val measured = estimateFrequency(output) + assertEquals(300.0 / 0.75, measured, (300.0 / 0.75) * 0.06) + } + + @Test + fun `empty input returns empty output`() { + val output = PitchShifter().shift(FloatArray(0), 1.5) + assertTrue(output.isEmpty()) + } +} diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 93295b50de..9296e49d5b 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -33,6 +33,11 @@ dependencies { application { mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") applicationName = "amy" + // amy is a non-interactive CLI — never spawn AWT GUI threads. Defensive + // against transitive deps that touch ImageIO / Toolkit during image + // upload (see commons/.../service/upload/ImageReencoder.kt). Belt-and- + // braces with the runtime System.setProperty in Main.kt. + applicationDefaultJvmArgs = listOf("-Djava.awt.headless=true") } // Inject `LANG=C.UTF-8` (and the matching Windows code page) into the @@ -223,7 +228,7 @@ val amyImage = #!/bin/sh # amy launcher — uses the bundled jlink'd JRE so no system Java is required. DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)" - exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" + exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" """.trimIndent() + "\n" doLast { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 6d9c0d8fec..1ea7b3d0e3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -48,6 +48,13 @@ import kotlin.system.exitProcess * shape is not. Diagnostic logs always go to stderr. */ fun main(argv: Array) { + // Force AWT headless before any class load that might touch ImageIO, + // Toolkit, or Graphics2D (image upload pulls in BufferedImage via + // commons MediaMetadataReader / ImageReencoder). The Gradle launcher + // also sets this via applicationDefaultJvmArgs; this is a belt-and- + // braces guard for invocations that bypass the launcher scripts. + System.setProperty("java.awt.headless", "true") + // Set output mode before dispatch so even argument-parsing errors // honour --json. if (argv.any { it == "--json" || it == "--json=true" }) { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 942cbd65e4..0cec8f4877 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -1,5 +1,23 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable + +// Disables the Kotlin/Native compiler cache for an iOS test binary so the +// Compose ui-uikit klib recompiles fresh instead of linking the broken prebuilt +// cache (see the call site in the `kotlin {}` block). The version guard makes +// Kotlin re-surface this workaround once we move past 2.3.21, so it can be +// dropped when a newer Compose/Kotlin pairing fixes the cache. Wrapped in a +// helper because @OptIn only applies to declarations, not bare statements. +@OptIn(KotlinNativeCacheApi::class) +fun TestExecutable.disableUiKitPrebuiltCache() = + disableNativeCache( + DisableCacheInKotlinVersion.`2_3_21`, + "Compose ui-uikit prebuilt cache references UIViewLayoutRegion (iOS 17+); " + + "linking the iOS test binary fails under Xcode 16.4.", + ) plugins { alias(libs.plugins.kotlinMultiplatform) @@ -50,6 +68,22 @@ kotlin { iosArm64() iosSimulatorArm64() + // Compose Multiplatform 1.11.0 ships an `org.jetbrains.compose.ui:ui-uikit` + // prebuilt Kotlin/Native cache whose CMPLayoutRegion object hard-references + // the UIKit class `UIViewLayoutRegion` (introduced in iOS 17). Linking the + // iOS *test* executable against that cache under Xcode 16.4 fails with + // ld: Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion + // because the cached object was built for a newer simulator SDK (18.5) than + // the test binary is being linked for (14.0). Disabling the native cache for + // the iOS test binaries makes ui-uikit recompile against the active SDK, + // where the symbol resolves. See disableUiKitPrebuiltCache() below and + // https://kotl.in/disable-native-cache + targets.withType().configureEach { + binaries.withType().configureEach { + disableUiKitPrebuiltCache() + } + } + sourceSets { commonMain { dependencies { @@ -148,6 +182,10 @@ kotlin { // EXIF stripping for image uploads (used by service/upload/MediaCompressor). implementation(libs.commons.imaging) + + // Image re-encode + progressive downscale (used by service/upload/ImageReencoder). + // Pure-Java, MIT. See docs/plans/2026-06-08-feat-desktop-image-compression-plan.md. + implementation(libs.thumbnailator) } } @@ -180,6 +218,15 @@ kotlin { } } + // jvmTest needs the JVM secp256k1 bindings whenever a test + // exercises a real signer (e.g. UploadOrchestratorTest, which + // signs Blossom auth events end-to-end). + getByName("jvmTest") { + dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + getByName("androidDeviceTest") { dependencies { implementation(libs.androidx.junit) @@ -195,6 +242,15 @@ compose.resources { generateResClass = always } +// JVM tests run AWT-backed code (ImageIO, Thumbnailator, BufferedImage) — pin +// headless mode so a stray Toolkit.getDefaultToolkit() in a transitive dep +// never bounces the macOS Dock during CI/local test runs. +tasks.withType().configureEach { + if (name == "jvmTest") { + jvmArgs("-Djava.awt.headless=true") + } +} + // iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale // there. Commons gains this gate once FeedDefinitionSerializer.kt has been // migrated off Jackson; future commonMain code must not reintroduce JVM-only diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 4b47a1e7de..9924ada856 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -55,6 +55,28 @@ User avatar Navigate + + Loading: + Fully loaded: + (fully loaded) + History by relay + Will retry when you reopen this screen + Older %1$s messages + All caught up + Reached the start of your %1$s messages + %1$s · %2$s · loaded since %3$s + %1$s · %2$s + waiting on %1$s + Some relays didn\'t respond + %1$s unreachable · tap to see which + %1$s · history by relay + since %1$s + Dismiss + + %1$d relay + %1$d relays + + replying to diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt new file mode 100644 index 0000000000..86eb82822c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.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.commons.audio + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.log10 + +/** One frame of frequency-domain magnitudes, ordered low→high Hz and normalized 0f..1f. */ +class Spectrum( + val bins: FloatArray, +) + +/** + * Groups a linear-frequency magnitude array (index 0 = DC, last index = Nyquist) + * into [binCount] log-spaced buckets so bass/mid get more bars than the treble + * tail. Output is normalized 0f..1f using [floorDb] as the silence floor. + * Bin 0 (DC) is always excluded; the Nyquist bin (last) is included. + * Low bins are guaranteed to map to distinct FFT bins (contiguous, non-aliased). + */ +fun FloatArray.toLogBins( + binCount: Int, + floorDb: Float = -60f, +): FloatArray { + if (isEmpty() || binCount <= 0) return FloatArray(0) + val out = FloatArray(binCount) + val logSize = ln(size.toDouble()) + var lo = 1 // skip DC (index 0) + for (b in 0 until binCount) { + var hi = exp(logSize * (b + 1) / binCount).toInt() + if (hi <= lo) hi = lo + 1 // contiguous & distinct buckets: low bins map to distinct FFT bins, not all to bin 1 + if (hi > size) hi = size + var peak = 0f + var i = lo + while (i < hi) { + if (this[i] > peak) peak = this[i] + i++ + } + val db = if (peak > 0f) 20f * log10(peak) else floorDb + out[b] = ((db - floorDb) / -floorDb).coerceIn(0f, 1f) + lo = hi + } + return out +} + +/** + * Returns a NEW array scaled so the largest value becomes 1f. An all-zero input is returned as a zero-filled copy. + * Reference/allocating variant — production uses the *Into / in-place form on the audio thread. + */ +fun FloatArray.normalizedToPeak(): FloatArray { + var peak = 0f + for (v in this) if (v > peak) peak = v + if (peak <= 0f) return copyOf() + val inv = 1f / peak + return FloatArray(size) { this[it] * inv } +} + +/** + * In-place version of [normalizedToPeak]: scales this array so its largest value becomes 1f. + * [fromIndex] excludes leading entries from both the peak search and the scaling (e.g. pass 1 to + * ignore the DC bin of an FFT magnitude array, which downstream log-binning also skips). + */ +fun FloatArray.normalizeToPeakInPlace(fromIndex: Int = 0) { + var peak = 0f + for (i in fromIndex until size) if (this[i] > peak) peak = this[i] + if (peak <= 0f) return + val inv = 1f / peak + for (i in fromIndex until size) this[i] *= inv +} + +/** + * Holds back the latest [frames] spectra and emits the one from [frames] hops ago. The PCM tap + * computes each [Spectrum] inside the audio processor chain, upstream of the AudioTrack output + * buffer, so it runs ahead of what's audible by roughly that buffer's depth. Delaying the visual by + * the same number of decoded FFT hops (one [Spectrum] == one hop) realigns it with the speaker, in + * the same decoded-sample units as the lead, so it stays correct across startup and rate jitter. + * [frames] <= 0 is identity (e.g. previews, which carry no output latency). + */ +fun Flow.delayedByFrames(frames: Int): Flow = + if (frames <= 0) { + this + } else { + flow { + val queue = ArrayDeque(frames + 1) + collect { frame -> + queue.addLast(frame) + if (queue.size > frames) emit(queue.removeFirst()) + } + } + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt new file mode 100644 index 0000000000..dc09f2919e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioVisualizer.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.Flow + +/** + * Renders [spectrum] using whichever [style] the user selected. + * CLASSIC is not a spectrum renderer; passing it renders nothing (OffRenderer fallback). Callers handle CLASSIC separately. + */ +@Composable +fun AudioVisualizer( + style: VisualizerStyle, + spectrum: Flow, + modifier: Modifier = Modifier, + palette: VisualizerPalette = VisualizerPalette.DEFAULT, +) { + VisualizerRegistry.forStyle(style).Render(spectrum, palette, modifier) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindow.kt new file mode 100644 index 0000000000..e95966c25b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindow.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.commons.audio + +import kotlin.math.PI +import kotlin.math.cos + +object AudioWindow { + /** Hann window of length [n]. */ + fun hann(n: Int): FloatArray { + if (n <= 1) return FloatArray(n) { 1f } + return FloatArray(n) { (0.5 - 0.5 * cos(2.0 * PI * it / (n - 1))).toFloat() } + } + + /** + * Converts 16-bit PCM [samples] to normalized floats in [-1, 1], multiplies by + * [window], and pads/truncates to `window.size`. + */ + fun shortsToWindowed( + samples: ShortArray, + window: FloatArray, + ): FloatArray = + FloatArray(window.size) { i -> + if (i < samples.size) (samples[i] / 32768f) * window[i] else 0f + } + + /** Like [shortsToWindowed] but writes into [out] (size must equal [window].size); no allocation. */ + fun shortsToWindowedInto( + samples: ShortArray, + window: FloatArray, + out: FloatArray, + ) { + require(out.size == window.size) { "out buffer must match window size" } + for (i in window.indices) { + out[i] = if (i < samples.size) (samples[i] / 32768f) * window[i] else 0f + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/Fft.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/Fft.kt new file mode 100644 index 0000000000..9bcc58d348 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/Fft.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.audio + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Minimal in-place iterative radix-2 Cooley–Tukey FFT (pure Kotlin, no JNI). + * Adapted from Nayuki's public-domain "Free small FFT". Only power-of-2 sizes + * are supported, which is all an audio visualiser needs (256/512/1024). + */ +object Fft { + /** In-place forward transform. [re]/[im] must be the same power-of-2 length. */ + fun transform( + re: DoubleArray, + im: DoubleArray, + ) { + val n = re.size + require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" } + + var j = 0 + for (i in 1 until n) { + var bit = n shr 1 + while (j and bit != 0) { + j = j xor bit + bit = bit shr 1 + } + j = j or bit + if (i < j) { + val tr = re[i] + re[i] = re[j] + re[j] = tr + val ti = im[i] + im[i] = im[j] + im[j] = ti + } + } + + var len = 2 + while (len <= n) { + val ang = -2.0 * PI / len + val wLenRe = cos(ang) + val wLenIm = sin(ang) + var i = 0 + while (i < n) { + var wRe = 1.0 + var wIm = 0.0 + val half = len / 2 + for (k in 0 until half) { + val aRe = re[i + k] + val aIm = im[i + k] + val bRe = re[i + k + half] * wRe - im[i + k + half] * wIm + val bIm = re[i + k + half] * wIm + im[i + k + half] * wRe + re[i + k] = aRe + bRe + im[i + k] = aIm + bIm + re[i + k + half] = aRe - bRe + im[i + k + half] = aIm - bIm + val nextWRe = wRe * wLenRe - wIm * wLenIm + wIm = wRe * wLenIm + wIm * wLenRe + wRe = nextWRe + } + i += len + } + len = len shl 1 + } + } + + /** + * Forward-transforms real [signal] and returns magnitudes for bins 0..N/2 (DC..Nyquist). + * Reference/allocating variant — production uses the *Into / in-place form on the audio thread. + */ + fun magnitudes(signal: FloatArray): FloatArray { + val out = FloatArray(signal.size / 2 + 1) + magnitudesInto(signal, DoubleArray(signal.size), DoubleArray(signal.size), out) + return out + } + + /** + * Like [magnitudes] but writes into caller-owned buffers (no allocation). [re] and [im] must be + * the same length as [signal] (a power of 2); [out] must be `signal.size / 2 + 1`. Used on the + * audio thread to avoid per-frame garbage. + */ + fun magnitudesInto( + signal: FloatArray, + re: DoubleArray, + im: DoubleArray, + out: FloatArray, + ) { + val n = signal.size + require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" } + require(re.size == n && im.size == n) { "re/im buffers must be size $n" } + require(out.size == n / 2 + 1) { "out buffer must be size ${n / 2 + 1}" } + for (i in 0 until n) { + re[i] = signal[i].toDouble() + im[i] = 0.0 + } + transform(re, im) + for (i in out.indices) out[i] = sqrt(re[i] * re[i] + im[i] * im[i]).toFloat() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.kt new file mode 100644 index 0000000000..a3c0b51eca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumCanvas.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.commons.audio + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.DrawScope +import kotlinx.coroutines.flow.Flow + +/** + * Collects [spectrum] into a decayed [FloatArray] and optionally runs a monotonic time clock, + * then calls [draw] inside the Canvas draw lambda. The fast-changing state is read + * ONLY in the draw lambda, so new frames trigger the draw phase, never recomposition. + * Pass [animated] = false for non-time-varying styles (bars, radial) to avoid 60fps redraws. + */ +@Composable +fun SpectrumCanvas( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + decay: Float = 0.85f, + animated: Boolean = true, + draw: DrawScope.(bins: FloatArray, timeSec: Float, palette: VisualizerPalette) -> Unit, +) { + val smoothed = remember { mutableStateOf(FloatArray(0)) } + LaunchedEffect(spectrum, decay) { + var prev = FloatArray(0) + spectrum.collect { frame -> + // A fresh array each frame is intentional: mutableStateOf compares by reference, so a new + // instance is what signals Compose to redraw. Do NOT switch to in-place mutation. + val next = + FloatArray(frame.bins.size) { i -> + val prior = if (i < prev.size) prev[i] * decay else 0f + if (frame.bins[i] > prior) frame.bins[i] else prior + } + smoothed.value = next + prev = next + } + } + + val timeSec = remember { mutableStateOf(0f) } + if (animated) { + LaunchedEffect(Unit) { + var last = 0L + while (true) { + withFrameMillis { ms -> + if (last != 0L) timeSec.value += (ms - last) / 1000f + last = ms + } + } + } + } + + Canvas(modifier) { + draw(smoothed.value, if (animated) timeSec.value else 0f, palette) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrum.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrum.kt new file mode 100644 index 0000000000..31833b4216 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrum.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.audio + +import androidx.compose.runtime.withFrameMillis +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.math.PI +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sin + +/** Deterministic decorative spectrum for previews and demos (no real audio). */ +object SyntheticSpectrum { + fun frame( + timeSec: Float, + binCount: Int, + ): Spectrum { + val t = timeSec + val bins = + FloatArray(binCount) { i -> + val f = i / binCount.toFloat() + val beat = (0.5 + 0.5 * sin(t * 2.0 * PI * 1.9)).pow(6.0) + val bass = exp((-f * 9.0)) * (0.55 + 0.9 * beat) + val mid = exp(-((f - 0.35) * 4.0).pow(2.0)) * (0.35 + 0.25 * sin(t * 5.0 + i)) + val treble = exp(-((f - 0.8) * 3.5).pow(2.0)) * max(0.0, sin(t * 14.0 + i * 2.3)) * 0.4 + val v = bass + mid + treble + 0.04 * sin(t * 3.0 + i * 0.7) + v.coerceIn(0.0, 1.0).toFloat() + } + return Spectrum(bins) + } + + /** + * Compose-friendly stream that emits one [frame] per display frame. + * + * Must be collected inside a Compose frame-clock scope (e.g. a `LaunchedEffect`); it uses + * `withFrameMillis`, which throws if collected from a plain coroutine with no frame clock. + */ + fun flow(binCount: Int = 48): Flow = + flow { + var startMs = -1L + while (true) { + val ms = withFrameMillis { it } + if (startMs < 0) startMs = ms + emit(frame((ms - startMs) / 1000f, binCount)) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerPalette.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerPalette.kt new file mode 100644 index 0000000000..c633ba7bff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerPalette.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.commons.audio + +import androidx.compose.runtime.Immutable + +/** + * Hue anchors (degrees, 0..360) the renderers use to colour the spectrum. Defaults + * give the cyan→magenta / bass-purple / treble-pink look from the design mockups. + */ +@Immutable +data class VisualizerPalette( + val lowHue: Float = 280f, + val midHue: Float = 200f, + val highHue: Float = 330f, + val saturation: Float = 0.92f, + val lightness: Float = 0.6f, +) { + init { + require(saturation in 0f..1f) { "saturation must be in 0f..1f, was $saturation" } + require(lightness in 0f..1f) { "lightness must be in 0f..1f, was $lightness" } + } + + companion object { + val DEFAULT = VisualizerPalette() + } +} + +/** Wraps a hue into 0f..360f (handles negatives), for Color.hsl. */ +fun Float.wrapHue(): Float = ((this % 360f) + 360f) % 360f diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt new file mode 100644 index 0000000000..dd8c454cb9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistry.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio + +import com.vitorpamplona.amethyst.commons.audio.renderers.AuroraRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.BarsRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.OffRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.RadialRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.StaticRenderer +import com.vitorpamplona.amethyst.commons.audio.renderers.WavesRenderer + +/** + * CLASSIC is intentionally absent — it requires the live player and is handled by the caller; + * forStyle(CLASSIC) falls back to OffRenderer. + */ +object VisualizerRegistry { + val all: List = + listOf(OffRenderer, BarsRenderer, WavesRenderer, RadialRenderer, AuroraRenderer, StaticRenderer) + + private val byStyle = all.associateBy { it.style } + + fun forStyle(style: VisualizerStyle): VisualizerRenderer = byStyle[style] ?: OffRenderer +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt new file mode 100644 index 0000000000..f1493271ba --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRenderer.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.Flow + +/** A single visualiser style. Implementations draw [spectrum] frames onto a Canvas. */ +interface VisualizerRenderer { + val style: VisualizerStyle + + @Composable + fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt new file mode 100644 index 0000000000..dd6b5af441 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyle.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.audio + +enum class VisualizerStyle { + CLASSIC, + BARS, + WAVES, + RADIAL, + AURORA, + STATIC, + OFF, + ; + + companion object { + val DEFAULT = CLASSIC + + fun fromName(name: String?): VisualizerStyle = entries.firstOrNull { it.name == name } ?: DEFAULT + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt new file mode 100644 index 0000000000..3b7fb241db --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/AuroraRenderer.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.audio.wrapHue +import kotlinx.coroutines.flow.Flow +import kotlin.math.sin + +/** + * Three overlapping glowing ribbons. Each ribbon is a stroked line whose vertical displacement from + * its centre is the live spectrum of its band (interpolated across the width), so the ribbon's shape + * tracks the audio; the glow thickness swells with the overall energy. A small time shimmer (±8%) + * adds life without driving the motion. The line is clamped inside the canvas (accounting for the + * stroke width) so the tops/bottoms never get cut. + */ +object AuroraRenderer : VisualizerRenderer { + override val style = VisualizerStyle.AURORA + + private class Ribbon( + val hue: (VisualizerPalette) -> Float, + val yo: Float, + val lo: Float, + val hi: Float, + val dir: Float, + ) + + private val ribbons = + listOf( + Ribbon({ it.midHue - 40f }, 0.55f, 0f, 0.5f, -1f), + Ribbon({ it.lowHue }, 0.5f, 0.15f, 0.85f, 1f), + Ribbon({ it.highHue }, 0.45f, 0.5f, 1f, -1f), + ) + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + val paths = remember { List(ribbons.size) { Path() } } + // Each ribbon's gradient depends only on the palette (the animation lives in the Path + // geometry), so build the brushes once instead of allocating three Colors + a Brush per + // ribbon on every frame. + val brushes = + remember(palette) { + ribbons.map { ribbon -> + val hue = ribbon.hue(palette).wrapHue() + Brush.horizontalGradient( + 0f to Color.hsl(hue, palette.saturation, palette.lightness, 0f), + 0.5f to Color.hsl(hue, palette.saturation, palette.lightness, 0.6f), + 1f to Color.hsl(hue, palette.saturation, palette.lightness, 0f), + ) + } + } + SpectrumCanvas(spectrum, palette, modifier) { bins, t, _ -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val w = size.width + val h = size.height + + // overall energy → glow thickness swells with loudness (bounded) + var energy = 0f + for (b in bins) energy += b + energy /= bins.size + val strokeW = 12f + energy * 30f + val half = strokeW / 2f + + ribbons.forEachIndexed { index, r -> + val path = paths[index] + path.reset() + var x = 0f + while (x <= w) { + val f = x / w + // interpolate this ribbon's band → the displacement IS the spectrum + val fb = (r.lo + (r.hi - r.lo) * f) * (n - 1) + val i0 = fb.toInt().coerceIn(0, n - 1) + val i1 = (i0 + 1).coerceAtMost(n - 1) + val v = bins[i0] + (bins[i1] - bins[i0]) * (fb - i0) + val shimmer = 1f + 0.08f * sin(f * 9f + t) + val disp = (v * shimmer).coerceIn(0f, 1f) * h * 0.40f + // maxOf guards the reversed-range crash when the canvas is shorter than the + // loudness-swelled stroke (h < strokeW makes half > h - half). + val y = (h * r.yo + r.dir * disp).coerceIn(half, maxOf(half, h - half)) + if (x == 0f) path.moveTo(x, y) else path.lineTo(x, y) + x += 5f + } + drawPath( + path = path, + brush = brushes[index], + style = Stroke(width = strokeW, cap = StrokeCap.Round), + blendMode = BlendMode.Plus, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.kt new file mode 100644 index 0000000000..63471780d9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/BarsRenderer.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.amethyst.commons.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.audio.wrapHue +import kotlinx.coroutines.flow.Flow +import kotlin.math.min + +object BarsRenderer : VisualizerRenderer { + override val style = VisualizerStyle.BARS + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + SpectrumCanvas(spectrum, palette, modifier, animated = false) { bins, _, pal -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val gap = 2f + val barW = (size.width - gap * (n - 1)) / n + if (barW <= 0f) return@SpectrumCanvas + val mid = size.height / 2f + val radius = CornerRadius(min(barW / 2f, 3f), min(barW / 2f, 3f)) + for (i in 0 until n) { + val v = bins[i].coerceIn(0f, 1f) + val half = v * (size.height / 2f - 2f) + if (half <= 0f) continue + val hue = pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat()) + drawRoundRect( + color = Color.hsl(hue.wrapHue(), pal.saturation, pal.lightness), + topLeft = Offset(i * (barW + gap), mid - half), + size = Size(barW, half * 2f), + cornerRadius = radius, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.kt new file mode 100644 index 0000000000..5587fe06c8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/OffRenderer.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.commons.audio.renderers + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import kotlinx.coroutines.flow.Flow + +object OffRenderer : VisualizerRenderer { + override val style = VisualizerStyle.OFF + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + // Draws nothing, but honors the modifier so the reserved layout region doesn't collapse. + Box(modifier) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.kt new file mode 100644 index 0000000000..95084b3db7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/RadialRenderer.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.commons.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.audio.wrapHue +import kotlinx.coroutines.flow.Flow +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +object RadialRenderer : VisualizerRenderer { + override val style = VisualizerStyle.RADIAL + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + SpectrumCanvas(spectrum, palette, modifier, animated = false) { bins, _, pal -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val cx = size.width / 2f + val cy = size.height / 2f + val minDim = min(size.width, size.height) + // A zero/near-zero canvas (transient layout pass while audio plays) would make coreR == 0, + // and Brush.radialGradient(radius = 0f) throws. Nothing meaningful to draw at that size. + if (minDim <= 0f) return@SpectrumCanvas + val r0 = minDim * 0.16f + + var energy = 0f + val lowCount = min(8, n) + for (i in 0 until lowCount) energy += bins[i] + energy /= lowCount + + val coreR = r0 * (1.4f + energy) + drawCircle( + brush = + Brush.radialGradient( + 0f to Color.hsl(pal.highHue, pal.saturation, 0.7f, 0.9f), + 1f to Color.hsl(pal.highHue, pal.saturation, 0.6f, 0f), + center = Offset(cx, cy), + radius = coreR, + ), + radius = coreR, + center = Offset(cx, cy), + ) + + for (i in 0 until n) { + val a = i / n.toFloat() * (2f * PI.toFloat()) - PI.toFloat() / 2f + val len = r0 + bins[i].coerceIn(0f, 1f) * minDim * 0.32f + val hue = (pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat())).wrapHue() + drawLine( + color = Color.hsl(hue, pal.saturation, pal.lightness), + start = Offset(cx + cos(a) * r0, cy + sin(a) * r0), + end = Offset(cx + cos(a) * len, cy + sin(a) * len), + strokeWidth = 2.4f, + cap = StrokeCap.Round, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/StaticRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/StaticRenderer.kt new file mode 100644 index 0000000000..be22fd7de6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/StaticRenderer.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SyntheticSpectrum +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * A still, non-animated bar graphic for users who dislike motion. Ignores the live [spectrum] + * and renders a single frozen frame via [BarsRenderer] (which runs its canvas with animated=false, + * so there is no per-frame redraw once settled). + */ +object StaticRenderer : VisualizerRenderer { + override val style = VisualizerStyle.STATIC + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + val frozen = remember { flowOf(SyntheticSpectrum.frame(0f, 48)) } + BarsRenderer.Render(frozen, palette, modifier) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt new file mode 100644 index 0000000000..bec0f12924 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/renderers/WavesRenderer.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.audio.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas +import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette +import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.audio.wrapHue +import kotlinx.coroutines.flow.Flow +import kotlin.math.sin + +/** + * Three translucent, overlapping filled waves. Each wave's OUTLINE is the live spectrum of its + * frequency band (low / mid / high), interpolated across the width, so the shape tracks the audio + * directly. A small time shimmer (±6%) adds life without driving the motion, and the height is + * clamped to 0.92·h so the fill never reaches or leaves the top edge. + */ +object WavesRenderer : VisualizerRenderer { + override val style = VisualizerStyle.WAVES + + private class Layer( + val lo: Float, + val hi: Float, + val hue: (VisualizerPalette) -> Float, + val amp: Float, + val phase: Float, + ) + + private val layers = + listOf( + Layer(0f, 0.4f, { it.lowHue }, 1.0f, 0f), + Layer(0.2f, 0.75f, { it.midHue }, 0.9f, 1.5f), + Layer(0.5f, 1f, { it.highHue }, 0.8f, 3f), + ) + + @Composable + override fun Render( + spectrum: Flow, + palette: VisualizerPalette, + modifier: Modifier, + ) { + val paths = remember { List(layers.size) { Path() } } + // Each layer's gradient depends only on the palette (the animation lives in the Path + // geometry), so build the brushes once instead of allocating two Colors + a Brush per layer + // on every frame. + val brushes = + remember(palette) { + layers.map { layer -> + val hue = layer.hue(palette).wrapHue() + Brush.verticalGradient( + 0f to Color.hsl(hue, palette.saturation, palette.lightness, 0.85f), + 1f to Color.hsl(hue, palette.saturation, palette.lightness * 0.8f, 0.05f), + ) + } + } + SpectrumCanvas(spectrum, palette, modifier) { bins, t, _ -> + if (bins.isEmpty()) return@SpectrumCanvas + val n = bins.size + val w = size.width + val h = size.height + layers.forEachIndexed { index, layer -> + val path = paths[index] + path.reset() + path.moveTo(0f, h) + var x = 0f + while (x <= w) { + val f = x / w + // interpolate the spectrum within this layer's band → smooth, audio-driven outline + val fb = (layer.lo + (layer.hi - layer.lo) * f) * (n - 1) + val i0 = fb.toInt().coerceIn(0, n - 1) + val i1 = (i0 + 1).coerceAtMost(n - 1) + val v = (bins[i0] + (bins[i1] - bins[i0]) * (fb - i0)) * layer.amp + val shimmer = 1f + 0.06f * sin(f * 12f + layer.phase + t * 1.5f) + val height = (v * shimmer).coerceIn(0f, 1f) * h * 0.92f + path.lineTo(x, h - height) + x += 4f + } + path.lineTo(w, h) + path.close() + drawPath( + path = path, + brush = brushes[index], + blendMode = BlendMode.Plus, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b4a0634e1f..12264f99e5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote import com.vitorpamplona.quartz.utils.Log @@ -178,6 +180,11 @@ class MarmotManager( * `persistOwn = false`. Headless callers (CLI) should leave it at * the default. * + * [mentions] become p-tags on the inner kind:9 (users referenced via + * `nostr:npub…`/`nostr:nprofile…` in [text]), mirroring how NIP-17 + * chat messages tag mentioned users. They stay inside the MLS + * ciphertext — the outer kind:445 never carries member pubkeys. + * * @return the signed kind:445 outer event together with the inner kind:9 * rumor id, so the caller can reference it for replies/reactions. */ @@ -187,10 +194,12 @@ class MarmotManager( replyToEventId: HexKey? = null, replyToAuthorPubKey: HexKey? = null, persistOwn: Boolean = true, + mentions: List = emptyList(), ): TextMessageBundle { val template = com.vitorpamplona.quartz.nip01Core.signers .eventTemplate(kind = 9, description = text) { + pTags(mentions) if (replyToEventId != null) { // Mirror ChatEvent.reply(): NIP-18 q-tag references the // parent inner kind:9 by id (+ optional author, no diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 43678ca38a..5c9f6cc7b3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -30,9 +30,10 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -46,6 +47,12 @@ class Chatroom : NotesGatherer { var ownerSentMessage: Boolean = false var newestMessage: Note? = null + // Per-conversation NIP-04 history paging cursors, held here so reopening this room keeps its + // progress and the cursors share the lifetime of the cached messages. The conversation history + // loader binds its (single-active) orchestrator to this. Lazy — most rooms in the rooms list are + // never opened for history paging, so they never allocate it. + val nip04History by lazy { RelayLoadingCursors() } + // Per-instance lock shared by previously @Synchronized methods. private val syncLock = KmpLock() @@ -132,13 +139,17 @@ class Chatroom : NotesGatherer { val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = - if ((sorted.firstOrNull()?.createdAt() ?: 0L) > TimeUtils.oneWeekAgo()) { - // Recent messages, keep last 100 - sorted.take(100).toSet() + if ((sorted.firstOrNull()?.createdAt() ?: 0L) > DmHistoryTuning.recentBoundary()) { + // Recent conversation, keep its newest N + sorted.take(DmHistoryTuning.recentKeepCount).toSet() } else { - // Old messages, keep the last one. + // Old conversation, keep the last one. sorted.take(1).toSet() - } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent } + } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is WrappedEvent } + // Both DM protocols are pruned by the recency rule above: NIP-04 (PrivateDmEvent) and NIP-17 + // (WrappedEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a + // room is kept. The caller realigns the per-relay download window for the dropped messages so + // they can be paged again later (see LocalCache.pruneOldMessages + RelayLoadingCursors.rewindTo). val toRemove = messages.minus(toKeep) messages = toKeep diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt index 3e2d25893e..ccb294281c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.privateChats import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.utils.cache.LargeCache @@ -34,6 +35,13 @@ class ChatroomList( var rooms = LargeCache() private set + // Account-level DM history paging cursors (one scope per account), held here so they share the + // lifetime of the cached messages and are dropped when the cache prunes them. The account-level + // history loaders bind their orchestrator to these. (Per-conversation NIP-04 cursors live on the + // individual [Chatroom] instead.) + val giftWrapHistory = RelayLoadingCursors() + val nip04History = RelayLoadingCursors() + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() } fun getOrCreatePrivateChatroom(user: User): Chatroom { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt new file mode 100644 index 0000000000..b2c1dae11c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.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.commons.model.privateChats + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.concurrent.Volatile + +/** + * One shared boundary for the DM history window, so the three things that must agree on "where the + * live tail ends and paged history begins" actually read the same number: + * - the live-tail subscriptions' `since` floor (e.g. `AccountGiftWrapsEoseManager`), + * - the backward history pager's pinned floor (`BackwardRelayPager`), + * - the memory prune's recent/old split + retention cap (`Chatroom.pruneMessagesToTheLatestOnly`). + * + * Keeping them in one place avoids the live tail and history overlapping (double-loading) or leaving a + * gap when the boundary is tuned. The knobs are plain `@Volatile` vars (overridable once at startup — + * e.g. shrinking the window in a test to exercise pruning + the per-relay download-window realignment + * without needing week-old threads); they are not meant to change mid-session. + */ +object DmHistoryTuning { + /** Seconds below "now" where the live tail ends and paged history begins. Production: one week. */ + @Volatile + var liveTailSeconds: Long = 7L * TimeUtils.ONE_DAY + + /** How many newest messages a still-recent conversation keeps on a prune. Production: 100. */ + @Volatile + var recentKeepCount: Int = 100 + + /** The epoch-seconds boundary `now − [liveTailSeconds]` (recomputed each call against the clock). */ + fun recentBoundary(): Long = TimeUtils.now() - liveTailSeconds +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt index 191d4e82e6..9502c087ad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt @@ -32,6 +32,8 @@ fun filterGiftWrapsToPubkey( relay: NormalizedRelayUrl, pubkey: HexKey?, since: Long?, + until: Long? = null, + limit: Int? = null, ): List { if (pubkey.isNullOrEmpty()) return emptyList() @@ -42,7 +44,13 @@ fun filterGiftWrapsToPubkey( Filter( kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), + // A gift wrap's outer created_at is randomized up to 2 days before the real + // message time, so widen the lower bound by 2 days to catch wraps for messages + // right at the floor. (The upper bound needs no margin: a slice's `until` is the + // previous slice's un-margined floor, so the 2-day overlap already covers the seam.) since = since?.minus(TimeUtils.twoDays()), + until = until, + limit = limit, ), ), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt new file mode 100644 index 0000000000..25d5af671a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.richtext + +/** + * Splits a line into the space-delimited words consumed by [RichTextParser], + * keeping LaTeX math spans (`$...$` inline, `$$...$$` display) whole even though + * they may contain spaces. + * + * This is a drop-in replacement for `line.split(' ')`: for a line with no math + * it returns exactly the same words (including the empty strings that + * consecutive/leading/trailing spaces produce, which [RichTextParser] relies on + * to preserve double-spaces). A math span simply comes back as a single + * [Token.Math] word instead of being torn apart at its internal spaces. + * + * Delimiter rules follow the common "pandoc/remark-math dollar" convention so + * that ordinary prose with currency (`$5 and $10`) doesn't false-fire: + * - An opening `$` must be immediately followed by a non-whitespace char. + * - A closing `$` must be immediately preceded by a non-whitespace char and + * must not be immediately followed by a digit. + * - A `$` escaped with a backslash (`\$`) is a literal dollar, never a delimiter. + * - `$$...$$` (display) is matched before `$...$` (inline). + * + * Math glued to alphanumeric text without a separating space (e.g. `a$x$b`) + * stays a single plain word and renders literally. A span glued only to leading + * opening punctuation — a paren, bracket, or quote, as in `($x$)` — is still + * detected: the punctuation rides along as the equation's leading/trailing extras. + */ +object MathParser { + sealed interface Token { + /** A space-delimited word; may be empty for consecutive spaces. */ + data class Word( + val text: String, + ) : Token + + /** + * A math span. [raw] includes the `$` delimiters; [latex] is the inner + * formula. [leading] holds any opening punctuation glued right before the + * opening `$` (e.g. the `(` in `($x$)`) and [trailing] any punctuation + * glued right after the closing `$` (e.g. the `.` in `$x$.`), so both + * render next to the equation instead of drifting off behind a space — + * same idea as [HashTagSegment]'s extras. + */ + data class Math( + val raw: String, + val latex: String, + val displayMode: Boolean, + val leading: String = "", + val trailing: String = "", + ) : Token + } + + /** Cheap gate: a line can only contain math if it has at least two `$`. */ + fun mightContainMath(line: String): Boolean { + val first = line.indexOf('$') + return first >= 0 && line.indexOf('$', first + 1) >= 0 + } + + /** + * Collapses doubled backslashes (`\\cmd` → `\cmd`). + * + * Some sources over-escape their LaTeX (every `\` written as `\\`). In inline + * math a `\\` is a TeX forced line break that is never intended, so `\\ldots` + * really means `\ldots`. Left as-is, a renderer like JLaTeXMath reads `\\` as + * a line break — splitting the equation across two lines — and the trailing + * command name (`ldots`, `cdots`) as literal letters, so the symbol is lost. + * + * No-op when the input isn't over-escaped. Only meant for inline math; display + * math (`$$…$$`) may use `\\` as a genuine line break in aligned/matrix forms. + */ + fun collapseDoubledBackslashes(latex: String): String = if (latex.contains("\\\\")) latex.replace("\\\\", "\\") else latex + + /** + * Splits [line] on spaces into [Token.Word]s, with any whitespace-delimited + * math span surfaced as a [Token.Math]. + */ + fun split(line: String): List = splitKeepingMathWhole(line).map(::toToken) + + /** + * Turns one space-delimited [cell] into a token. A `$…$` span at the start of + * the cell — or behind a run of opening punctuation (`(`, `[`, `"`, …) — + * becomes math, with that punctuation kept as [Token.Math.leading] and any + * remainder (e.g. a closing `)` or `.`) as [Token.Math.trailing]. Anything + * else, including alphanumeric-glued math (`a$x$`), stays a plain word. + */ + private fun toToken(cell: String): Token { + val dollar = mathStartIn(cell) ?: return Token.Word(cell) + val math = matchMathAt(cell, dollar) ?: return Token.Word(cell) + return math.copy( + leading = cell.substring(0, dollar), + trailing = cell.substring(dollar + math.raw.length), + ) + } + + /** + * The index of a `$` that may open math in [cell]: index 0, or the first `$` + * when every character before it is opening punctuation. Null otherwise. + */ + private fun mathStartIn(cell: String): Int? { + if (cell.isEmpty() || cell[0] == '$') return 0 + var i = 0 + while (i < cell.length && isOpeningPunctuation(cell[i])) i++ + return if (i in 1 until cell.length && cell[i] == '$') i else null + } + + /** + * Punctuation that commonly opens a wrapped equation in prose. Covers every + * Unicode opening bracket and initial quote (so CJK/fullwidth `「『(` work for + * a global audience) plus the ASCII straight quotes, which Unicode classes as + * generic — not initial — punctuation and so must be named explicitly. + */ + private fun isOpeningPunctuation(c: Char): Boolean = + c == '"' || + c == '\'' || + c.category == CharCategory.START_PUNCTUATION || + c.category == CharCategory.INITIAL_QUOTE_PUNCTUATION + + /** + * Splits [line] on single spaces the way `line.split(' ')` would, except that + * spaces *inside* a math span don't act as delimiters. + */ + private fun splitKeepingMathWhole(line: String): List { + if (!mightContainMath(line)) return line.split(' ') + + val cells = ArrayList() + val current = StringBuilder() + val len = line.length + var i = 0 + while (i < len) { + val c = line[i] + when { + c == ' ' -> { + cells.add(current.toString()) + current.clear() + i++ + } + c == '$' -> { + val span = matchMathAt(line, i)?.raw + if (span != null) { + current.append(span) + i += span.length + } else { + current.append(c) + i++ + } + } + else -> { + current.append(c) + i++ + } + } + } + cells.add(current.toString()) + return cells + } + + /** Matches a `$$...$$` or `$...$` span starting at [start], or null. */ + private fun matchMathAt( + line: String, + start: Int, + ): Token.Math? { + if (start >= line.length || line[start] != '$' || isEscaped(line, start)) return null + return matchDisplay(line, start) ?: matchInline(line, start) + } + + /** A `$` is escaped when preceded by an odd number of backslashes. */ + private fun isEscaped( + line: String, + index: Int, + ): Boolean { + var backslashes = 0 + var j = index - 1 + while (j >= 0 && line[j] == '\\') { + backslashes++ + j-- + } + return backslashes % 2 == 1 + } + + /** Matches `$$...$$` starting at [start] (which points at the first `$`). */ + private fun matchDisplay( + line: String, + start: Int, + ): Token.Math? { + if (start + 1 >= line.length || line[start + 1] != '$') return null + val contentStart = start + 2 + var j = contentStart + while (j + 1 < line.length) { + if (line[j] == '$' && line[j + 1] == '$' && !isEscaped(line, j)) { + val latex = line.substring(contentStart, j) + if (latex.isBlank()) return null + return Token.Math(line.substring(start, j + 2), latex.trim(), displayMode = true) + } + j++ + } + return null + } + + /** Matches `$...$` starting at [start] (which points at the opening `$`). */ + private fun matchInline( + line: String, + start: Int, + ): Token.Math? { + val contentStart = start + 1 + // Opening `$` must be followed by a non-whitespace character. + if (contentStart >= line.length || line[contentStart].isWhitespace()) return null + + var j = contentStart + while (j < line.length) { + if (line[j] == '$' && !isEscaped(line, j)) { + // Closing `$` must be preceded by a non-whitespace char and + // not be immediately followed by a digit (avoids `$5 ... $10`). + val prev = line[j - 1] + val nextIsDigit = j + 1 < line.length && line[j + 1].isDigit() + if (!prev.isWhitespace() && !nextIsDigit) { + val latex = line.substring(contentStart, j) + if (latex.isBlank()) return null + return Token.Math(line.substring(start, j + 1), latex, displayMode = false) + } + // This `$` can't close — and since it isn't escaped it also + // can't appear inside inline math, so stop scanning. + return null + } + j++ + } + return null + } +} 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 b8f8736716..bfd18c5985 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 @@ -134,38 +134,66 @@ class RichTextParser { ): String { if (urlList.isEmpty()) return input - // Escape and join words: (word1|word2) - val wordsPattern = urlList.sortedByDescending { it.length }.joinToString("|") { Regex.escape(it) } + // Walk the text, and wherever one of the detected URLs sits glued to a + // non-space/non-newline neighbour, insert a single separating space so the + // word-by-word segmenter downstream can recognise it as a standalone URL. + // + // This used to be a `Regex("([^ \n])?($escapedWords)([^ \n])?")` replace, + // but Kotlin/Native's regex engine mishandles the optional capture groups + // `([^ \n])?` (it fails to backtrack them to zero width), corrupting every + // URL on iOS — e.g. "https://x" came back as "h https://x". A direct scan + // sidesteps the engine entirely and is platform-independent. + // + // This runs on the main thread per rendered note, so it stays linear in the + // text length: URLs are bucketed by their first character, and the inner + // match attempt only fires at positions whose character can actually start + // a URL — every other character costs a single map lookup. Within a bucket + // the URLs are kept longest-first so a URL that is a prefix of a longer one + // never shadows it. + val byFirstChar = HashMap>() + urlList + .asSequence() + .filter { it.isNotEmpty() } + .sortedByDescending { it.length } + .forEach { byFirstChar.getOrPut(it[0]) { ArrayList(1) }.add(it) } - // Regex breakdown: - // ([^ ])? -> Group 1: Optional character that is NOT a space or new line (Prefix) - // ($wordsPattern) -> Group 2: One of your target words - // ([^ ])? -> Group 3: Optional character that is NOT a space or new line (Suffix) - val regex = Regex("([^ \n])?($wordsPattern)([^ \n])?") - - return regex.replace(input) { match -> - val prefix = match.groups[1]?.value ?: "" - val word = match.groups[2]?.value ?: "" - val suffix = match.groups[3]?.value ?: "" - - val result = StringBuilder() - - // Add prefix + space if the prefix exists - if (prefix.isNotEmpty()) { - result.append(prefix) - result.append(" ") + val result = StringBuilder(input.length) + val length = input.length + var i = 0 + while (i < length) { + val candidates = byFirstChar[input[i]] + var match: String? = null + if (candidates != null) { + for (url in candidates) { + if (input.startsWith(url, i)) { + match = url + break + } + } } - result.append(word) + if (match != null) { + // Separate from a glued prefix character. + if (result.isNotEmpty()) { + val prev = result[result.length - 1] + if (prev != ' ' && prev != '\n') result.append(' ') + } - // Add space + suffix if the suffix exists - if (suffix.isNotEmpty()) { - result.append(" ") - result.append(suffix) + result.append(match) + i += match.length + + // Separate from a glued suffix character. + if (i < length) { + val next = input[i] + if (next != ' ' && next != '\n') result.append(' ') + } + } else { + result.append(input[i]) + i++ } - - result.toString() } + + return result.toString() } fun parseText( @@ -240,12 +268,15 @@ class RichTextParser { lines.forEach { paragraph -> val isRTL = isArabic(paragraph) - val wordList = paragraph.trimEnd().split(' ') - - val segments = ArrayList(wordList.size) - wordList.forEach { word -> - segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags)) - } + // split() behaves like `line.split(' ')`, but keeps math spans + // (`$...$`, `$$...$$`) whole instead of tearing them at internal spaces. + val segments = + MathParser.split(paragraph.trimEnd()).map { token -> + when (token) { + is MathParser.Token.Math -> MathSegment(token.raw, token.latex, token.displayMode, token.leading, token.trailing) + is MathParser.Token.Word -> wordIdentifier(token.text, images, videos, pdfs, urls, emojis, tags) + } + } paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index e03b8a6dd5..25d3eae75d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -169,3 +169,22 @@ class NowhereLinkSegment( class RegularTextSegment( segment: String, ) : Segment(segment) + +/** + * A LaTeX math span delimited by `$...$` (inline) or `$$...$$` (display). + * + * [segmentText] keeps the original text *including* the `$` delimiters so the + * raw form can be shown as a fallback when rendering fails, while [latex] holds + * just the inner formula that gets handed to the math renderer. [leading] is any + * opening punctuation glued before the opening `$` (e.g. `(` in `($x$)`) and + * [trailing] any punctuation glued after the closing `$` (e.g. `.` in `$x$.`), + * both rendered right next to the equation so they don't drift off behind a space. + */ +@Immutable +class MathSegment( + segment: String, + val latex: String, + val displayMode: Boolean, + val leading: String = "", + val trailing: String = "", +) : Segment(segment) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt new file mode 100644 index 0000000000..e32444f27c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt @@ -0,0 +1,434 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.ui.feeds + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.action_dismiss +import com.vitorpamplona.amethyst.commons.resources.chats_history_all_caught_up +import com.vitorpamplona.amethyst.commons.resources.chats_history_by_relay +import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete +import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete_sub +import com.vitorpamplona.amethyst.commons.resources.chats_history_older +import com.vitorpamplona.amethyst.commons.resources.chats_history_reached_start +import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_since +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays_title +import com.vitorpamplona.amethyst.commons.resources.chats_history_stalled_retry +import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle +import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle_no_date +import com.vitorpamplona.amethyst.commons.resources.chats_history_waiting +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource + +// How long the "All caught up" state lingers before the card collapses away. +private const val ALL_DONE_VISIBLE_MS = 2200L + +/** + * The "older history" status card for a per-relay [BackwardRelayPager]-backed feed, shown at one + * protocol's oldest-loaded boundary (rooms list and conversation). It tells the user exactly what the + * app is reaching for: which protocol, how many relays it is asking, and how far back it has paged. + * When that protocol runs dry it does NOT just vanish — it crossfades to an "All caught up" state, + * holds for a beat, then collapses away. When it stops short because relays stalled it says so and + * stays put (an *incomplete* window — messages may still be out there). + * + * Shared across front ends; pass the platform's locale date formatter as [formatReachDate] so the card + * carries no `java.time` / `NSDateFormatter` dependency. + * + * @param protocolName human label woven into sentences, e.g. "encrypted" / "legacy". + * @param protocolTag short technical tag for the subtitle, e.g. "NIP-17" / "NIP-04". + * @param reachedBack epoch seconds of the oldest point reached so far (the deepest `until` cursor). + * @param relayProgress per-relay reach (where each relay's window is, done/stalled). Tapping the card + * opens a popup listing them; pass empty to make the card non-interactive. + * @param formatReachDate formats an epoch-seconds reach point to a short label (e.g. "Jun 2026"). + */ +@Composable +fun DmHistoryLoadingCard( + protocolName: String, + protocolTag: String, + loading: Boolean, + exhausted: Boolean, + relayCount: Int, + stalledCount: Int, + reachedBack: Long?, + relayProgress: Map = emptyMap(), + formatReachDate: (epochSeconds: Long) -> String, + modifier: Modifier = Modifier, +) { + // Exhausted ("nothing more reachable right now") splits two ways and must NOT read the same: + // - caughtUp: every relay genuinely bottomed out (empty page). This is the real "all caught up". + // - incomplete: we stopped only because some relays are stalled (auth-walled / offline / silent), + // so messages may still be out there. It must say so, stay put, and let the user tap to see which. + val caughtUp = exhausted && stalledCount <= 0 + val incomplete = exhausted && stalledCount > 0 + + // Relays that still have older history to pull: not done and not stalled. Unlike [relayCount] (only + // those fetching a page *right now*), this also counts relays that returned a page and PARKED — the + // `⋯` paused state — so the subtitle doesn't read as a bare tag while the tap-popup lists N relays. + val reaching = remember(relayProgress) { relayProgress.values.count { !it.done && !it.stalled } } + + // Only the genuine caught-up state lingers then collapses; an incomplete window stays so it can be acted on. + var collapsed by remember { mutableStateOf(false) } + LaunchedEffect(caughtUp) { + collapsed = + if (caughtUp) { + delay(ALL_DONE_VISIBLE_MS) + true + } else { + false + } + } + + var showRelays by remember { mutableStateOf(false) } + if (showRelays) { + DmHistoryRelayDialog(protocolTag, relayProgress, formatReachDate) { showRelays = false } + } + + AnimatedVisibility( + visible = !collapsed, + modifier = modifier, + enter = fadeIn(), + exit = shrinkVertically(tween(400)) + fadeOut(tween(250)), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .then(if (relayProgress.isNotEmpty()) Modifier.clickable { showRelays = true } else Modifier), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 2.dp, + ) { + val phase = + when { + caughtUp -> HistoryPhase.CaughtUp + incomplete -> HistoryPhase.Incomplete + else -> HistoryPhase.Loading + } + Crossfade(targetState = phase, animationSpec = tween(500), label = "dmHistoryState") { state -> + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + when (state) { + HistoryPhase.CaughtUp -> + Text( + "✓", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + HistoryPhase.Incomplete -> + // Same glyph the per-relay dialog uses for a stalled relay, same error colour — + // signals "stopped early, some relays didn't answer", not "done". + Text( + "…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + HistoryPhase.Loading -> + if (loading) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + // Paused: not caught up, but not actively loading (the auto-fill stopped short + // of exhaustion, or we're between pages). Show a static "more" glyph so the + // icon slot is never blank — resumes on scroll. + Text( + "⋯", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = + when (state) { + HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_all_caught_up) + HistoryPhase.Incomplete -> stringResource(Res.string.chats_history_incomplete) + HistoryPhase.Loading -> stringResource(Res.string.chats_history_older, protocolName) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = + when (state) { + HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_reached_start, protocolName) + HistoryPhase.Incomplete -> incompleteSubtitle(stalledCount) + HistoryPhase.Loading -> historySubtitle(protocolTag, reaching, stalledCount, reachedBack, formatReachDate) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +/** The three terminal-vs-loading faces of the history card: still loading/paused, genuinely caught up, or + * stopped early because relays stalled. Kept distinct so an incomplete window never reads as "all caught up". */ +private enum class HistoryPhase { Loading, CaughtUp, Incomplete } + +/** Subtitle for the "stopped early" state: how many relays we couldn't reach, with a hint to tap for the list. + * Shared with the reply placeholder so both read identically. */ +@Composable +fun incompleteSubtitle(stalledCount: Int): String = + stringResource( + Res.string.chats_history_incomplete_sub, + pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount), + ) + +@Composable +fun historySubtitle( + protocolTag: String, + relayCount: Int, + stalledCount: Int, + reachedBack: Long?, + formatReachDate: (epochSeconds: Long) -> String, +): String { + val backLabel = remember(reachedBack) { reachedBack?.let(formatReachDate) } + // Middle segment: relays still working on it — fetching a page OR parked with more to pull ("N relays") + // — or, when none are reaching but some can't be reached, what we're waiting on ("waiting on N relays"). + // With neither (every relay done), just the tag. [relayCount] here is the reaching count, not in-flight. + val middle = + when { + relayCount > 0 -> pluralStringResource(Res.plurals.chats_history_relays, relayCount, relayCount) + stalledCount > 0 -> + stringResource( + Res.string.chats_history_waiting, + pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount), + ) + else -> return protocolTag + } + return if (backLabel != null) { + stringResource(Res.string.chats_history_subtitle, protocolTag, middle, backLabel) + } else { + stringResource(Res.string.chats_history_subtitle_no_date, protocolTag, middle) + } +} + +/** + * Popup shown when the history card is tapped: one row per relay with its state glyph (✓ done, … stalled, + * ↓ still reaching) and how far back it has paged ("since "), deepest-reaching first. A stalled relay + * also gets a one-line hint that it retries when the screen is reopened. + */ +@Composable +fun DmHistoryRelayDialog( + protocolTag: String, + relayProgress: Map, + formatReachDate: (epochSeconds: Long) -> String, + onDismiss: () -> Unit, +) { + val rows = remember(relayProgress) { relayProgress.entries.sortedBy { it.value.reachedUntil } } + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + }, + title = { Text(stringResource(Res.string.chats_history_relays_title, protocolTag)) }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + rows.forEach { (relay, p) -> + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = relayStateGlyph(p), + color = relayStateColor(p), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + Text( + text = relayShortName(relay), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.chats_history_relay_since, formatReachDate(p.reachedUntil)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (p.stalled) StalledRetryHint() + } + } + } + }, + ) +} + +/** The "stopped early, retries on reopen" caption shown under a stalled relay in the per-relay popups. + * Retry is demand-driven (no timer): reopening the screen re-binds and clears the stalled set, which + * retries it — so that's what we tell the user rather than a countdown we can't honour. */ +@Composable +private fun StalledRetryHint() { + Text( + text = stringResource(Res.string.chats_history_stalled_retry), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 22.dp, top = 2.dp), + ) +} + +private fun relayStateGlyph(p: RelayPagingProgress) = + when { + p.done -> "✓" + p.stalled -> "…" + else -> "↓" + } + +@Composable +private fun relayStateColor(p: RelayPagingProgress): Color = + when { + p.done -> MaterialTheme.colorScheme.primary + p.stalled -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') + +/** + * Popup shown when an in-stream "Loading" marker is tapped: the relays whose window sits at that point + * in the stream, each with its protocol tag, state glyph (✓ done · … stalled · ↓ reaching) and how far + * back it has paged — so the otherwise-terse `Loading: ↓ N` divider stops being a dead end and its + * meaning is explorable. A stalled relay also gets the retry-on-reopen hint. Deepest-reaching first. + */ +@Composable +fun RelayReachDetailDialog( + cursors: List, + formatReachDate: (epochSeconds: Long) -> String, + onDismiss: () -> Unit, +) { + val rows = remember(cursors) { cursors.sortedBy { it.reachedUntil } } + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + }, + title = { Text(stringResource(Res.string.chats_history_by_relay)) }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + rows.forEach { c -> + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = reachGlyph(c.state), + color = reachColor(c.state), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + if (c.protocol.isNotEmpty()) { + Text( + text = c.protocol, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = c.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.chats_history_relay_since, formatReachDate(c.reachedUntil)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (c.state == RelayReachState.STALLED) StalledRetryHint() + } + } + } + }, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt new file mode 100644 index 0000000000..6e25e26c2c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.ui.feeds + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded +import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded_label +import com.vitorpamplona.amethyst.commons.resources.chats_history_loading_label +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource + +// A relay-reach divider is hair-thin; inlined here so the shared component carries no app-theme dep. +private val DividerThickness = 0.25.dp + +/** + * True when [reachedUntil] falls in the gap between a newer message (at [newerCreatedAt]) and its + * next-older neighbour (at [olderCreatedAt], null past the oldest end): the newer side is strictly + * newer than the cursor and the older side is at or below it (or absent). This single predicate both + * places the marker ([RelayReachMarkers]) and decides when its paging sentinel is on screen + * ([RelayReachSentinels]), so the two can never disagree about which gap a cursor lives in. + */ +internal fun reachedFallsInGap( + reachedUntil: Long, + newerCreatedAt: Long?, + olderCreatedAt: Long?, +): Boolean = + newerCreatedAt != null && + newerCreatedAt > reachedUntil && + (olderCreatedAt == null || olderCreatedAt <= reachedUntil) + +/** How far one relay has paged into a feed's history, for an in-stream progress marker. */ +enum class RelayReachState { + // Still paging older — its marker slides down (older) as it advances. + REACHING, + + // Accepted but not answering right now (auth CLOSE / unreachable / slow); kept open, still trying. + STALLED, + + // Hit an empty page: nothing older on this relay, it has reached the bottom of its window. + DONE, +} + +/** One relay's marker entry within a gap. */ +data class RelayReach( + val name: String, + val state: RelayReachState, +) + +/** + * One relay's window-limit: places a marker and carries the [advance] that pulls that relay's next, + * older page. The marker sits at [reachedUntil] (the oldest point the relay has paged to); + * [RelayReachMarkers] draws it and [RelayReachSentinels] fires [advance] while it is on + * screen. + * + * @param key stable identity (protocol tag + relay url) so the sentinel survives list reorders. + */ +data class RelayReachCursor( + val key: String, + val name: String, + val reachedUntil: Long, + val state: RelayReachState, + // Short protocol tag (e.g. "NIP-17" / "NIP-04") shown in the tap-through detail popup, so a marker + // that mixes protocols in one gap isn't ambiguous. Empty when the feed has a single (implicit) kind. + val protocol: String = "", + val advance: () -> Unit, +) + +/** + * Drives demand-driven paging for every limit, **hoisted above the list** so its identity does not ride + * on which row currently hosts the marker. Each non-done limit gets one stable effect (keyed by + * [RelayReachCursor.key]) that watches the [listState] and pulls that relay's next page when its marker + * is on screen. + * + * Why hoisted: the marker for a limit lives in exactly one gap (between the two rows straddling its + * reached cursor). Placing the sentinel *inside* that row made its effect's identity ride the hosting + * row — so any feed reorder (a live message, or a slow relay dribbling a history page) moved the gap to a + * different row, tore the effect down and recreated it, and re-fired `advance()` on a static screen. + * That re-armed stalled/auth relays into a silence-watchdog storm and could walk a delivering relay + * back a window with no scroll. Hoisting the effect and driving it off **viewport visibility** instead + * of composition presence removes that coupling. + * + * Fires `advance()` when (and only when) the marker's gap is among the currently visible rows AND either + * it just scrolled into view OR its reached cursor moved (a page landed — keep paging while visible). + * A reorder that keeps the marker on the same side of the fold changes neither, so it no longer re-fires. + * A done relay drives nothing. + * + * @param createdAtAt createdAt of the list item at an index (null past the ends / for non-message rows), + * so the visible-gap test mirrors [RelayReachMarkers]'s placement against only the on-screen rows. + */ +@Composable +fun RelayReachSentinels( + limits: List, + listState: LazyListState, + createdAtAt: (index: Int) -> Long?, +) { + limits.forEach { lim -> + if (lim.state == RelayReachState.DONE) return@forEach + key(lim.key) { + val reached = rememberUpdatedState(lim.reachedUntil) + val advance = rememberUpdatedState(lim.advance) + val getAt = rememberUpdatedState(createdAtAt) + LaunchedEffect(Unit) { + snapshotFlow { + val r = reached.value + val at = getAt.value + // Visible if any on-screen row is the "newer" side of the gap holding this cursor — + // the same predicate RelayReachMarkers uses to place the marker, but over the + // visible rows only. + val onScreen = + listState.layoutInfo.visibleItemsInfo.any { info -> + reachedFallsInGap(r, at(info.index), at(info.index + 1)) + } + // Pair so distinctUntilChanged also lets a landed page (r moved) re-fire while visible, + // not just the off→on-screen transition. + onScreen to r + }.distinctUntilChanged() + .collect { (onScreen, r) -> + if (onScreen) { + // One line per sentinel fire — a re-fire LOOP would show the same key firing + // over and over (and whether its reached cursor is drifting). + Log.d("DMPagination") { "marker fire ${lim.key} reachedUntil=$r" } + advance.value() + } + } + } + } + } +} + +/** + * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message + * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end). Pure + * UI: the load driving lives in [RelayReachSentinels], so this can be (re)placed freely per row on + * every feed reorder without triggering any paging. + * + * A [DONE][RelayReachState.DONE] relay has no incompleteness frontier — it has loaded everything it has — + * so it does NOT mark its own history bottom mid-stream (which would read like a false "incomplete below + * here" line). Instead every done relay sinks to the **oldest-end gap** ([olderCreatedAt] null), where it + * renders as one "fully loaded" marker. Only [REACHING][RelayReachState.REACHING] / + * [STALLED][RelayReachState.STALLED] relays — the genuine "below here may still be incomplete" frontiers — + * are placed at their reached cursor. + */ +@Composable +fun RelayReachMarkers( + limits: List, + newerCreatedAt: Long?, + olderCreatedAt: Long?, + // Tapped with the relays in this gap, so the caller can open a detail popup (which relays, how far + // back, per protocol). Null leaves the marker a passive, non-interactive divider. + onShowDetail: ((List) -> Unit)? = null, +) { + val here = + remember(limits, newerCreatedAt, olderCreatedAt) { + limits.filter { + if (it.state == RelayReachState.DONE) { + // Fully loaded → sink to the oldest end rather than mark a frontier it doesn't have. + newerCreatedAt != null && olderCreatedAt == null + } else { + reachedFallsInGap(it.reachedUntil, newerCreatedAt, olderCreatedAt) + } + } + } + if (here.isEmpty()) return + + RelayReachMarker(here.map { RelayReach(it.name, it.state) }, onClick = onShowDetail?.let { cb -> { cb(here) } }) +} + +/** + * A thin divider drawn between two messages marking the point one or more relays have paged down to. + * As a relay loads older history its reached cursor drops, so the caller places this marker further + * down (older) in the stream — relays that race ahead leave their marker deep while slower relays' + * markers trail higher up, converging as they catch up. + * + * The line is always captioned so it's never a bare glyph cluster: the live frontiers + * ([REACHING][RelayReachState.REACHING] / [STALLED][RelayReachState.STALLED]) read "Loading:"; the + * oldest-end pile of [DONE][RelayReachState.DONE] relays reads "Fully loaded:". Each state then renders + * one compact label: the host name(s) when one — or two short-named — relays sit at that state (the usual + * converged case, where each relay rests at its own depth), or just a count when several pile up at the + * same depth (e.g. all nine clustered at the oldest-end floor) so the line can't grow into an unreadable + * comma list. In the rare mixed line (an active frontier sharing the oldest-end gap with done relays) the + * caption is "Loading:", so the done chip is suffixed "(fully loaded)" to keep its meaning clear. Either + * way the whole marker is tappable for the full per-relay breakdown. Reads e.g. "Loading: ↓ nostr.wine", + * "Loading: ↓ 8 relays" or "Fully loaded: ✓ 8 relays". + */ +@Composable +private fun RelayReachMarker( + entries: List, + onClick: (() -> Unit)? = null, +) { + if (entries.isEmpty()) return + + val hasActiveFrontier = entries.any { it.state != RelayReachState.DONE } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(5.dp).then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), + ) { + HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + // Always caption the line so it's never a bare glyph cluster: live frontiers are "Loading:"; the + // oldest-end pile of only-done relays is "Fully loaded:". + Text( + text = + stringResource( + if (hasActiveFrontier) Res.string.chats_history_loading_label else Res.string.chats_history_fully_loaded_label, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + // Present states in enum order. Written with an explicit `sortedBy` + `entry.key`/`entry.value` + // (not `toSortedMap(compareBy { it.ordinal })` + a destructured `(state, list)`) because + // Kotlin/Native's Compose compiler can't infer those inside this inline @Composable lambda + // (commons iOS). + entries + .groupBy { it.state } + .entries + .sortedBy { it.key.ordinal } + .forEachIndexed { index, entry -> + val state = entry.key + val list = entry.value + if (index > 0) { + Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) + } + // Spell out 1–2 short host names; otherwise "N relays" for every state, so a count chip + // always reads as a sentence ("↓ 8 relays", "✓ 8 relays") rather than a bare number. Only a + // mixed line (caption "Loading:") needs the done chip tagged "(fully loaded)" — a pure-done + // line already says so in its "Fully loaded:" caption. + val names = list.map { it.name } + val label = reachInlineNames(names) ?: pluralStringResource(Res.plurals.chats_history_relays, names.size, names.size) + val chip = reachGlyph(state) + " " + label + Text( + text = if (state == RelayReachState.DONE && hasActiveFrontier) chip + " " + stringResource(Res.string.chats_history_fully_loaded) else chip, + color = reachColor(state), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + } +} + +// Host names short enough to spell out inline on the single-line divider instead of collapsing to a +// bare count: a name up to [INLINE_NAME_MAX] when it's the lone relay of its state, or two names each +// up to [INLINE_TWO_NAMES_MAX] when a pair shares it. Longer hosts, or 3+ relays at one state, return +// null so the caller renders a count instead and the line can't grow unbounded — the tap-through dialog +// always lists them all. +private const val INLINE_NAME_MAX = 16 +private const val INLINE_TWO_NAMES_MAX = 12 + +internal fun reachInlineNames(names: List): String? = + when { + names.size == 1 && names[0].length <= INLINE_NAME_MAX -> names[0] + names.size == 2 && names.all { it.length <= INLINE_TWO_NAMES_MAX } -> names.joinToString(", ") + else -> null + } + +internal fun reachGlyph(state: RelayReachState) = + when (state) { + RelayReachState.REACHING -> "↓" + RelayReachState.STALLED -> "…" + RelayReachState.DONE -> "✓" + } + +@Composable +internal fun reachColor(state: RelayReachState): Color = + when (state) { + RelayReachState.REACHING -> MaterialTheme.colorScheme.onSurfaceVariant + RelayReachState.STALLED -> MaterialTheme.colorScheme.error + RelayReachState.DONE -> MaterialTheme.colorScheme.primary + } 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 f308bf5b82..e75f2371b5 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 @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.Hex @@ -144,16 +145,19 @@ class ChatNewMessageState( } val replyHint = _replyTo.value?.toEventHint() + val subjectText = _subject.value.text.ifBlank { null } val template = if (replyHint == null) { ChatMessageEvent.build(messageText, pTags) { + subjectText?.let { changeSubject(it) } hashtags(findHashtags(messageText)) references(findURLs(messageText)) quotes(findNostrEventUris(messageText)) } } else { ChatMessageEvent.reply(messageText, replyHint) { + subjectText?.let { changeSubject(it) } hashtags(findHashtags(messageText)) references(findURLs(messageText)) quotes(findNostrEventUris(messageText)) diff --git a/commons/src/commonTest/kotlin/android/util/Log.kt b/commons/src/commonTest/kotlin/android/util/Log.kt index cc9ab36145..d0c09ca3af 100644 --- a/commons/src/commonTest/kotlin/android/util/Log.kt +++ b/commons/src/commonTest/kotlin/android/util/Log.kt @@ -22,13 +22,11 @@ package android.util class Log { companion object { - @JvmStatic fun isLoggable( tag: String?, msg: Int?, ): Boolean = true - @JvmStatic fun d( tag: String?, msg: String?, @@ -37,7 +35,6 @@ class Log { return 0 } - @JvmStatic fun i( tag: String?, msg: String?, @@ -46,7 +43,6 @@ class Log { return 0 } - @JvmStatic fun w( tag: String?, msg: String?, @@ -55,7 +51,6 @@ class Log { return 0 } - @JvmStatic fun w( tag: String?, msg: String?, @@ -66,7 +61,6 @@ class Log { return 0 } - @JvmStatic fun e( tag: String?, msg: String?, @@ -75,7 +69,6 @@ class Log { return 0 } - @JvmStatic fun e( tag: String?, msg: String?, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/Base83Test.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/Base83Test.kt index b0d40b2303..77edcd8415 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/Base83Test.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/Base83Test.kt @@ -21,22 +21,22 @@ package com.vitorpamplona.amethyst.commons import com.vitorpamplona.amethyst.commons.blurhash.Base83 -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals class Base83Test { @Test fun testEncodeDecode() { for (i in 0..820000) { - assertEquals("$i encode decode", i, Base83.decode(Base83.encode(i.toLong()))) + assertEquals(i, Base83.decode(Base83.encode(i.toLong())), "$i encode decode") } } @Test fun testSingleDigits() { for (i in 0..82) { - val expected: String = String(Base83.ALPHABET, i, 1) - assertEquals("$i encodes", expected, Base83.encode(i.toLong(), 1)) + val expected: String = Base83.ALPHABET.concatToString(i, i + 1) + assertEquals(expected, Base83.encode(i.toLong(), 1), "$i encodes") } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/SRGBTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/SRGBTest.kt index 569e45e563..c3a9d38daf 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/SRGBTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/SRGBTest.kt @@ -21,22 +21,22 @@ package com.vitorpamplona.amethyst.commons import com.vitorpamplona.amethyst.commons.blurhash.SRGB -import org.junit.Assert.assertEquals -import org.junit.Test import kotlin.math.round +import kotlin.test.Test +import kotlin.test.assertEquals class SRGBTest { @Test fun testEncodeDecode() { for (i in 0..255) { - assertEquals("$i encode decode", i, SRGB.linearToSrgb(SRGB.srgbToLinear(i))) + assertEquals(i, SRGB.linearToSrgb(SRGB.srgbToLinear(i)), "$i encode decode") } for (i in 0..100) { val srgb = SRGB.linearToSrgb(i / 100.0f) val linear = round(SRGB.srgbToLinear(srgb) * 100).toInt() - assertEquals("$i decode encode", i, linear) + assertEquals(i, linear, "$i decode encode") } } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt index a152963ec9..cddc9872db 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt @@ -22,12 +22,12 @@ package com.vitorpamplona.amethyst.commons import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue class ThumbHashTest { @Test @@ -37,20 +37,20 @@ class ThumbHashTest { val pixels = IntArray(w * h) { 0xFFFF8040.toInt() } // opaque warm orange val hashBytes = ThumbHashEncoder.encode(pixels, w, h) - assertTrue("hash should have at least header bytes", hashBytes.size >= 5) + assertTrue(hashBytes.size >= 5, "hash should have at least header bytes") val decoded = ThumbHashDecoder.decode(hashBytes) assertNotNull(decoded) decoded!! - assertTrue("decoded width should be positive", decoded.width > 0) - assertTrue("decoded height should be positive", decoded.height > 0) + assertTrue(decoded.width > 0, "decoded width should be positive") + assertTrue(decoded.height > 0, "decoded height should be positive") val originalRatio = w.toFloat() / h.toFloat() val decodedRatio = decoded.width.toFloat() / decoded.height.toFloat() // ThumbHash loses some precision, but landscape vs portrait should be preserved. assertTrue( - "decoded ratio ($decodedRatio) should be on the same side of 1 as original ($originalRatio)", (originalRatio > 1f) == (decodedRatio > 1f) || originalRatio == decodedRatio, + "decoded ratio ($decodedRatio) should be on the same side of 1 as original ($originalRatio)", ) } @@ -69,8 +69,8 @@ class ThumbHashTest { } val encoded = ThumbHashEncoder.encodeToBase64(pixels, w, h) - assertTrue("base64 string should be non-empty", encoded.isNotEmpty()) - assertTrue("base64 string should not contain padding", !encoded.contains('=')) + assertTrue(encoded.isNotEmpty(), "base64 string should be non-empty") + assertTrue(!encoded.contains('='), "base64 string should not contain padding") val viaBase64 = ThumbHashDecoder.decode(encoded) assertNotNull(viaBase64) @@ -80,8 +80,8 @@ class ThumbHashTest { assertNotNull(viaBytes) viaBytes!! - assertEquals("base64 path and raw path should agree on width", viaBytes.width, viaBase64.width) - assertEquals("base64 path and raw path should agree on height", viaBytes.height, viaBase64.height) + assertEquals(viaBytes.width, viaBase64.width, "base64 path and raw path should agree on width") + assertEquals(viaBytes.height, viaBase64.height, "base64 path and raw path should agree on height") } @Test @@ -101,7 +101,7 @@ class ThumbHashTest { decoded!! for (p in decoded.pixels) { val a = (p ushr 24) and 0xff - assertEquals("alpha should be 255 for opaque encode", 255, a) + assertEquals(255, a, "alpha should be 255 for opaque encode") } } @@ -120,7 +120,7 @@ class ThumbHashTest { val a = (p ushr 24) and 0xff if (a > maxAlpha) maxAlpha = a } - assertTrue("max alpha of all-transparent decode should be low; got $maxAlpha", maxAlpha <= 16) + assertTrue(maxAlpha <= 16, "max alpha of all-transparent decode should be low; got $maxAlpha") } @Test @@ -150,9 +150,9 @@ class ThumbHashTest { val avgB = sumB / count // ThumbHash quantisation allows a handful of codepoints of drift. - assertTrue("avg R drift: expected ${target[0]}, got $avgR", abs(avgR - target[0]) < 8) - assertTrue("avg G drift: expected ${target[1]}, got $avgG", abs(avgG - target[1]) < 8) - assertTrue("avg B drift: expected ${target[2]}, got $avgB", abs(avgB - target[2]) < 8) + assertTrue(abs(avgR - target[0]) < 8, "avg R drift: expected ${target[0]}, got $avgR") + assertTrue(abs(avgG - target[1]) < 8, "avg G drift: expected ${target[1]}, got $avgG") + assertTrue(abs(avgB - target[2]) < 8, "avg B drift: expected ${target[2]}, got $avgB") } @Test @@ -163,7 +163,7 @@ class ThumbHashTest { val hash = ThumbHashEncoder.encode(pixels, w, h) val ratio = ThumbHashDecoder.aspectRatio(hash) assertNotNull(ratio) - assertTrue("landscape ratio should be > 1, got $ratio", ratio!! > 1f) + assertTrue(ratio!! > 1f, "landscape ratio should be > 1, got $ratio") } @Test @@ -174,11 +174,11 @@ class ThumbHashTest { val hash = ThumbHashEncoder.encode(pixels, w, h) val ratio = ThumbHashDecoder.aspectRatio(hash) assertNotNull(ratio) - assertTrue("portrait ratio should be < 1, got $ratio", ratio!! < 1f) + assertTrue(ratio!! < 1f, "portrait ratio should be < 1, got $ratio") } @Test - fun `repeated decodes produce identical output (cosine cache determinism)`() { + fun `repeated decodes produce identical output - cosine cache determinism`() { val w = 40 val h = 30 val pixels = @@ -222,8 +222,8 @@ class ThumbHashTest { // Chop off half the AC payload. val truncated = fullHash.copyOfRange(0, 5 + (fullHash.size - 5) / 4) assertNull( - "hash with insufficient AC bytes should be rejected", ThumbHashDecoder.decode(truncated), + "hash with insufficient AC bytes should be rejected", ) } @@ -239,13 +239,13 @@ class ThumbHashTest { assertNotNull(decoded) decoded!! assertTrue( - "expected output to fit in 32x32, got ${decoded.width}x${decoded.height}", decoded.width in 1..32 && decoded.height in 1..32, + "expected output to fit in 32x32, got ${decoded.width}x${decoded.height}", ) assertEquals( - "pixel buffer size must match dimensions", decoded.width * decoded.height, decoded.pixels.size, + "pixel buffer size must match dimensions", ) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrumTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrumTest.kt new file mode 100644 index 0000000000..a690cc64be --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrumTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.audio + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertTrue + +class AudioSpectrumTest { + @Test + fun normalizedToPeakScalesMaxToOne() { + val out = floatArrayOf(0f, 2f, 4f, 1f).normalizedToPeak() + assertEquals(1f, out[2], 1e-6f) + assertEquals(0.5f, out[1], 1e-6f) + } + + @Test + fun normalizedToPeakHandlesAllZero() { + val out = floatArrayOf(0f, 0f, 0f).normalizedToPeak() + assertTrue(out.all { it == 0f }) + } + + @Test + fun normalizedToPeakReturnsNewArrayEvenForAllZero() { + val input = floatArrayOf(0f, 0f, 0f) + assertNotSame(input, input.normalizedToPeak()) + } + + @Test + fun toLogBinsProducesRequestedCountClampedZeroToOne() { + val mags = FloatArray(256) { if (it < 4) 1f else 0f } + val bins = mags.toLogBins(32) + assertEquals(32, bins.size) + assertTrue(bins.all { it in 0f..1f }) + assertTrue(bins.first() > bins.last()) + } + + @Test + fun normalizeToPeakInPlaceMatchesAllocatingVersion() { + val input = floatArrayOf(0f, 2f, 4f, 1f) + val expected = input.normalizedToPeak() + val inPlace = input.copyOf() + inPlace.normalizeToPeakInPlace() + for (i in inPlace.indices) assertEquals(expected[i], inPlace[i], 1e-6f) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindowTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindowTest.kt new file mode 100644 index 0000000000..9fad1d4359 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioWindowTest.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.commons.audio + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AudioWindowTest { + @Test + fun hannIsZeroAtEndsAndOneInMiddle() { + val w = AudioWindow.hann(9) + assertEquals(0f, w.first(), 1e-5f) + assertEquals(0f, w.last(), 1e-5f) + assertEquals(1f, w[4], 1e-4f) + } + + @Test + fun shortsToWindowedNormalizesAndPadsToWindowLength() { + val window = FloatArray(4) { 1f } + val out = AudioWindow.shortsToWindowed(shortArrayOf(32767, -32768), window) + assertEquals(4, out.size) + assertTrue(out[0] in 0.99f..1.01f) + assertTrue(out[1] in -1.01f..-0.99f) + assertEquals(0f, out[2]) + assertEquals(0f, out[3]) + } + + @Test + fun shortsToWindowedIntoMatchesAllocatingVersion() { + val window = AudioWindow.hann(8) + val samples = ShortArray(8) { (it * 1000).toShort() } + val expected = AudioWindow.shortsToWindowed(samples, window) + val out = FloatArray(8) + AudioWindow.shortsToWindowedInto(samples, window, out) + for (i in out.indices) assertEquals(expected[i], out[i], 1e-6f) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/FftTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/FftTest.kt new file mode 100644 index 0000000000..a6017a1f34 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/FftTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio + +import kotlin.math.PI +import kotlin.math.sin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FftTest { + @Test + fun sineProducesPeakAtItsBin() { + val n = 64 + val k = 8 + val signal = FloatArray(n) { sin(2.0 * PI * k * it / n).toFloat() } + + val mags = Fft.magnitudes(signal) + + assertEquals(n / 2 + 1, mags.size) + val peakBin = mags.indices.maxByOrNull { mags[it] } + assertEquals(k, peakBin) + } + + @Test + fun dcOffsetLandsInBinZero() { + val n = 32 + val signal = FloatArray(n) { 0.5f } + + val mags = Fft.magnitudes(signal) + + val peakBin = mags.indices.maxByOrNull { mags[it] } + assertEquals(0, peakBin) + } + + @Test + fun rejectsNonPowerOfTwo() { + var threw = false + try { + Fft.magnitudes(FloatArray(30)) + } catch (e: IllegalArgumentException) { + threw = true + } + assertTrue(threw) + } + + @Test + fun magnitudesIntoMatchesMagnitudes() { + val n = 64 + val signal = FloatArray(n) { sin(2.0 * PI * 5 * it / n).toFloat() } + val expected = Fft.magnitudes(signal) + val out = FloatArray(n / 2 + 1) + Fft.magnitudesInto(signal, DoubleArray(n), DoubleArray(n), out) + for (i in out.indices) assertEquals(expected[i], out[i], 1e-3f) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumDelayTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumDelayTest.kt new file mode 100644 index 0000000000..b6f97804af --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumDelayTest.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.commons.audio + +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SpectrumDelayTest { + private fun frames(n: Int) = (0 until n).map { Spectrum(floatArrayOf(it.toFloat())) } + + @Test + fun delaysByFrameCountHoldingBackTheLatest() = + runTest { + // 10 frames in, hold back the latest 3 → 7 emitted, each shifted 3 hops earlier. + val out = frames(10).asFlow().delayedByFrames(3).toList() + assertEquals(7, out.size) + assertEquals(0f, out.first().bins[0]) + assertEquals(6f, out.last().bins[0]) + } + + @Test + fun zeroDelayIsIdentity() = + runTest { + val out = frames(4).asFlow().delayedByFrames(0).toList() + assertEquals(listOf(0f, 1f, 2f, 3f), out.map { it.bins[0] }) + } + + @Test + fun negativeDelayIsIdentity() = + runTest { + assertEquals( + 4, + frames(4) + .asFlow() + .delayedByFrames(-5) + .toList() + .size, + ) + } + + @Test + fun fewerFramesThanDelayEmitsNothing() = + runTest { + // Everything is still "in the buffer" (not yet audible), so nothing is shown. + assertEquals( + 0, + frames(2) + .asFlow() + .delayedByFrames(5) + .toList() + .size, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrumTest.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrumTest.kt index 0d451daecf..e2b5564491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticSpectrumTest.kt @@ -18,28 +18,38 @@ * AN ACTION OF CONTRACT, TORT OR 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.chats.privateDM.datasource +package com.vitorpamplona.amethyst.commons.audio -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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue -class ChatroomFilterSubAssembler( - client: INostrClient, - allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { - override fun updateFilter( - key: ChatroomQueryState, - since: SincePerRelayMap?, - ): List? = - if (key.account.isWriteable()) { - filterNip04DMs(key.room.users, key.account, since) - } else { - emptyList() +class SyntheticSpectrumTest { + @Test + fun frameHasRequestedBinsClampedZeroToOne() { + val frame = SyntheticSpectrum.frame(timeSec = 1.0f, binCount = 48) + assertEquals(48, frame.bins.size) + assertTrue(frame.bins.all { it in 0f..1f }) + } + + @Test + fun frameIsDeterministic() { + val a = SyntheticSpectrum.frame(2.0f, 32).bins + val b = SyntheticSpectrum.frame(2.0f, 32).bins + assertTrue(a.contentEquals(b)) + } + + @Test + fun bassBinsCarryMoreEnergyThanTreble() { + var low = 0f + var high = 0f + var t = 0f + repeat(20) { + val bins = SyntheticSpectrum.frame(t, 32).bins + low += bins.take(6).sum() + high += bins.takeLast(6).sum() + t += 0.05f } - - override fun user(key: ChatroomQueryState) = key.account.userProfile() - - override fun list(key: ChatroomQueryState) = key.listId + assertTrue(low > high) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt new file mode 100644 index 0000000000..ec64a42944 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerRegistryTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio + +import kotlin.test.Test +import kotlin.test.assertEquals + +class VisualizerRegistryTest { + @Test + fun registryCoversSpectrumStylesExactlyOnce() { + val styles = VisualizerRegistry.all.map { it.style }.toSet() + assertEquals( + setOf( + VisualizerStyle.OFF, + VisualizerStyle.BARS, + VisualizerStyle.WAVES, + VisualizerStyle.RADIAL, + VisualizerStyle.AURORA, + VisualizerStyle.STATIC, + ), + styles, + ) + assertEquals(6, VisualizerRegistry.all.size) + } + + @Test + fun forStyleMatchesForRegisteredStylesAndFallsBackForClassic() { + for (renderer in VisualizerRegistry.all) { + assertEquals(renderer.style, VisualizerRegistry.forStyle(renderer.style).style) + } + // CLASSIC has no spectrum renderer; the dispatcher falls back to OFF. + assertEquals(VisualizerStyle.OFF, VisualizerRegistry.forStyle(VisualizerStyle.CLASSIC).style) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt new file mode 100644 index 0000000000..0af8faecc1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/VisualizerStyleTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio + +import kotlin.test.Test +import kotlin.test.assertEquals + +class VisualizerStyleTest { + @Test + fun parsesByNameWithSafeDefault() { + assertEquals(VisualizerStyle.WAVES, VisualizerStyle.fromName("WAVES")) + assertEquals(VisualizerStyle.RADIAL, VisualizerStyle.fromName("RADIAL")) + assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.fromName("nonsense")) + assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.fromName(null)) + } + + @Test + fun defaultIsClassic() { + assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.DEFAULT) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt index 578df11b05..5c174845fb 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.emojicoder -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class EmojiCoderTest { companion object { @@ -68,7 +68,7 @@ class EmojiCoderTest { val encoded = EmojiCoder.encode(emoji, sentence) val decoded = EmojiCoder.decode(encoded) assertEquals(sentence, decoded) - assertTrue("Failed sentence for emoji $emoji with sentence `$sentence`: `$encoded`", EmojiCoder.isCoded(encoded)) + assertTrue(EmojiCoder.isCoded(encoded), "Failed sentence for emoji $emoji with sentence `$sentence`: `$encoded`") } } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt index 316c8b3743..f1d9d36ac0 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** * Lock in the descending-by-usage ordering and equal-count preservation of diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt index 3bcd5f52e7..77a7b54748 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue class NamecoinSettingsTest { // ── Server string parsing ────────────────────────────────────────── diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/OwnedEmojiPackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/OwnedEmojiPackTest.kt index 037fb9f70c..90a73f8ae6 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/OwnedEmojiPackTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/OwnedEmojiPackTest.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class OwnedEmojiPackTest { private val publicEmoji = EmojiUrlTag("public_one", "https://example.com/public.png") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/FixMissingSpacesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/FixMissingSpacesTest.kt new file mode 100644 index 0000000000..0890bd96cd --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/FixMissingSpacesTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Focused guard for [RichTextParser.fixMissingSpaces]. The earlier + * `Regex("([^ \n])?(url)([^ \n])?")` implementation corrupted every URL on + * Kotlin/Native (e.g. iOS) because the engine fails to backtrack the optional + * `([^ \n])?` groups to zero width — `"https://x"` came back as `"h https://x"`. + * These cases run on every target (incl. iosSimulatorArm64) and pin the behaviour. + */ +class FixMissingSpacesTest { + private val parser = RichTextParser() + + @Test + fun leavesAlreadySeparatedUrlUntouched() { + val url = "https://example.com/audio/track.f4a" + assertEquals(url, parser.fixMissingSpaces(url, setOf(url))) + } + + @Test + fun leavesUrlWithRegexMetacharactersUntouched() { + val url = "universe.nostrich.land?lang=zh" + val text = "foo $url bar" + assertEquals(text, parser.fixMissingSpaces(text, setOf(url))) + } + + @Test + fun insertsSpacesAroundGluedUrl() { + assertEquals( + "a https://example.com/x b", + parser.fixMissingSpaces("ahttps://example.com/xb", setOf("https://example.com/x")), + ) + } + + @Test + fun emptyUrlSetIsNoOp() { + val text = "no urls here" + assertEquals(text, parser.fixMissingSpaces(text, emptySet())) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt new file mode 100644 index 0000000000..fafd50fdf8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.richtext + +import com.vitorpamplona.amethyst.commons.richtext.MathParser.Token +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MathParserTest { + // Kotlin uses `$` for string templates; using constants keeps the test + // strings readable instead of peppering them with `\$`. + private val d = "$" + private val bs = "\\" + + private fun math(tokens: List) = tokens.filterIsInstance() + + private fun joinWords(tokens: List) = tokens.filterIsInstance().joinToString(" ") { it.text } + + @Test + fun simpleInlineMath() { + assertEquals( + listOf( + Token.Word("before"), + Token.Math("${d}x_1$d", "x_1", false), + Token.Word("after"), + ), + MathParser.split("before ${d}x_1$d after"), + ) + } + + @Test + fun displayMath() { + val m = math(MathParser.split("eq $d${d}E = mc^2$d$d end")).single() + assertEquals("E = mc^2", m.latex) + assertTrue(m.displayMode) + assertEquals("$d${d}E = mc^2$d$d", m.raw) + } + + @Test + fun mathWithInternalSpacesStaysWhole() { + // The span keeps its internal spaces instead of being split into words. + val tokens = MathParser.split("Vectors ${d}A_1, ${bs}ldots, A_n$d are") + assertEquals( + listOf("Vectors", "are"), + tokens.filterIsInstance().map { it.text }, + ) + val m = math(tokens).single() + assertEquals("A_1, ${bs}ldots, A_n", m.latex) + assertFalse(m.displayMode) + } + + @Test + fun theLinearIndependencePost() { + val content = + "Vectors ${d}A_1, ${bs}ldots, A_n$d are independent if the only choice of scalars for which " + + "${d}x_1 A_1 + ${bs}cdots + x_n A_n = 0$d is the trivial one ${d}x_1 = ${bs}cdots = x_n = 0$d." + val m = math(MathParser.split(content)) + assertEquals(3, m.size) + assertEquals("A_1, ${bs}ldots, A_n", m[0].latex) + assertEquals("x_1 A_1 + ${bs}cdots + x_n A_n = 0", m[1].latex) + assertEquals("x_1 = ${bs}cdots = x_n = 0", m[2].latex) + // The closing span ends the sentence, so the period rides along as trailing. + assertEquals("", m[0].trailing) + assertEquals(".", m[2].trailing) + } + + @Test + fun trailingPunctuationRidesWithMath() { + val m = math(MathParser.split("the value ${d}x$d, computed")).single() + assertEquals("x", m.latex) + assertEquals(",", m.trailing) + } + + @Test + fun currencyIsNotMath() { + // Opening `$` of `$5` is followed by a digit (fine), but the closing `$` + // of `$10` is preceded by a space, so no valid span is formed. + val line = "It costs ${d}5 and ${d}10 total" + val tokens = MathParser.split(line) + assertTrue(math(tokens).isEmpty()) + assertEquals(line, joinWords(tokens)) + } + + @Test + fun openingDollarFollowedBySpaceIsNotMath() { + assertTrue(math(MathParser.split("a $d x + y $d b")).isEmpty()) + } + + @Test + fun escapedDollarIsLiteral() { + assertTrue(math(MathParser.split("price is $bs${d}5 to $bs${d}9")).isEmpty()) + } + + @Test + fun escapedDollarInsideMathDoesNotClose() { + val m = math(MathParser.split("cost ${d}a + $bs$d + b$d ok")).single() + assertEquals("a + $bs$d + b", m.latex) + } + + @Test + fun emptyMathIsIgnored() { + assertTrue(math(MathParser.split("empty $d$d here")).isEmpty()) + } + + @Test + fun collapsesOverEscapedBackslashes() { + // `\\ldots` (over-escaped) -> `\ldots`; otherwise JLaTeXMath sees a line + // break plus the literal letters "ldots". + assertEquals("A_1, ${bs}ldots, A_n", MathParser.collapseDoubledBackslashes("A_1, $bs$bs" + "ldots, A_n")) + assertEquals("x_1 = ${bs}cdots = x_n", MathParser.collapseDoubledBackslashes("x_1 = $bs$bs" + "cdots = x_n")) + } + + @Test + fun singleBackslashLatexIsLeftAlone() { + assertEquals("${bs}frac{1}{2} + ${bs}sqrt{x}", MathParser.collapseDoubledBackslashes("${bs}frac{1}{2} + ${bs}sqrt{x}")) + } + + @Test + fun gluedMathStaysPlainWord() { + // Math without a separating space is not whitespace-delimited, so it + // remains a single literal word rather than three rendered pieces. + val tokens = MathParser.split("a${d}x$d" + "b") + assertTrue(math(tokens).isEmpty()) + assertEquals(listOf(Token.Word("a${d}x$d" + "b")), tokens) + } + + @Test + fun noDollarsBehavesLikeSpaceSplit() { + assertFalse(MathParser.mightContainMath("just regular text")) + assertEquals( + "just regular text".split(' ').map { Token.Word(it) }, + MathParser.split("just regular text"), + ) + } + + @Test + fun doubleSpacesArePreservedAsEmptyWords() { + // RichTextParser relies on split(' ') semantics to keep double-spaces: + // each run of N spaces yields N-1 empty words, on both sides of math. + assertEquals( + listOf( + Token.Word("a"), + Token.Word(""), + Token.Word("b"), + Token.Math("${d}x$d", "x", false), + Token.Word(""), + Token.Word("c"), + ), + MathParser.split("a b ${d}x$d c"), + ) + } + + @Test + fun adjacentInlineSpans() { + val m = math(MathParser.split("${d}a$d ${d}b$d")) + assertEquals(2, m.size) + assertEquals("a", m[0].latex) + assertEquals("b", m[1].latex) + } + + @Test + fun parenWrappedMathIsDetected() { + // `($r = 1$)` is the common prose case: the opening paren rides along as + // leading, the closing paren as trailing, and the inner span is math. + val m = math(MathParser.split("translations (${d}r = 1$d) is cyclic")).single() + assertEquals("r = 1", m.latex) + assertEquals("(", m.leading) + assertEquals(")", m.trailing) + } + + @Test + fun bracketAndQuoteWrappedMathAreDetected() { + val tokens = MathParser.split("see [${d}x$d] and \"${d}y$d\" here") + val m = math(tokens) + assertEquals(2, m.size) + assertEquals("x", m[0].latex) + assertEquals("[", m[0].leading) + assertEquals("]", m[0].trailing) + assertEquals("y", m[1].latex) + assertEquals("\"", m[1].leading) + assertEquals("\"", m[1].trailing) + } + + @Test + fun alphanumericGluedPrefixStaysPlainWord() { + // A letter before `$` is not opening punctuation, so the existing + // glued-word behaviour is preserved (no false-firing on `a$x$`). + val tokens = MathParser.split("a${d}x$d" + " end") + assertTrue(math(tokens).isEmpty()) + assertEquals(listOf("a${d}x$d", "end"), tokens.filterIsInstance().map { it.text }) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMathAdjacencyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMathAdjacencyTest.kt new file mode 100644 index 0000000000..7dfa03d931 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMathAdjacencyTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Guards that math extraction stays inside its `$`-delimiters and doesn't disturb + * neighbouring links / images / hashtags when separated by a space (the normal + * case), and documents the glued (no-space) edge behaviour. + */ +class RichTextParserMathAdjacencyTest { + // Kotlin uses `$` for string templates; a constant keeps the test strings readable. + private val d = "$" + + private fun parse(content: String) = RichTextParser().parseText(content, EmptyTagList, null).paragraphs.flatMap { it.words } + + @Test + fun mathSegmentIsJustTheSpanNotTheParagraph() { + val content = "before ${d}x_1 + y$d after more words here" + val math = parse(content).filterIsInstance().single() + assertEquals("${d}x_1 + y$d", math.segmentText) + assertEquals("x_1 + y", math.latex) + } + + @Test + fun spaceSeparatedHashtagUrlAndMathAreAllDetected() { + val content = "#physics the law ${d}E=mc^2$d see https://nostr.com today" + val words = parse(content) + assertEquals(1, words.filterIsInstance().size) + assertEquals(1, words.filterIsInstance().size) + assertEquals(1, words.filterIsInstance().size) + assertEquals("physics", words.filterIsInstance().single().hashtag) + assertEquals("E=mc^2", words.filterIsInstance().single().latex) + assertEquals("https://nostr.com", words.filterIsInstance().single().segmentText) + } + + @Test + fun imageNextToMathStaysAnImage() { + val content = "${d}a^2$d https://i.imgur.com/abc.jpg done" + val words = parse(content) + assertEquals(1, words.filterIsInstance().size) + assertEquals(1, words.filterIsInstance().size) + assertEquals("https://i.imgur.com/abc.jpg", words.filterIsInstance().single().segmentText) + } + + @Test + fun mathEndingSentenceThenHashtag() { + // `$x$.` keeps the period as trailing; the hashtag after the space is its own segment. + val content = "result ${d}x=0$d. #done" + val words = parse(content) + val math = words.filterIsInstance().single() + assertEquals("x=0", math.latex) + assertEquals(".", math.trailing) + assertEquals("done", words.filterIsInstance().single().hashtag) + } + + // ---- glued (no-space) adjacency: documents the known limitation ---- + + @Test + fun hashtagGluedAfterMathBecomesTrailingText() { + // `$x$#tag` with no space: the hashtag is absorbed as trailing punctuation + // and is NOT a clickable hashtag. Acceptable edge case. + val content = "eq ${d}x$d#tag" + val words = parse(content) + val math = words.filterIsInstance().single() + assertEquals("x", math.latex) + assertEquals("#tag", math.trailing) + assertTrue(words.none { it is HashTagSegment }) + } + + @Test + fun mathGluedAfterHashtagDoesNotRenderAsMath() { + // `#tag$x$` with no space: the `#` token wins; the math stays literal text. + val content = "see #tag${d}x$d here" + val words = parse(content) + assertTrue(words.none { it is MathSegment }) + assertEquals("tag", words.filterIsInstance().single().hashtag) + } + + @Test + fun currencyBeforeRealEquationDoesNotSwallow() { + // `$5` must not pair with the later equation's `$`. + val content = "costs ${d}5 but the formula ${d}x=1$d holds" + assertEquals("x=1", parse(content).filterIsInstance().single().latex) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt index a1b896e453..397e7b79a7 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class RichTextParserMultibyteTest { @Test @@ -42,20 +42,20 @@ class RichTextParserMultibyteTest { // user@example.com should be EmailSegment assertTrue( - "user@example.com should be EmailSegment", allSegments.any { it is EmailSegment && it.segmentText == "user@example.com" }, + "user@example.com should be EmailSegment", ) // user@example.com should NOT be a LinkSegment assertTrue( - "user@example.com should not be a LinkSegment", allSegments.none { it is LinkSegment && it.segmentText == "user@example.com" }, + "user@example.com should not be a LinkSegment", ) // user@example.com should not be in urlSet assertTrue( - "user@example.com should not be in urlSet", !state.urlSet.withScheme.contains("user@example.com") && !state.urlSet.withoutScheme.contains("user@example.com"), + "user@example.com should not be in urlSet", ) } @@ -145,8 +145,8 @@ class RichTextParserMultibyteTest { val state = RichTextParser().parseText(text, EmptyTagList, null) val allSegments = state.paragraphs.flatMap { it.words } assertTrue( - "user@example.com should be EmailSegment", allSegments.any { it is EmailSegment && it.segmentText == "user@example.com" }, + "user@example.com should be EmailSegment", ) } @@ -158,11 +158,11 @@ class RichTextParserMultibyteTest { val allSegments = state.paragraphs.flatMap { it.words } val urlSegments = allSegments.filterIsInstance() - assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) - assertTrue("URL should be example.com", urlSegments.any { it.segmentText == "example.com" }) + assertTrue(urlSegments.isNotEmpty(), "Should have SchemelessUrlSegment") + assertTrue(urlSegments.any { it.segmentText == "example.com" }, "URL should be example.com") val textSegments = allSegments.filterIsInstance() - assertTrue("Should have prefix ああ", textSegments.any { it.segmentText == "ああ" }) + assertTrue(textSegments.any { it.segmentText == "ああ" }, "Should have prefix ああ") } @Test @@ -173,11 +173,11 @@ class RichTextParserMultibyteTest { val allSegments = state.paragraphs.flatMap { it.words } val urlSegments = allSegments.filterIsInstance() - assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) - assertTrue("URL should be example.com", urlSegments.any { it.segmentText == "example.com" }) + assertTrue(urlSegments.isNotEmpty(), "Should have SchemelessUrlSegment") + assertTrue(urlSegments.any { it.segmentText == "example.com" }, "URL should be example.com") val textSegments = allSegments.filterIsInstance() - assertTrue("Should have suffix ああ", textSegments.any { it.segmentText == "ああ" }) + assertTrue(textSegments.any { it.segmentText == "ああ" }, "Should have suffix ああ") } @Test @@ -188,12 +188,12 @@ class RichTextParserMultibyteTest { val allSegments = state.paragraphs.flatMap { it.words } val emailSegment = allSegments.filterIsInstance() - assertTrue("Should have EmailSegment", emailSegment.isNotEmpty()) - assertTrue("Email should be user@example.com", emailSegment.any { it.segmentText == "user@example.com" }) + assertTrue(emailSegment.isNotEmpty(), "Should have EmailSegment") + assertTrue(emailSegment.any { it.segmentText == "user@example.com" }, "Email should be user@example.com") val textSegments = allSegments.filterIsInstance() - assertTrue("Should have prefix ほむほむ", textSegments.any { it.segmentText == "ほむほむ" }) - assertTrue("Should have suffix ほげほげ", textSegments.any { it.segmentText == "ほげほげ" }) + assertTrue(textSegments.any { it.segmentText == "ほむほむ" }, "Should have prefix ほむほむ") + assertTrue(textSegments.any { it.segmentText == "ほげほげ" }, "Should have suffix ほげほげ") } @Test @@ -204,10 +204,10 @@ class RichTextParserMultibyteTest { val allSegments = state.paragraphs.flatMap { it.words } val emailSegment = allSegments.filterIsInstance() - assertTrue("Should have EmailSegment", emailSegment.isNotEmpty()) - assertTrue("Email should be user@example.com", emailSegment.any { it.segmentText == "user@example.com" }) + assertTrue(emailSegment.isNotEmpty(), "Should have EmailSegment") + assertTrue(emailSegment.any { it.segmentText == "user@example.com" }, "Email should be user@example.com") val textSegments = allSegments.filterIsInstance() - assertTrue("Should have suffix ふがふが", textSegments.any { it.segmentText == "ふがふが" }) + assertTrue(textSegments.any { it.segmentText == "ふがふが" }, "Should have suffix ふがふが") } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index f0f8ff5a94..68361d41c3 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists -import junit.framework.TestCase import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -688,17 +687,17 @@ class RichTextParserTest { val state = RichTextParser() .parseText(textToParse, EmptyTagList, null) - TestCase.assertEquals( + assertEquals( "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, miceliomad@miceliomad.github.io/nostr/, zapper.lol, smies.me, baller.hodl", state.urlSet.withoutScheme.joinToString(", "), ) - TestCase.assertEquals( + assertEquals( "jb55@jb55.com, Snowden@Nostr-Check.com, cameri@elder.nostr.land, natalie@NostrVerified.com, alanbwt@nostrplebs.com, rick@no.str.cr, shawn@shawnyeager.com, 0xtr@oxtr.dev, pavol@rusnak.io, caitlin@nostrverified.com, ralf@snort.social, stacksats@nostrplebs.com, MrHodl@nostrpurple.com, _@mikedilger.com, jascha@relayable.org, Nakadaimon@nostrplebs.com, KeithMukai@nostr.seedsigner.com, theguyswann@NostrVerified.com, dk@stacker.news, npub1z7eqn5603ltuxr77w70t3sasep8hyngzr6lxqpa9hfcqjwe9wmdqhw0qhv@nost.vip, miljan@primal.net, jared@nostrplebs.com, radii@orangepill.dev, _@katieannbaker.com, giacomozucco@BitcoinNostr.com, kr@stacker.news, phil@nostrpurple.com, angela@nostr.world, mason@lacosanostr.com, lau@nostr.report, damascusrex@iris.to, nym@nostr.fan, nico@nostrplebs.com, seekerdreamer1@stacker.news, thesamecat@iris.to, nitesh@noderunner.wtf, gpt3@jb55.com, byzantine@stacker.news, wealththeory@nostrplebs.com, gug@nostrplebs.com, lana@b.tc, shevacai@nostrplebs.com, joe@nostrpurple.com, simplestbitcoinbook@nostrplebs.com, knutsvanholm@iris.to, rs@zbd.ai, GRANTGILLIAM@grantgilliam.com, lifeloveliberty@iris.to, npub1s9c53smfq925qx6fgkqgw8as2e99l2hmj32gz0hjjhe8q67fxdvs3ga9je@nost.vip, arbedout@granddecentral.com, glowleaf@nostrplebs.com, modus@lacosanostr.com, anil@bitcoinnostr.com, documentingbtc@uselessshit.co, wolfbearclaw@nostr.messagepush.io, _@amboss.space, k3tan@k3tan.com, wolzie@BitcoinNostr.com, trey@nostrplebs.com, woody@fountain.fm, cosmicdimension@nostrplebs.com, mirbtc@getalby.com, marks@nostrplebs.com, barrensatin40@walletofsatoshi.com, alexemidio@alexemidio.github.io, Jenn@mintgreen.co, spacemonkey@nostrich.love, ishak@nostrplebs.com, GrassFedBitcoin@start9.com, ninoholds@nostrplebs.com, satcap@nostr.satcap.io, tpmoreira@nostrplebs.com, force2b@nostrplebs.com, hendrix@nostrplebs.com, TXMC@alphabetasoup.tv, pipleb@iris.to, reallhex@terranostr.com, nicb@nicb.me, NabismoPrime@BostonBTC.com, paco@iris.to, globalstatesmen@nostrplebs.com, _@NostrNet.work, crayonsmell@habel.net, ToxiKat27@Bitcoiner.social, jtrag@BitcoinNostr.com, joemartinmusic@nostrplebs.com, ph@nostrplebs.com, horse@iris.to, kp@no.str.cr, rebornbitcoiner@getalby.com, toshi@nostr-check.com, freeborn@nostrplebs.com, blee@bitcoiner.social, SatsTonight@BitcoinNostr.com, freeverification@Nostr-Check.com, cowmaster@getalby.com, hacker818@iris.to, amandabitcasa@nostrplebs.com, tiago@nostrplebs.com, sepehr@nostribe.com, gfy@stacker.news, cryptojournaal@iris.to, bon@nostrplebs.com, bot@binarywatch.org, moritz@getalby.com, hodlish@Nostr-Check.com, HolgerHatGarKeineNode@nip05.easify.de, joe@jaxo.github.io, hahattpro@iris.to, bensima@simatime.com, satan@nostrcheck.me, radvladdy@nostrplebs.com, yidneth@getalby.com, bellatrix@iris.to, securecoop@iris.to, charliesurf@ln.tips, bitcoinatm@Nostr-Check.com, lnstallone@allmysats.com, L0laL33tz@cashu.me, Lommy@nostrplebs.com, jgmontoya@nostrplebs.com, bavarianledger@iris.to, operator@brb.io, tiotito@nostriches.net, javi@www.javiergonzalez.io, geekigai@nostrplebs.com, Mr.Rabbit@BitcoinNostr.com, kilicl@nostr-check.com, alexbit@nostrbr.online, william@nostrplebs.com, oneezra@nostrplebs.com, iceandfirebtc@nostrplebs.com, nostrgang@nostrplebs.com, npub1vez5zekuzc3qk989q5gtly2zg9k2gz4l3wuplv5xs8y3se09yussg4vp7p@carteclip.com, Sqvaznyak@uselessshit.co, braj@nostrplebs.com, libertus@getalby.com, ZoeBoudreault@id.nostrfy.me, dnilso@iris.to, shroom@nostrplebs.com, olegaba@olegaba.com, wasabi@nostrplebs.com, artur@getalby.com, ihsanmd@getalby.com, satoshee@vida.page, ancapsu@getalby.com, niceaction@www.niceaction.com, seak@nostrplebs.com, andy@nodeless.io, pinkyjay@nostrplebs.com, criptobastardo@nostrplebs.com, lacosanostr@lacosanostr.com, teejem@nostrplebs.com, mewj@elder.nostr.land, thetonewrecker@nostrplebs.com, yodatravels@iris.to, bitcoin69@iris.to, Zzar@nostrplebs.com, vidalbidi@getalby.com, juangalt@current.ninja, dean@nostrplebs.com, alex2@nostrverified.com, trooper@iris.to, satscoinsv@getalby.com, aarbtc@iris.to, _@gue.yogsite.com, nostrmemes@iris.to, btcpavao@iris.to, Anonymous@BitcoinNostr.com, zoltanab@iris.to, katsu@onsats.org, bryan@nonni.io, pedromvpg@pedromvpg.com, sonicstudio@getalby.com, kounsellor@nostrplebs.com, katieross@nostrplebs.com, iamlj@iris.to, Zach@BitcoinNostr.com, amouranth@nostrcheck.me, hss5qy@getalby.com, dpcpw@iris.to, bitcoinfinity@nostrplebs.com, TonySeries@BitcoinNostr.com, kuobano@nostrplebs.com, kitakripto@BitcoinNostr.com, _@localhost.re, alxc@uselessshit.co, kukryr@orangepill.dev, saidah@nostrplebs.com, sharon21m@nostr.fan, dmn@noderunners.org, nela_at_nostrica2023@Nostr-Check.com, xbologg@nanostr.deno.dev, btcurenas@nostr.fan, amaluenda@getalby.com, queenb@nostrplebs.com, ezekiel@Nostr-Check.com, marakesh@getalby.com, storm@reddirtmining.io, _b_o_n_e_s_@stacker.news, bdichdbd@stacker.news, j9@nostrplebs.com, nokyctranslate@iris.to, Neomobius_at_mstdn.jp@mostr.pub, paddepadde@getcurrent.io, val@nostrplebs.com, dishwasher_iot@wlvs.space, 1@justinrezvani.com, sshh@nostrplebs.com, mk05@iris.to, herald@bitcoin-herald.org, nostr@pos.btcpayserver.it, thumpgofast@NostrVerified.com, neo@elder.nostr.land, alchemist@electronalchemy.com, timp@iris.to, ken@BitcoinNostr.com, irebus@nostr.red, stim4444@no.str.cr, layerlnw@nostr.fan, enhickman@enhickman.net, LiveFreeBTC@livefreebtc.org, aptx4869@aptx4869.app, khalil@klouche.com, nsec@ittybitty.tips, bigfish@iris.to, oldschool@iris.to, danton@nostrplebs.com, bitcoinzavior@nostrplebs.com, BitcoinSermons@BitcoinNostr.com, uknwlinux@plebs.place, satoshism@nostrplebs.com, jon@nostrplebs.com, hobozakki@nostrplebs.com, sirgalahodl@satstream.me, victor@lnmarkets.com, jacksmies@iris.to, chemaclass@snort.social, jayson@tautic.com, jesterhodl@jesterhodl.com, Bitcoin_Gamer_21@bitcoin-21.org, water-bot@gourcetools.github.io, w3crypto@iris.to, makaveli@nostrplebs.com, jamieanders@ln.tips, cball@nostrplebs.com, laura@nostrich.zone, kaz@reddirtmining.io, verismus@nostrplebs.com, bitpetro@nostrplebs.com, nossence@nossence.xyz, manbearpig@nostrplebs.com, johnsmith@nostrplebs.com, bitcoin@bitcoinedu.com, knggolf@nostrplebs.com, nighthaven@iris.to, xbt_fi@iris.to, gencarlosq17@iris.to, rotciv@plebs.place, Merlin@bitcoinnostr.com, yeg0rpetrov@iris.to, baloo@nostrpurple.com, jamesgospodyn@nostr.theorangepillapp.com, carl@armadalabs.studio, btcportal@nostrplebs.com, mjb@nostrplebs.com, esbewolkt@nostr.fan, koukos@iris.to, davekrock@NostrVerified.com, Bitcoinlovelife@BitcoinNostr.com, xolagl2@getalby.com, remoney@nostrplebs.com, roberto@bitcoiner.chat, cryptolazyninja@stacker.news, kimymt@getalby.com, benderlogic@rogue.earth, MAESTRO@BitcoinNostr.com, travis@west.report, coffeelover@nostrplebs.com, shadowysuperstore@shadowysuperstore.com, npub1wg2dsjnh0g7phheq23v288k0mj8x75fffmq7rghtkhv53027hnassf4w8t@nost.vip, eynhaender@nostrplebs.com, b1ackswan@nostrplebs.com, mrbitc0in@nostrplebs.com, jedi@nostrplebs.com, cloudnull@nostrplebs.com, Mrwh0@Mrwh0.github.io, shinohai@iris.to, awoi@iris.to, jcope101@nostrplebs.com, murmur@nostrplebs.com, dario@nostrplebs.com, leonawankum@BitcoinNostr.com, phil@iris.to, thatirdude@nostrplebs.com, K_hole@ketamine.com, 2@lokuyow.github.io, hazey@iris.to, HeineNon@tomottodx.github.io, titan@nostrplebs.com", state.urlSet.emails.joinToString(", "), ) - TestCase.assertEquals( + assertEquals( "", state.urlSet.withScheme.joinToString(", "), ) @@ -4046,7 +4045,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } @@ -4183,7 +4182,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } } @@ -4216,7 +4215,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } } @@ -4248,7 +4247,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } } @@ -4280,7 +4279,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } } @@ -4312,7 +4311,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } } @@ -4357,7 +4356,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } } @@ -4395,7 +4394,7 @@ class RichTextParserTest { .forEachIndexed { index, seg -> assertEquals( expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "${seg::class.simpleName!!.replace("Segment", "")}(${seg.segmentText})", ) } } @@ -4451,7 +4450,7 @@ class RichTextParserTest { paragraph.words.forEach { seg -> println( "\"${ - seg.javaClass.simpleName.replace( + seg::class.simpleName!!.replace( "Segment", "", ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt new file mode 100644 index 0000000000..596677410d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The gap predicate places the per-relay reach marker AND gates its paging sentinel, so an off-by-one + * here would either render a marker in the wrong gap or fire (or never fire) paging. The boundaries are + * deliberately asymmetric — newer side strictly `>`, older side `<=` — so each edge is pinned here. + */ +class RelayReachMarkerTest { + @Test + fun cursorStrictlyBetweenTwoMessagesIsInTheGap() { + // gap is (older=80, newer=100]; a cursor reached down to 90 sits in it. + assertTrue(reachedFallsInGap(reachedUntil = 90, newerCreatedAt = 100, olderCreatedAt = 80)) + } + + @Test + fun cursorAtTheOldestEndWithNoOlderNeighbourIsInTheGap() { + // Past the oldest loaded row (olderCreatedAt null): any cursor below the last message sits here. + assertTrue(reachedFallsInGap(reachedUntil = 50, newerCreatedAt = 100, olderCreatedAt = null)) + } + + @Test + fun noNewerRowMeansNotInThisGap() { + // newerCreatedAt null = nothing on the newer side (e.g. a non-message row) → never placed here. + assertFalse(reachedFallsInGap(reachedUntil = 50, newerCreatedAt = null, olderCreatedAt = 20)) + } + + @Test + fun newerSideIsStrictlyNewer_equalDoesNotCount() { + // The marker belongs in the gap *below* the message it reached, not at the message itself. + assertFalse(reachedFallsInGap(reachedUntil = 100, newerCreatedAt = 100, olderCreatedAt = 50)) + } + + @Test + fun olderSideIsInclusive_equalCounts() { + // older == reached: the cursor sits exactly at the older message → still this gap (`<=`). + assertTrue(reachedFallsInGap(reachedUntil = 80, newerCreatedAt = 100, olderCreatedAt = 80)) + } + + @Test + fun gapWhoseOlderNeighbourIsStillNewerThanTheCursorIsNotIt() { + // older=90 is newer than the cursor (70): the cursor lives in a deeper gap, not this one. + assertFalse(reachedFallsInGap(reachedUntil = 70, newerCreatedAt = 100, olderCreatedAt = 90)) + } + + @Test + fun cursorNewerThanTheNewerRowIsNotInThisGap() { + assertFalse(reachedFallsInGap(reachedUntil = 150, newerCreatedAt = 100, olderCreatedAt = 80)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt index 6226acc97c..e0d5791888 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt @@ -30,10 +30,10 @@ 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.nip10Notes.TextNoteEvent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue class ReplyContextTest { private val parentEventId = "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c" diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt index ee7ea2c932..23b5970edc 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.util -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals class CodePointsTest { // ---- codePointCharCount ---- @@ -44,30 +44,30 @@ class CodePointsTest { @Test fun toCharsRoundTripsAscii() { - assertArrayEquals(charArrayOf('A'), codePointToChars(0x0041)) + assertContentEquals(charArrayOf('A'), codePointToChars(0x0041)) } @Test fun toCharsRoundTripsLastBmp() { - assertArrayEquals(charArrayOf('￿'), codePointToChars(0xFFFF)) + assertContentEquals(charArrayOf('￿'), codePointToChars(0xFFFF)) } @Test fun toCharsProducesSurrogatePairForGrinningFace() { // U+1F600 (😀) is encoded as the surrogate pair (0xD83D, 0xDE00). - assertArrayEquals(charArrayOf('\uD83D', '\uDE00'), codePointToChars(0x1F600)) + assertContentEquals(charArrayOf('\uD83D', '\uDE00'), codePointToChars(0x1F600)) } @Test fun toCharsProducesSurrogatePairForFirstSupplementary() { // U+10000 -> (0xD800, 0xDC00). - assertArrayEquals(charArrayOf('\uD800', '\uDC00'), codePointToChars(0x10000)) + assertContentEquals(charArrayOf('\uD800', '\uDC00'), codePointToChars(0x10000)) } @Test fun toCharsProducesSurrogatePairForLastCodePoint() { // U+10FFFF -> (0xDBFF, 0xDFFF). - assertArrayEquals(charArrayOf('\uDBFF', '\uDFFF'), codePointToChars(0x10FFFF)) + assertContentEquals(charArrayOf('\uDBFF', '\uDFFF'), codePointToChars(0x10FFFF)) } // ---- String.codePointAtKmp ---- @@ -141,11 +141,11 @@ class CodePointsTest { for (cp in samples) { val chars = codePointToChars(cp) val asString = chars.concatToString() - assertEquals("round-trip code point U+${cp.toString(16).uppercase()}", cp, asString.codePointAtKmp(0)) + assertEquals(cp, asString.codePointAtKmp(0), "round-trip code point U+${cp.toString(16).uppercase()}") assertEquals( - "char count for U+${cp.toString(16).uppercase()}", chars.size, codePointCharCount(cp), + "char count for U+${cp.toString(16).uppercase()}", ) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt index 7a894efa85..684d38356a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt @@ -37,7 +37,7 @@ class RoomReactionsStateTest { to: String?, content: String, createdAt: Long, - id: String = "%064x".format(nextEventId++), + id: String = (nextEventId++).toString(16).padStart(64, '0'), ): ReactionEvent { val tags = buildList> { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt index eea3ffcd04..bc67bc0981 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt @@ -45,7 +45,7 @@ class RoomZapsStateTest { from: String, to: String?, createdAt: Long, - id: String = "%064x".format(nextEventId++), + id: String = (nextEventId++).toString(16).padStart(64, '0'), ): LnZapEvent { val tags = buildList> { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt new file mode 100644 index 0000000000..a38dc61050 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.relayClient.paging + +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Atomic snapshot of a [BackwardRelayPager]'s display state. Every field is recomputed together in one + * pass ([BackwardRelayPager.status]'s producer), so a consumer collects ONE flow and never sees a torn + * mix (e.g. an updated [relayCount] against a still-stale [relayProgress]) or pays for several separate + * recompositions per page settle. [BackwardRelayPager.loadingMore] is deliberately NOT folded in here: it + * is debounced on its own timer in the load tracker, decoupled from this recompute. + */ +data class PagingStatus( + // Nothing more reachable right now: every relay is done or stalled. See the pager doc — not "caught up". + val exhausted: Boolean = false, + // Relays currently fetching a page (for an "asking N relays" status line). + val relayCount: Int = 0, + // Not-done relays that can't be reached right now (auth CLOSE / unreachable / silent). + val stalledCount: Int = 0, + // Oldest `createdAt` reached across all relays (the deepest cursor), or null before any delivery. + val reachedBack: Long? = null, + // Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders. + val relayProgress: Map = emptyMap(), +) + +/** + * Reusable **per-relay backward pagination** engine: pages a set of relays back through history, + * **one page at a time, per relay, on demand**, by `until`+`limit` ([RelayLoadingCursors]) — with each + * relay advancing independently the moment *it* settles, never paced by the slowest one. + * + * This is the **single-active orchestrator** around the paging state. The state itself (the per-relay + * [RelayLoadingCursors]) does NOT live here — the caller holds it on whatever object owns the scope, so + * its lifetime matches that object. One orchestrator drives whichever scope is currently bound; calling + * [bind] repoints it at that scope's cursors. This is safe as long as the caller only advances the bound + * scope (e.g. the one the user is viewing), so a backgrounded scope produces no callbacks to mis-route. + * + * What it owns (all transient, recomputed on each [bind]): the in-flight + silence tracking + * ([PerRelayLoadTracker]), the stalled-relay set, and the display flows — one atomic [status] snapshot + * ([PagingStatus]) plus the separately-debounced [loadingMore]. The persistent cursors and the pinned + * history floor live on the bound [RelayLoadingCursors]. + * + * What it does NOT own (the caller supplies these — they are protocol- and framework-specific): + * - **Building the actual REQ filters.** The caller reads [armedRelays] + [requestedUntilFor] and + * assembles its own `RelayBasedFilter`s (the kinds / authors / `#p` tags differ per query). + * - **The subscription lifecycle.** The caller wires its `INostrClient` subscription and forwards + * relay callbacks here via [onEvent] / [onEose] / [onClosed] / [onCannotConnect], then re-issues + * its filter after [advance] / [advanceAll] return true. + * - **Which scope's cursors + which relays.** Supplied together by [bind]. + * + * ### Done vs stalled (read [exhausted] with care) + * A relay is **done** once it answers an empty page (gap-proof: nothing older). A relay that won't + * answer right now — auth CLOSE, unreachable, or silent past the tracker's window — is flagged + * **stalled** but kept (its subscription stays open; re-[advance] retries it). [exhausted] flips true + * once every relay is *done or stalled* — "nothing more reachable right now", which is NOT the same as + * "fully caught up". Callers that render a terminal state should split on [stalledCount]: `exhausted && + * stalledCount == 0` is genuinely caught up; `exhausted && stalledCount > 0` stopped early and may be + * missing messages. + * + * Not internally synchronized beyond the primitives it composes; intended to be driven from one owning + * scope with relay callbacks serialized per relay (as the relay IO layer delivers them). + */ +class BackwardRelayPager( + // Short label for the DMPagination logs (e.g. "giftwrap.history", "convo.nip04.history"). + private val name: String, + // Asked of every relay per page; large on purpose (a whole band in one page), and caps per-request + // volume. A relay returning fewer is its own cap, NOT exhaustion — only an empty page ends a relay. + val pageLimit: Int = DEFAULT_PAGE_LIMIT, + // How far below "now" the history floor sits — paging starts here and walks backward. Defaults to + // the shared live-tail boundary ([DmHistoryTuning]): everything newer is the always-on tail's job. + private val liveTailSeconds: Long = DmHistoryTuning.liveTailSeconds, +) { + private val loadTracker = PerRelayLoadTracker(name, onSilenced = ::onSilenced) + + // The active scope, set by [bind]: its persistent per-relay cursors (which live on the owning domain + // object) and the lookup for the relay set it fans out to. + @Volatile + private var cursors: RelayLoadingCursors? = null + + @Volatile + private var relaysFor: () -> Collection? = { null } + + // Relays not advancing for the active scope (auth CLOSE / unreachable / silent). Transient: cleared + // and recomputed on each [bind]; a stalled relay is kept (its sub stays open) and retried on advance. + private val stalledRelays = ConcurrentHashMap.newKeySet() + + /** + * True while any relay is mid-page. Starts false (an idle engine isn't "loading"). Kept apart from + * [status] on purpose: the load tracker debounces this flow's falling edge on its own timer, decoupled + * from the [publishStatus] recompute, so folding it into the snapshot would miss that delayed flip. + */ + val loadingMore: StateFlow = loadTracker.loading + + private val _status = MutableStateFlow(PagingStatus()) + + /** One atomic snapshot of the display state (exhausted / counts / reached / per-relay progress), all + * recomputed together in [publishStatus] so consumers collect ONE flow and never see a torn mix. */ + val status: StateFlow = _status.asStateFlow() + + // The session-pinned floor for the active scope — kept on its cursors so it persists with the scope + // and does not drift forward on recompute (which would re-trigger an undelivered relay's loader). + private fun floor(): Long { + val c = cursors ?: return TimeUtils.now() - liveTailSeconds + return c.floor ?: (TimeUtils.now() - liveTailSeconds).also { c.floor = it } + } + + /** + * Repoints to a scope (call on subscribe / when the active scope changes): its persistent + * [scopeCursors] (held on the caller's scope object), the [scope] for the silence watchdog, and the + * [relaysForScope] lookup. Resets the transient orchestration (in-flight, stalled) and recomputes + * the display flows from the bound cursors — so a previously-paged scope restores its progress + * instead of restarting. + */ + fun bind( + scopeCursors: RelayLoadingCursors, + scope: CoroutineScope, + relaysForScope: () -> Collection?, + ) { + cursors = scopeCursors + relaysFor = relaysForScope + loadTracker.bind(scope) + loadTracker.reset() + stalledRelays.clear() + publishStatus() + } + + /** + * Whether [c] is the currently-bound scope's cursor object. The orchestrator is single-active: it can + * only correctly process callbacks for the bound scope. A caller whose subscription may still be alive + * for a *just-backgrounded* scope (navigation overlap, a second pane) must gate its forwarded callbacks + * on this — otherwise a late EOSE from scope A would move scope B's cursors. The cursor object is the + * scope identity (one per `Chatroom`/`ChatroomList`), so reference identity is the check. + */ + fun isBoundTo(c: RelayLoadingCursors): Boolean = c === cursors + + // --- Filter building support: the caller assembles the actual REQ from these. --- + + /** Relays of the active scope that have been advanced (armed) and aren't done — i.e. carry a REQ. */ + fun armedRelays(relays: Collection): List = cursors?.armedRelays(relays) ?: emptyList() + + /** The `until` [relay]'s next page should carry (null if it isn't armed / no scope bound). */ + fun requestedUntilFor(relay: NormalizedRelayUrl): Long? = cursors?.requestedUntilFor(relay) + + // --- Demand-driven advance (the caller re-issues its filter when these return true). --- + + /** Steps a single [relay] to its next, older page. @return true if it actually advanced. */ + fun advance(relay: NormalizedRelayUrl): Boolean { + if (!arm(relay)) return false + publishStatus() + return true + } + + /** Steps every not-done, not-in-flight relay of the active scope one page. For a scope too small to scroll. */ + fun advanceAll(): Boolean { + val relays = relaysFor() ?: return false + var any = false + relays.forEach { if (arm(it)) any = true } + if (any) publishStatus() + return any + } + + // Moves one relay's cursor to its next page and marks it in-flight. Returns false if it can't advance + // (no scope bound, unknown relay, already fetching, or already done). Caller batches the recompute. + private fun arm(relay: NormalizedRelayUrl): Boolean { + val c = cursors ?: return false + val relays = relaysFor() ?: return false + if (relay !in relays) return false + if (loadTracker.isInFlight(relay)) return false + if (!c.advance(relay, floor())) return false + stalledRelays.remove(relay) + loadTracker.onAdvance(relay) + return true + } + + // --- Subscription callbacks: the owner forwards these from its SubscriptionListener. --- + + /** Records one delivered event for [relay] (a sign of life + a page tally entry). */ + fun onEvent( + relay: NormalizedRelayUrl, + createdAt: Long, + ) { + loadTracker.onActivity() + cursors?.onEvent(relay, createdAt) + stalledRelays.remove(relay) + } + + /** Finalizes [relay]'s page on EOSE. @return true if this EOSE is the one that marked it done. */ + fun onEose(relay: NormalizedRelayUrl): Boolean { + val c = cursors ?: return false + stalledRelays.remove(relay) + c.onEose(relay) + loadTracker.onSettled(relay) + val done = c.isDone(relay) + publishStatus() + return done + } + + /** [relay] rejected the REQ (e.g. auth-required): settle it and flag it stalled (kept, retryable). */ + fun onClosed( + relay: NormalizedRelayUrl, + message: String, + ) { + loadTracker.onSettled(relay) + markStalled(relay, "CLOSED: $message") + publishStatus() + } + + /** [relay] is unreachable right now: settle it and flag it stalled (kept, retryable). */ + fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + ) { + loadTracker.onSettled(relay) + markStalled(relay, "cannot connect: $message") + publishStatus() + } + + // The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag them + // stalled but kept, so the window can settle instead of hanging on a dead relay. + private fun onSilenced(relays: Set) { + relays.forEach { markStalled(it, "no response (silence timeout)") } + publishStatus() + } + + private fun markStalled( + relay: NormalizedRelayUrl, + reason: String, + ) { + if (stalledRelays.add(relay)) Log.d(TAG) { "[$name] ${relay.url} stalled — $reason (kept, advance to retry)" } + } + + // --- Display-state recompute (one atomic snapshot from the bound cursors). --- + + /** + * Recomputes the whole [status] snapshot from the active scope's cursors and publishes it in one + * emission, so consumers never see a torn mix of fields nor pay for several recompositions per settle. + * + * `exhausted` is computed here too: nothing more is reachable once every relay is done (empty page) or + * stalled (unreachable) — a merely parked relay (more to load, just not advancing) keeps it false. An + * empty / unbound scope leaves `exhausted` at its previous value (mirrors the old recompute's + * early-return), so a transient empty relay set never flips it spuriously. + */ + private fun publishStatus() { + val c = cursors + val relays = relaysFor() ?: emptySet() + val floor = floor() + val prev = _status.value + val exhausted = + if (c == null || relays.isEmpty()) { + prev.exhausted + } else { + relays.none { !c.isDone(it) && it !in stalledRelays } + } + if (exhausted && !prev.exhausted && c != null) { + val done = relays.filter { c.isDone(it) }.map { it.url } + val stuck = relays.filter { it in stalledRelays && !c.isDone(it) }.map { it.url } + Log.d(TAG) { "[$name] window settled (nothing more reachable) — done=$done stalled=$stuck" } + } + _status.value = + PagingStatus( + exhausted = exhausted, + relayCount = loadTracker.count(), + stalledCount = relays.count { it in stalledRelays && c?.isDone(it) != true }, + reachedBack = c?.deepestReached(relays, floor), + relayProgress = + relays.associateWith { relay -> + RelayPagingProgress( + reachedUntil = c?.reachedUntilFor(relay, floor) ?: floor, + done = c?.isDone(relay) ?: false, + stalled = relay in stalledRelays && c?.isDone(relay) != true, + ) + }, + ) + } + + companion object { + private const val TAG = "DMPagination" + + const val DEFAULT_PAGE_LIMIT = 10000 + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt new file mode 100644 index 0000000000..732b6d1917 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.relayClient.paging + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +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.util.concurrent.ConcurrentHashMap + +/** + * Tracks which relays currently have a demand-driven history page **in flight**, so a caller can show a + * spinner while any relay is fetching and clear it the moment they've all answered (or parked). Unlike + * [WindowLoadTracker] this has no notion of a "window" or "round" — relays are advanced one page at a + * time, independently, on demand by the caller, so completion is simply "nothing in flight." + * + * A single backstop covers a relay that accepts a REQ and then goes silent (auth-walled / dead): if + * nothing has been heard from ANY in-flight relay for [silenceMs], the still-pending relays are dropped + * from the in-flight set (so the spinner clears) and reported to [onSilenced] so the owner can flag them + * stalled. Relays that answer with CLOSED / cannot-connect are settled directly by the owner and don't + * need the watchdog. + */ +class PerRelayLoadTracker( + private val name: String, + // How long the whole in-flight cohort can go without ANY signal before the still-pending relays are + // dropped + reported stalled. lastActivityMs is global, so any relay delivering keeps it fresh for all + // — this only fires on total dead air. Set well above mobile-over-Tor connect times (which run tens of + // seconds, occasionally past a minute): at 15 s a relay was being flagged "stalled" before its circuit + // even finished connecting. A genuinely dead relay still settles via CLOSED/cannot-connect, not this. + private val silenceMs: Long = 60_000L, + private val onSilenced: (Set) -> Unit = {}, +) { + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading.asStateFlow() + + private val inFlight = ConcurrentHashMap.newKeySet() + + @Volatile + private var lastActivityMs = 0L + + @Volatile + private var watchdog: Job? = null + + // Delays dropping the spinner so back-to-back pages don't flicker it off between each one. + @Volatile + private var clearJob: Job? = null + + @Volatile + private var scope: CoroutineScope? = null + + fun bind(scope: CoroutineScope) { + this.scope = scope + } + + fun isInFlight(relay: NormalizedRelayUrl) = inFlight.contains(relay) + + fun count() = inFlight.size + + /** A relay's next page was just requested. Raises the spinner and (re)arms the silence watchdog. */ + @Synchronized + fun onAdvance(relay: NormalizedRelayUrl) { + clearJob?.cancel() // a new page is starting — keep the spinner up, no flicker + clearJob = null + inFlight.add(relay) + lastActivityMs = TimeUtils.nowMillis() + _loading.value = true + ensureWatchdog() + } + + /** A sign of life from a relay (an event). Keeps the silence watchdog from firing. */ + fun onActivity() { + lastActivityMs = TimeUtils.nowMillis() + } + + /** + * A relay answered (EOSE / CLOSED / cannot-connect). Drops it from in-flight. When the last one + * settles, the spinner is dropped after a short linger rather than immediately, so a relay paging + * page-after-page (each page settles, then the caller advances the next) keeps a steady spinner instead + * of flickering it off for the few ms between pages. The linger is cancelled the moment a new page + * starts ([onAdvance]). + */ + @Synchronized + fun onSettled(relay: NormalizedRelayUrl) { + lastActivityMs = TimeUtils.nowMillis() + if (inFlight.remove(relay) && inFlight.isEmpty()) scheduleClear() + } + + private fun scheduleClear() { + clearJob?.cancel() + val s = scope + if (s == null) { + _loading.value = false + return + } + clearJob = + s.launch { + delay(LOADING_LINGER_MS) + synchronized(this@PerRelayLoadTracker) { + if (inFlight.isEmpty()) _loading.value = false + } + } + } + + /** Drops everything (e.g. the bound scope switched). */ + @Synchronized + fun reset() { + inFlight.clear() + clearJob?.cancel() + clearJob = null + _loading.value = false + watchdog?.cancel() + watchdog = null + } + + private fun ensureWatchdog() { + if (watchdog?.isActive == true) return + val s = scope ?: return + watchdog = + s.launch { + while (isActive) { + delay(WATCHDOG_TICK_MS) + val silenced = + synchronized(this@PerRelayLoadTracker) { + if (inFlight.isNotEmpty() && TimeUtils.nowMillis() - lastActivityMs > silenceMs) { + val pending = inFlight.toSet() + inFlight.clear() + _loading.value = false + pending + } else { + emptySet() + } + } + if (silenced.isNotEmpty()) { + Log.d(TAG) { "[$name] silenced (no response ${silenceMs}ms): ${silenced.map { it.url }}" } + onSilenced(silenced) + } + if (inFlight.isEmpty()) break + } + } + } + + companion object { + private const val TAG = "DMPagination" + private const val WATCHDOG_TICK_MS = 1_000L + + // How long to keep the spinner up after the last page settles, to bridge the gap to the next + // back-to-back page so the card doesn't flicker between every page. + private const val LOADING_LINGER_MS = 600L + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt new file mode 100644 index 0000000000..345fe58f18 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.relayClient.paging + +import com.vitorpamplona.quartz.nip01Core.core.Event +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.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +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.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Tracks when one relay-subscription "window" has finished loading, so a caller (e.g. an auto-fill / + * pagination loop) can wait for the WHOLE response instead of declaring victory on the first EOSE. + * + * A subscription fans a single REQ out to several relays. The first EOSE is a misleading "done" + * signal: a fast but near-empty relay can EOSE in milliseconds while the relay that actually holds + * the data is still connecting, stuck in an auth handshake, or busy streaming thousands of stored + * events. An auto-fill loop driven by the first EOSE — or by a fixed wall-clock timeout — would + * widen the window again mid-stream, before the events were even processed, re-issuing an ever-wider + * REQ that re-downloads the whole history over and over. + * + * Completion is therefore **per-relay terminal-state** based, not wall-clock based. A relay is + * *settled* once it answers with a terminal signal — an EOSE (stored backfill done), a CLOSED (it + * rejected the REQ, e.g. `auth-required`), or a cannot-connect (it is unreachable). The load is done + * when **every targeted relay has settled** ([settled] ⊇ [expected]). This is the only signal that + * survives the real world: relays connect over a wide spread (tens of seconds on mobile), and some + * answer only with CLOSED — a quiet-time heuristic fires in the gap between two relays connecting and + * mistakes a half-loaded window for a finished one, which is exactly how a load reports "1 event" + * when a hundred are still on the way. + * + * Two backstops cover misbehaving relays. If every relay we're still waiting on has at least been + * *heard from* (any event, EOSE, CLOSED, or cannot-connect) but one streamed events without ever + * sending EOSE, an [idleTimeout] of quiet completes the load — the "heard from" gate is what keeps + * this from firing in a connection gap. And an [absoluteCap] is the final ceiling on a window that + * somehow defeats the above. + */ +class WindowLoadTracker( + // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). + private val name: String = "dm", + private val idleTimeout: Duration = 3.seconds, + private val absoluteCap: Duration = 5.minutes, +) { + private val _loading = MutableStateFlow(true) + val loading: StateFlow = _loading.asStateFlow() + + // Relays the current REQ was sent to. Volatile: written on IO (updateFilter), read on the + // listener threads and the watchdog. + @Volatile + private var expected: Set = emptySet() + + // Relays that have produced any signal at all (event / EOSE / CLOSED / cannot-connect). The idle + // backstop only arms once this covers [expected], so a still-connecting relay can't be skipped. + private val heardFrom = ConcurrentHashMap.newKeySet() + + // Relays that reached a terminal signal (EOSE / CLOSED / cannot-connect). When this covers + // [expected] the stored backfill is complete on every relay and the load is done. + private val settled = ConcurrentHashMap.newKeySet() + + private var watchdog: Job? = null + + // Incremented on every (re)start so a stale watchdog that wakes right as a new load begins + // recognizes it has been superseded and bows out instead of completing the new window. + private var generation = 0 + + // Wall-clock of the last signal for the current window; the idle backstop completes the window + // once this stops advancing for [idleTimeout]. Volatile so the hot per-event path stays lock-free. + @Volatile + private var lastActivityMs = 0L + + /** Begins a fresh window load: clears the per-relay sets, raises [loading], and arms the watchdog. */ + @Synchronized + fun startLoading(scope: CoroutineScope) { + val gen = ++generation + expected = emptySet() + heardFrom.clear() + settled.clear() + lastActivityMs = TimeUtils.nowMillis() + val wasLoading = _loading.value + _loading.value = true + Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } + watchdog?.cancel() + watchdog = + scope.launch { + val deadline = TimeUtils.nowMillis() + absoluteCap.inWholeMilliseconds + while (isActive) { + delay(IDLE_CHECK_MS) + if (!tick(gen, TimeUtils.nowMillis(), deadline)) break + } + } + } + + // One watchdog poll. Returns false (stop polling) when this watchdog has been superseded by a + // newer load, the window already finished, or a completion deadline is reached. Synchronized so + // the generation/loading checks and the completion are atomic against startLoading/finish. + @Synchronized + private fun tick( + gen: Int, + now: Long, + deadline: Long, + ): Boolean { + if (gen != generation || !_loading.value) return false + if (expected.isNotEmpty()) { + // Once every relay has reached a terminal signal, nothing more is coming for this round. + if (settled.containsAll(expected)) { + finish("settled") + return false + } + // Idle backstop: every relay we're still waiting on has at least streamed something (so this + // isn't a connection gap) and the stream has gone quiet. Settled relays don't count. + val stillWaiting = expected.filterNot { settled.contains(it) } + if (stillWaiting.all { heardFrom.contains(it) } && now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { + finish("idle") + return false + } + } + if (now >= deadline) { + finish("cap") + return false + } + return true + } + + /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ + @Synchronized + fun setExpectedRelays(relays: Set) { + expected = relays + if (relays.isEmpty()) { + finish("no relays") + } else if (settled.containsAll(relays)) { + finish("all relays") + } + } + + /** A non-terminal sign of life from [relay] (a stored or live event). Keeps the idle timer alive. */ + fun onRelayEvent(relay: NormalizedRelayUrl) { + heardFrom.add(relay) + lastActivityMs = TimeUtils.nowMillis() + } + + /** + * A terminal signal from [relay] — EOSE, CLOSED, or cannot-connect. Once every expected relay has + * settled the stored backfill is complete and the load finishes. + */ + @Synchronized + fun onRelaySettled(relay: NormalizedRelayUrl) { + lastActivityMs = TimeUtils.nowMillis() + heardFrom.add(relay) + settled.add(relay) + if (expected.isNotEmpty() && settled.containsAll(expected)) finish("all relays") + } + + // Idempotent: only the first call after a load actually completes (and logs); later calls no-op. + @Synchronized + private fun finish(reason: String) { + if (!_loading.value) return + watchdog?.cancel() + watchdog = null + Log.d(TAG) { "[$name] load done: $reason" } + _loading.value = false + } + + companion object { + private const val TAG = "DMPagination" + private const val IDLE_CHECK_MS = 500L + } +} + +/** + * Builds the standard [SubscriptionListener] that feeds this tracker. Every event (stored backfill + * included) is a non-terminal sign of life from its relay; an EOSE, CLOSED, or cannot-connect settles + * that relay. [forward] carries the EOSE / live-event signal so the owning EOSE manager can record the + * relay's timestamp (its usual `newEose`). + */ +fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List?) -> Unit): SubscriptionListener = + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelaySettled(relay) + forward(relay, forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelayEvent(relay) + if (isLive) { + forward(relay, forFilters) + } + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelaySettled(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + onRelaySettled(relay) + } + } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/AmethystTempDir.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/AmethystTempDir.kt new file mode 100644 index 0000000000..701ea3eb7c --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/AmethystTempDir.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.service.upload + +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.TimeUnit +import kotlin.io.path.createTempFile +import kotlin.io.path.exists + +/** + * Per-user temporary directory at `~/.amethyst/tmp/`, mode 0700 on + * POSIX systems. Used by the image compression pipeline so: + * + * - tmpfs-mounted `/tmp` on Linux doesn't OOM under a 50 MP HEIC + * decode (a 600 MB intermediate easily exceeds a small VM's + * tmpfs allotment), + * - shared multi-user systems can't race on a predictably-named temp + * file in world-readable `/tmp`, + * - the boot-time sweep has a strict ownership claim — anything in + * this directory belongs to us. + * + * The directory may be overridden via `-Damethyst.tmp.dir=PATH`, + * useful for tests. + */ +object AmethystTempDir { + private const val PROP_OVERRIDE = "amethyst.tmp.dir" + private const val ORPHAN_MAX_AGE_HOURS = 24L + + private val root: File by lazy { + resolveRoot().also { + ensure(it) + // First access in this JVM session — sweep amethyst_* files + // older than ORPHAN_MAX_AGE_HOURS. Recovers temp space after + // a JVM crash that skipped shutdown hooks. Idempotent. + sweepOrphansAt(it) + } + } + + /** + * Create a temp file under the per-user temp dir. Names follow + * `amethyst__.` so the boot-time sweep can + * recognize ownership. + */ + fun createTempFile( + prefix: String, + suffix: String, + ): File { + require(prefix.startsWith("amethyst_")) { + "Temp file prefix must start with 'amethyst_' for sweep ownership; got '$prefix'" + } + return createTempFile(root.toPath(), prefix, suffix).toFile() + } + + /** Path to the per-user temp dir. Test-friendly accessor. */ + fun rootDir(): File = root + + /** + * Delete `amethyst_*` files older than [ORPHAN_MAX_AGE_HOURS] hours. + * Called once at app startup to clean up after JVM crashes that + * skipped shutdown hooks. Returns the number of files removed. + */ + fun sweepOrphans(): Int = sweepOrphansAt(root) + + private fun sweepOrphansAt(dir: File): Int { + val cutoff = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(ORPHAN_MAX_AGE_HOURS) + var removed = 0 + dir.listFiles()?.forEach { f -> + if (f.isFile && f.name.startsWith("amethyst_") && f.lastModified() < cutoff) { + if (f.delete()) removed++ + } + } + return removed + } + + private fun resolveRoot(): File { + System.getProperty(PROP_OVERRIDE)?.let { return File(it) } + return File(System.getProperty("user.home"), ".amethyst/tmp") + } + + private fun ensure(dir: File) { + val path = dir.toPath() + if (!path.exists()) Files.createDirectories(path) + // POSIX: lock to owner-only. Best-effort on Windows (no equivalent). + try { + Files.setPosixFilePermissions( + path, + PosixFilePermissions.fromString("rwx------"), + ) + } catch (_: UnsupportedOperationException) { + // Windows / non-POSIX FS — leave default ACLs. + } catch (_: SecurityException) { + // Sandbox or unusual perms — best effort only. + } + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt index 2901a94f14..2ce416f5fe 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -40,10 +40,10 @@ import java.io.File * The default constructor uses a fresh OkHttpClient — fine for one-shot * uses such as the CLI. */ -class BlossomClient( +open class BlossomClient( private val okHttpClient: OkHttpClient = OkHttpClient(), ) { - suspend fun upload( + open suspend fun upload( file: File, contentType: String, serverBaseUrl: String, @@ -84,7 +84,7 @@ class BlossomClient( /** * Upload raw bytes (e.g. encrypted blobs) to a Blossom server. */ - suspend fun upload( + open suspend fun upload( bytes: ByteArray, contentType: String, serverBaseUrl: String, diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionException.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionException.kt new file mode 100644 index 0000000000..bfb74a0184 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionException.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.commons.service.upload + +/** + * Typed failures from the image-compression pipeline. The fail-loud + * dialog renders [message] directly; subclass identity drives the + * "Send Original" decision (e.g., InputTooLarge → user can still + * bypass; UnsupportedFormat → bypass uploads raw bytes). + * + * `Exception(message, cause)` already wires `cause` into the + * `Throwable.cause` slot — DO NOT call `initCause` again, doing so + * throws `IllegalStateException("Can't overwrite cause …")` at + * construction time. + */ +sealed class CompressionException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { + /** Source format has no usable decoder in v1 (AVIF, HEIC). */ + class UnsupportedFormat( + val format: String, + ) : CompressionException("Format not supported: $format") + + /** + * Source pixel count exceeds [ImageReencoder.MAX_INPUT_PIXELS] + * (50 megapixels by default). Guards against memory-bomb inputs. + */ + class InputTooLarge( + val pixels: Long, + val limit: Long, + ) : CompressionException("Image has $pixels pixels — exceeds the $limit limit") + + /** Decoder or encoder threw — wrap the cause. */ + class EncodeFailed( + cause: Throwable, + ) : CompressionException("Image encode failed: ${cause.message ?: cause::class.simpleName}", cause) +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionQuality.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionQuality.kt new file mode 100644 index 0000000000..be124e6e15 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionQuality.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.service.upload + +/** + * Image compression quality presets. JPEG quality values are tuned for + * 2026 display densities — the 2014-era Android values (q=0.40/0.50/0.80) + * produce visible blocking on modern displays. + * + * @property displayName Human-readable label for settings UI and the + * per-post override chip. + * @property maxDim Maximum width OR height in pixels for the encoded + * output. The image keeps its aspect ratio; the longer edge clamps to + * this value. ImageReencoder enforces "never upscale": if both source + * dimensions are already within this box, the source dims are kept + * and only the JPEG re-encode runs. + * @property jpegQuality JPEG quality factor in [0.0, 1.0] passed to + * `javax.imageio.ImageWriteParam.setCompressionQuality(...)`. + */ +enum class CompressionQuality( + val displayName: String, + val maxDim: Int, + val jpegQuality: Float, + /** + * Short subtitle that explains what the preset gives you. Surfaced + * under the segmented row in settings and under each row in the + * per-post override dropdown. + */ + val summary: String, +) { + LOW("Low", 640, 0.65f, "640 px · smallest files, best for slow uplinks"), + MEDIUM("Medium", 640, 0.75f, "640 px · balanced size and quality"), + HIGH("High", 640, 0.85f, "640 px · visually lossless on phones"), + DESKTOP_HIGH("Desktop High", 1920, 0.90f, "1920 px · best for desktop displays"), + ; + + /** Compact label suitable for a chip: "Medium (640 px)". */ + val chipLabel: String get() = "$displayName ($maxDim px)" + + companion object { + /** Default for first-install. */ + val DEFAULT: CompressionQuality = DESKTOP_HIGH + + /** Round-trip from a stored preference value. Falls back to [DEFAULT]. */ + fun fromName(name: String?): CompressionQuality = entries.firstOrNull { it.name == name } ?: DEFAULT + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormat.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormat.kt new file mode 100644 index 0000000000..ab5f2a46ec --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormat.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +/** + * Container image format produced by [ImageFormatSniffer]. The + * sniffer's job is to decide whether a payload re-encodes through the + * pipeline, passes through byte-identical, or refuses upload entirely. + */ +sealed class ImageFormat { + /** MIME type for the upload payload. */ + abstract val mimeType: String + + object Jpeg : ImageFormat() { + override val mimeType = "image/jpeg" + } + + object Png : ImageFormat() { + override val mimeType = "image/png" + } + + object Bmp : ImageFormat() { + override val mimeType = "image/bmp" + } + + object Tiff : ImageFormat() { + override val mimeType = "image/tiff" + } + + /** + * Animated GIFs must pass through byte-identical — ImageIO would + * only encode the first frame. + */ + data class Gif( + val animated: Boolean, + ) : ImageFormat() { + override val mimeType = "image/gif" + } + + /** + * Animated WebPs must pass through byte-identical. Static WebPs + * decode via stock ImageIO (limited) — for v1 they pass through + * unchanged since there is no pure-Java WebP encoder. + */ + data class WebP( + val animated: Boolean, + ) : ImageFormat() { + override val mimeType = "image/webp" + } + + /** Vector format — pass through unchanged. */ + object Svg : ImageFormat() { + override val mimeType = "image/svg+xml" + } + + /** Detected but refused — no pure-Java decoder exists in 2026. */ + object Avif : ImageFormat() { + override val mimeType = "image/avif" + } + + /** Detected but refused — no pure-Java decoder exists in 2026. */ + object Heic : ImageFormat() { + override val mimeType = "image/heic" + } + + /** Magic bytes did not match any known format. */ + data class Unknown( + override val mimeType: String, + ) : ImageFormat() +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSniffer.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSniffer.kt new file mode 100644 index 0000000000..33b186d612 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSniffer.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +import java.io.File + +/** + * Magic-byte sniffer for image upload payloads. Detection runs on a + * small header prefix — full file decode is left to ImageReencoder. + * + * For containers that can be either still or animated (GIF, WebP) the + * sniffer scans deeper into the bytes to set the `animated` flag, so + * the orchestrator can short-circuit to byte-identical pass-through + * for animations (ImageIO would otherwise encode only frame 0). + */ +object ImageFormatSniffer { + /** Bytes from the start of the file used to sniff. */ + private const val PREFIX_BYTES = 8192 + + fun sniff(file: File): ImageFormat { + if (!file.exists() || file.length() == 0L) return ImageFormat.Unknown("application/octet-stream") + val prefix = + file.inputStream().use { input -> + val buf = ByteArray(minOf(PREFIX_BYTES.toLong(), file.length()).toInt()) + var read = 0 + while (read < buf.size) { + val n = input.read(buf, read, buf.size - read) + if (n < 0) break + read += n + } + if (read == buf.size) buf else buf.copyOf(read) + } + return sniff(prefix) + } + + fun sniff(bytes: ByteArray): ImageFormat { + if (bytes.size < 4) return ImageFormat.Unknown("application/octet-stream") + + // JPEG: FF D8 FF + if (bytes.startsWith(0xFF, 0xD8, 0xFF)) return ImageFormat.Jpeg + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (bytes.startsWith(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) return ImageFormat.Png + + // GIF: "GIF87a" or "GIF89a". Animation detected via NETSCAPE2.0 + // application extension (animated GIFs almost always carry it). + if (bytes.startsWith('G', 'I', 'F', '8') && bytes.size >= 6 && + (bytes[4] == '7'.code.toByte() || bytes[4] == '9'.code.toByte()) && + bytes[5] == 'a'.code.toByte() + ) { + return ImageFormat.Gif(animated = bytes.containsAscii("NETSCAPE2.0")) + } + + // BMP: "BM" + if (bytes.startsWith('B', 'M')) return ImageFormat.Bmp + + // TIFF: II*\0 (little-endian) or MM\0* (big-endian) + if (bytes.startsWith(0x49, 0x49, 0x2A, 0x00) || bytes.startsWith(0x4D, 0x4D, 0x00, 0x2A)) { + return ImageFormat.Tiff + } + + // RIFF container: WebP. "RIFF....WEBP". Animation requires VP8X + // chunk (byte after 'VP8X' tag, then 4-byte chunk size, then a + // single flags byte whose bit 1 = animation, OR the presence of + // an 'ANIM' chunk somewhere in the prefix. + if (bytes.size >= 12 && bytes.startsWith('R', 'I', 'F', 'F') && + bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() && + bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() + ) { + val hasAnim = bytes.containsAscii("ANIM") || webpHasAnimationFlag(bytes) + return ImageFormat.WebP(animated = hasAnim) + } + + // ISO Base Media File Format (ftyp box). Used by HEIC, AVIF. + // Layout: 4-byte size, "ftyp", 4-byte major brand, 4-byte minor + // version, then compatible brands. + if (bytes.size >= 12 && bytes[4] == 'f'.code.toByte() && bytes[5] == 't'.code.toByte() && + bytes[6] == 'y'.code.toByte() && bytes[7] == 'p'.code.toByte() + ) { + val brand = String(bytes, 8, 4) + return when (brand) { + "avif", "avis" -> ImageFormat.Avif + "heic", "heix", "heim", "heis", "hevc", "hevm", "hevs", "mif1", "msf1" -> ImageFormat.Heic + else -> ImageFormat.Unknown("application/octet-stream") + } + } + + // SVG: optional XML prolog then "= 3 && bytes[0] == 0xEF.toByte() && + bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte() + ) { + 3 + } else { + 0 + } + val text = + String(bytes, bomLen, minOf(bytes.size - bomLen, 1024), Charsets.US_ASCII) + .trimStart(' ', '\n', '\r', '\t') + if (text.startsWith(" this.size) return false + outer@ for (i in 0..this.size - needle.length) { + for (j in needle.indices) { + if (this[i + j] != needle[j].code.toByte()) continue@outer + } + return true + } + return false + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt new file mode 100644 index 0000000000..fbdc94c912 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.service.upload + +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.EncodeFailed +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.InputTooLarge +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.UnsupportedFormat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import net.coobird.thumbnailator.Thumbnails +import java.awt.color.ColorSpace +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.IIOImage +import javax.imageio.ImageIO +import javax.imageio.ImageWriteParam +import javax.imageio.stream.FileImageOutputStream +import kotlin.coroutines.coroutineContext + +/** + * Re-encode + downscale step of the desktop image upload pipeline. + * + * Caller contract (see [UploadOrchestrator]): + * - `Reencoded` → caller uploads the returned file and owns its cleanup. + * - `PassThrough` → caller uploads the *original* file unchanged. + * - On `CompressionException` → caller surfaces the fail-loud dialog + * (`InputTooLarge` / `UnsupportedFormat` / `EncodeFailed`). + * + * The reencoder is serial: only one image is decoded + encoded at any + * given time, regardless of how many callers run in parallel. This + * keeps the peak heap footprint to a single in-flight BufferedImage. + */ +object ImageReencoder { + private const val DEFAULT_MAX_INPUT_PIXELS = 50L * 1_000_000L + + /** + * Refuse inputs above this pixel count to defend against memory + * bombs (a malicious header claiming 99999×99999 would otherwise + * cause ImageIO to attempt a ~40 GB allocation). + * + * Tunable via `-Damethyst.compression.maxPixels=N`; queried on + * every call so tests can override per-test via a system property. + */ + val maxInputPixels: Long + get() = + System.getProperty("amethyst.compression.maxPixels")?.toLongOrNull() + ?: DEFAULT_MAX_INPUT_PIXELS + + /** + * CPU-bound work runs on `Default` (not `IO`). The serial cap + * keeps memory bounded — only one BufferedImage in flight at any + * given moment, regardless of how many callers fan in. + */ + @Suppress("OPT_IN_USAGE") + private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1) + + /** + * Re-encode a file from disk. + * + * @throws CompressionException on any failure that the caller + * should surface in a fail-loud dialog. + */ + suspend fun reencode( + source: File, + quality: CompressionQuality, + ): ReencodeResult = + withContext(compressionDispatcher) { + coroutineContext.ensureActive() + val format = ImageFormatSniffer.sniff(source) + // 1) Decide between pass-through, refuse, or re-encode. + when (format) { + is ImageFormat.Gif -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated) + is ImageFormat.WebP -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated) + ImageFormat.Svg -> return@withContext ReencodeResult.PassThrough(PassReason.Vector) + ImageFormat.Avif -> throw UnsupportedFormat("avif") + ImageFormat.Heic -> throw UnsupportedFormat("heic") + is ImageFormat.Unknown -> return@withContext ReencodeResult.PassThrough(PassReason.NotAnImage) + else -> { /* fall through to re-encode */ } + } + // 2) Decode with the pre-decode pixel guard + subsampling. + coroutineContext.ensureActive() + val img = decodeWithGuard(source, quality.maxDim) + // 3) Resize only when source exceeds the target box — + // Thumbnailator otherwise upscales by default. + coroutineContext.ensureActive() + val finalImage = + if (img.width > quality.maxDim || img.height > quality.maxDim) { + Thumbnails + .of(img) + .size(quality.maxDim, quality.maxDim) + .asBufferedImage() + } else { + img + } + // 4) Encode JPEG to a temp file, preserving ICC where possible. + coroutineContext.ensureActive() + val temp = AmethystTempDir.createTempFile("amethyst_compress_", ".jpg") + try { + encodeJpeg(finalImage, temp, quality.jpegQuality) + ReencodeResult.Reencoded(temp) + } catch (t: Throwable) { + withContext(NonCancellable) { temp.delete() } + throw if (t is CompressionException) t else EncodeFailed(t) + } + } + + /** + * Decode `source` into a `BufferedImage`, refusing inputs above + * [maxInputPixels] before any pixel buffer is allocated. When the + * source is larger than `targetMaxDim`, decode at a sub-sampled + * stride so we don't allocate the full-resolution buffer just to + * downscale it. + */ + private fun decodeWithGuard( + source: File, + targetMaxDim: Int, + ): BufferedImage { + ImageIO.createImageInputStream(source).use { iis -> + requireNotNull(iis) { "Could not open image input stream" } + val reader = + ImageIO.getImageReaders(iis).let { + if (!it.hasNext()) throw UnsupportedFormat("no ImageIO reader for this input") + it.next() + } + reader.input = iis + try { + val w = reader.getWidth(0) + val h = reader.getHeight(0) + val pixels = w.toLong() * h.toLong() + val cap = maxInputPixels + if (pixels > cap) throw InputTooLarge(pixels, cap) + val param = reader.defaultReadParam + val downscaleStride = subsamplingStride(w, h, targetMaxDim) + if (downscaleStride > 1) { + param.setSourceSubsampling(downscaleStride, downscaleStride, 0, 0) + } + return reader.read(0, param) + } finally { + reader.dispose() + } + } + } + + /** + * Compute the per-axis subsampling stride. Subsampling is a + * memory optimization — Thumbnailator does the final precise + * resize. Use `floor` so the post-subsampling image stays ABOVE + * the target, leaving headroom for Thumbnailator to land + * exactly on `targetMaxDim`. A `ceil` would drop us below the + * target and Thumbnailator could not recover. + * + * Example: 4032 × 3024 with target 1920 → stride 2 → decodes to + * 2016 × 1512, then Thumbnailator resizes precisely to 1920 × + * 1440. + */ + private fun subsamplingStride( + w: Int, + h: Int, + targetMaxDim: Int, + ): Int { + val longestEdge = maxOf(w, h) + if (longestEdge <= targetMaxDim) return 1 + return (longestEdge / targetMaxDim).coerceAtLeast(1) + } + + /** + * Encode `image` as JPEG into `out` at the given `quality` factor. + * + * JPEG can only write 3-channel RGB. Inputs decoded as + * TYPE_INT_ARGB (most PNGs), TYPE_BYTE_GRAY, TYPE_CUSTOM (CMYK + * JPEGs, indexed PNGs, exotic colorspaces) all blow up the stock + * `JPEGImageWriter` with `Bogus input colorspace`. Force a + * canonical `TYPE_INT_RGB` BufferedImage via [toRgbCanvas] before + * handing off to the writer. + */ + private fun encodeJpeg( + image: BufferedImage, + out: File, + quality: Float, + ) { + val rgb = if (image.type == BufferedImage.TYPE_INT_RGB) image else toRgbCanvas(image) + val writer = + ImageIO.getImageWritersByMIMEType("image/jpeg").let { + if (!it.hasNext()) throw EncodeFailed(IllegalStateException("no JPEG writer registered")) + it.next() + } + try { + FileImageOutputStream(out).use { stream -> + writer.output = stream + val param = + writer.defaultWriteParam.apply { + compressionMode = ImageWriteParam.MODE_EXPLICIT + compressionType = "JPEG" + compressionQuality = quality + } + writer.write(null, IIOImage(rgb, null, null), param) + } + } finally { + writer.dispose() + } + } + + /** + * Draw [src] onto a fresh `TYPE_INT_RGB` canvas. Transparent + * pixels render against a white background — JPEG has no alpha, + * and "default to white" matches what every major image viewer + * does when displaying transparent PNGs over a light surface. + */ + private fun toRgbCanvas(src: BufferedImage): BufferedImage { + val rgb = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_RGB) + val g = rgb.createGraphics() + try { + g.color = java.awt.Color.WHITE + g.fillRect(0, 0, src.width, src.height) + g.drawImage(src, 0, 0, null) + } finally { + g.dispose() + } + return rgb + } + + /** + * Pass-through reason captured for telemetry and diagnostics. Not + * surfaced to users — the orchestrator decides on user-facing + * messaging. + */ + enum class PassReason { + /** Animated GIF / animated WebP — would lose frames on re-encode. */ + Animated, + + /** Vector format (SVG) — no raster re-encode is meaningful. */ + Vector, + + /** + * Re-encode was attempted earlier and failed; the user + * confirmed via the fail-loud dialog that the original + * should be uploaded anyway. + */ + BypassByUser, + + /** + * Sniffer couldn't identify the bytes as any known image + * format. Common for voice memos, videos, plain-text + * attachments, and other non-image uploads that share the + * orchestrator path. Refusing them here would crash + * non-image upload flows (DM file send, voice messages). + */ + NotAnImage, + } + + /** Outcome of a [reencode] call. */ + sealed class ReencodeResult { + /** A new temp file was produced. Caller owns cleanup. */ + data class Reencoded( + val file: File, + ) : ReencodeResult() + + /** + * Source must be uploaded byte-identical (animated / vector). + */ + data class PassThrough( + val reason: PassReason, + ) : ReencodeResult() + } + + /** + * Returns `true` if the source carries a non-sRGB ICC profile. The + * fail-loud / wide-gamut warning path will use this to ask for + * user consent before silently shifting colors. Not yet wired into + * the orchestrator — see Phase 7 of the plan. + */ + fun hasWideGamutProfile(image: BufferedImage): Boolean { + val cs = image.colorModel?.colorSpace ?: return false + if (cs.isCS_sRGB) return false + // CS_LINEAR_RGB / CS_PYCC / CS_GRAY are all narrow-gamut or + // colorimetrically neutral — only true ICC color spaces with + // wider primaries (Display P3, Adobe RGB, ProPhoto) matter. + return cs.type == ColorSpace.TYPE_RGB && !cs.isCS_sRGB + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt index bd4621eb4c..7c12f8a647 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/MediaCompressor.kt @@ -26,6 +26,16 @@ import java.io.ByteArrayOutputStream import java.io.File object MediaCompressor { + /** + * Strip EXIF from a JPEG. Caller owns the returned file's cleanup + * — the temp file lands under [AmethystTempDir] and is NOT + * registered with deleteOnExit (long-running desktop sessions + * leaked under the old behavior, see + * `docs/temp-file-cleanup-analysis.md`). + * + * Returns the input file unchanged if the source is not a JPEG, + * carries no EXIF, or strip fails. + */ fun stripExif(file: File): File { if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) { return file @@ -39,9 +49,8 @@ object MediaCompressor { val baos = ByteArrayOutputStream() ExifRewriter().removeExifMetadata(bytes, baos) - val stripped = File.createTempFile("stripped_", ".jpg") + val stripped = AmethystTempDir.createTempFile("amethyst_stripped_", ".jpg") stripped.writeBytes(baos.toByteArray()) - stripped.deleteOnExit() stripped } catch (_: Exception) { file diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 54af97d0d9..1c214a4a94 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -20,11 +20,14 @@ */ package com.vitorpamplona.amethyst.commons.service.upload +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import java.io.File data class UploadResult( @@ -42,48 +45,109 @@ data class EncryptedUploadResult( class UploadOrchestrator( private val client: BlossomClient = BlossomClient(), ) { + /** + * Upload an image file to a Blossom server, optionally re-encoding + * + downscaling first, optionally stripping EXIF. + * + * Flow: + * 1. [ImageReencoder] decides: + * - `Reencoded(temp)` → upload the temp; EXIF is naturally + * absent because the re-encode wiped it. + * - `PassThrough` (animated GIF / WebP / SVG) → upload the + * *original*. If [stripExif] is on and source is JPEG, run + * [MediaCompressor.stripExif] first. + * 2. [MediaMetadataReader] computes the upload hash on the final + * bytes (whatever bytes actually leave the machine). + * 3. Blossom auth + upload (unchanged). + * 4. All temp files created along the way are deleted in a + * finally block under [NonCancellable] so cleanup survives + * coroutine cancellation. + * + * @throws CompressionException when the reencoder refuses or fails + * (caller surfaces the fail-loud confirm dialog). + */ suspend fun upload( file: File, alt: String?, serverBaseUrl: String, signer: NostrSigner, stripExif: Boolean = true, + quality: CompressionQuality? = null, + bypassReencode: Boolean = false, + preCompressed: File? = null, ): UploadResult { - // 1. Strip EXIF if requested (JPEG only) - val processedFile = - if (stripExif) { - MediaCompressor.stripExif(file) - } else { - file + var reencodedTemp: File? = null + var strippedTemp: File? = null + try { + // 1. Re-encode (or pass-through). Four branches: + // - preCompressed != null: the preview dialog already + // ran ImageReencoder and is handing off ownership of + // its temp file. Skip reencode, skip stripExif (the + // reencode already wiped EXIF), just upload + clean. + // - bypassReencode == true: user opted to send the + // original after a reencode failure or refusal. + // - quality == null: caller did not opt into the + // desktop image-compression feature. Treat the file + // as a pass-through — preserves old orchestrator + // semantics for the CLI, Android, and any non- + // image upload (voice memos, video, DM files). + // - else: run the normal reencode path with the + // caller-requested preset. + val reencode = + when { + preCompressed != null -> ReencodeResult.Reencoded(preCompressed) + bypassReencode || quality == null -> + ReencodeResult.PassThrough(ImageReencoder.PassReason.BypassByUser) + else -> ImageReencoder.reencode(file, quality) + } + val afterReencode = + when (reencode) { + is ReencodeResult.Reencoded -> reencode.file.also { reencodedTemp = it } + is ReencodeResult.PassThrough -> file + } + + // 2. Strip EXIF only when we're shipping the original + // (re-encode already wipes EXIF naturally). + val finalFile = + if (stripExif && reencode is ReencodeResult.PassThrough) { + val stripped = MediaCompressor.stripExif(afterReencode) + if (stripped != afterReencode) strippedTemp = stripped + stripped + } else { + afterReencode + } + + // 3. Compute metadata on the bytes that will actually + // leave this machine. + val metadata = MediaMetadataReader.compute(finalFile) + + // 4. Create auth header. + val authHeader = + BlossomAuth.createUploadAuth( + hash = metadata.sha256, + size = metadata.size, + alt = alt ?: "Uploading ${file.name}", + signer = signer, + ) + + // 5. Upload. + val result = + client.upload( + file = finalFile, + contentType = metadata.mimeType, + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + return UploadResult(blossom = result, metadata = metadata) + } finally { + // Eager cleanup of every intermediate. NonCancellable so a + // user-cancelled upload still cleans up its temps. + withContext(NonCancellable) { + strippedTemp?.delete() + reencodedTemp?.delete() } - - // 2. Compute metadata (hash, dimensions, blurhash) - val metadata = MediaMetadataReader.compute(processedFile) - - // 3. Create auth header - val authHeader = - BlossomAuth.createUploadAuth( - hash = metadata.sha256, - size = metadata.size, - alt = alt ?: "Uploading ${file.name}", - signer = signer, - ) - - // 4. Upload - val result = - client.upload( - file = processedFile, - contentType = metadata.mimeType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) - - // 5. Clean up temp file if we stripped EXIF - if (processedFile != file) { - processedFile.delete() } - - return UploadResult(blossom = result, metadata = metadata) } /** diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt new file mode 100644 index 0000000000..01f9543be7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.relayClient.paging + +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * State-machine tests for [BackwardRelayPager]: drive its relay callbacks directly (no network) and + * assert the cursor / done / stalled / exhausted bookkeeping — the logic that backed the "All caught up + * while messages missing" and the stalled-vs-done bugs. The relay's own `until`+`limit`+EOSE wire + * behaviour is covered separately against the in-process relay in `UntilLimitPagingRelayTest`. + * + * The pager is the **single-active orchestrator**: its per-relay cursors live on a separate + * [RelayLoadingCursors] (in production, on a `Chatroom` / `ChatroomList`), bound in via [bind]. These tests + * supply their own cursor object so they can rebind a previously-paged scope and assert what persists + * (the cursors) versus what is transient and recomputed (the stalled set, the live flows). + */ +class BackwardRelayPagerTest { + private val r1 = NormalizedRelayUrl("wss://r1.example/") + private val r2 = NormalizedRelayUrl("wss://r2.example/") + private val r3 = NormalizedRelayUrl("wss://r3.example/") + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @AfterTest + fun tearDown() { + scope.cancel() + } + + // A pager bound to a fresh scope of [relays]; returns both so tests can read the pinned cursor floor. + private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair { + val cursors = RelayLoadingCursors() + val p = BackwardRelayPager("test") + p.bind(cursors, scope) { relays.toList() } + return p to cursors + } + + @Test + fun firstPageRequestsTheFloorAndAnEmptyPageIsCaughtUp() { + val (p, cursors) = pagerOf(r1) + assertFalse(p.status.value.exhausted) + + assertTrue(p.advance(r1)) + // The very first page asks `until = floor` (pinned on the bound cursors). + assertEquals(cursors.floor, p.requestedUntilFor(r1)) + + // Empty page + EOSE → that relay is done; the only relay is done → genuinely caught up. + assertTrue(p.onEose(r1)) + assertTrue( + p.status.value.relayProgress + .getValue(r1) + .done, + ) + assertTrue(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + } + + @Test + fun nonEmptyPageMovesTheCursorThenBottomsOut() { + val (p, _) = pagerOf(r1) + p.advance(r1) + + // A page of three events; the oldest is 80, so the reached cursor drops to 80 (not done). + p.onEvent(r1, 100) + p.onEvent(r1, 80) + p.onEvent(r1, 90) + assertFalse(p.onEose(r1)) + assertFalse( + p.status.value.relayProgress + .getValue(r1) + .done, + ) + assertEquals(80L, p.status.value.reachedBack) + assertFalse(p.status.value.exhausted) + + // The next page must start strictly below the oldest reached (80 → until 79). + assertTrue(p.advance(r1)) + assertEquals(79L, p.requestedUntilFor(r1)) + + // Empty page now → done → caught up. + assertTrue(p.onEose(r1)) + assertTrue(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + } + + @Test + fun aStalledRelayMakesExhaustionIncompleteNotCaughtUp() { + val (p, _) = pagerOf(r1, r2) + p.advance(r1) + p.advance(r2) + + // r1 genuinely bottoms out; r2 is still pending, so not exhausted yet. + p.onEose(r1) + assertFalse(p.status.value.exhausted) + + // r2 auth-walls the REQ → stalled (kept, not done). + p.onClosed(r2, "auth-required") + assertTrue( + p.status.value.relayProgress + .getValue(r2) + .stalled, + ) + assertFalse( + p.status.value.relayProgress + .getValue(r2) + .done, + ) + + // Every relay is now done-or-stalled → exhausted, but it is INCOMPLETE: one relay unreachable. + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + } + + @Test + fun cannotConnectAlsoStalls() { + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onCannotConnect(r1, "offline") + assertTrue( + p.status.value.relayProgress + .getValue(r1) + .stalled, + ) + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + } + + @Test + fun reAdvancingAStalledRelayClearsTheStallAndUnExhausts() { + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onClosed(r1, "auth-required") + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + + // Retrying it re-arms the relay: no longer stalled, no longer exhausted. + assertTrue(p.advance(r1)) + assertFalse( + p.status.value.relayProgress + .getValue(r1) + .stalled, + ) + assertFalse(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + } + + @Test + fun reachedBackIsTheDeepestCursorAcrossRelays() { + val (p, _) = pagerOf(r1, r2) + p.advance(r1) + p.advance(r2) + + p.onEvent(r1, 500) + p.onEose(r1) // r1 reached 500 + + p.onEvent(r2, 300) + p.onEose(r2) // r2 reached 300 + + // Deepest = the oldest point any relay has reached. + assertEquals(300L, p.status.value.reachedBack) + } + + @Test + fun aDoneRelayWillNotAdvanceAgain() { + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onEose(r1) // empty → done + assertFalse(p.advance(r1)) + } + + @Test + fun advanceAllArmsEveryNotDoneRelay() { + val (p, _) = pagerOf(r1, r2, r3) + // r2 already finished; advanceAll should arm only r1 and r3. + p.advance(r2) + p.onEose(r2) + + assertTrue(p.advanceAll()) + assertEquals(setOf(r1, r3), p.armedRelays(listOf(r1, r2, r3)).toSet()) + } + + @Test + fun rebindingRepointsFlowsKeepingDoneCursorsButDroppingTransientStalls() { + val cursorsA = RelayLoadingCursors() + val cursorsB = RelayLoadingCursors() + val p = BackwardRelayPager("test") + + // Scope A: r1 bottoms out (done — a persistent cursor fact); r2 auth-walls (stalled — transient). + p.bind(cursorsA, scope) { listOf(r1, r2) } + p.advance(r1) + p.advance(r2) + p.onEose(r1) + p.onClosed(r2, "auth-required") + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + + // Bind to a fresh scope B: the flows reflect B's own (empty) state — nothing stalled, and its + // reach sits at B's floor (no history fetched yet — markers start at the live-tail boundary). + p.bind(cursorsB, scope) { listOf(r3) } + assertFalse(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + assertEquals(cursorsB.floor, p.status.value.reachedBack) + + // Rebind to A: r1 is still DONE (its cursor persisted on cursorsA), but r2's stall is gone — stall + // is transient, so r2 is pending again and A is no longer exhausted (it will retry the auth relay). + p.bind(cursorsA, scope) { listOf(r1, r2) } + assertTrue( + p.status.value.relayProgress + .getValue(r1) + .done, + ) + assertEquals(0, p.status.value.stalledCount) + assertFalse(p.status.value.exhausted) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionSmokeTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionSmokeTest.kt new file mode 100644 index 0000000000..536b393ff5 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionSmokeTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.service.upload + +import net.coobird.thumbnailator.Thumbnails +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Phase 0 smoke: confirms the Thumbnailator dep is on the classpath, AWT + * is headless during tests, and a basic re-encode produces a readable + * JPEG. Deeper compression behavior is verified by ImageReencoderTest in + * Phase 1. + */ +class CompressionSmokeTest { + @Test + fun awtHeadlessIsEnabled() { + // Without -Djava.awt.headless=true, this is false on macOS dev + // machines and macOS would happily spin up a Dock icon during + // tests. Pin it here so a regression in commons/build.gradle.kts + // is caught fast. + assertEquals( + "true", + System.getProperty("java.awt.headless"), + "java.awt.headless must be true during commons jvmTest", + ) + } + + @Test + fun thumbnailatorReencodesJpeg() { + val src = makeSyntheticImage(640, 480) + val out = File.createTempFile("amethyst_smoke_", ".jpg") + try { + Thumbnails + .of(src) + .size(320, 320) + .outputFormat("jpg") + .outputQuality(0.9f) + .toFile(out) + + assertTrue(out.length() > 0, "output file must be non-empty") + + val decoded = ImageIO.read(out) + assertTrue(decoded != null, "output must decode as a valid image") + assertEquals(320, decoded.width, "downscale must hit target width") + assertEquals( + 240, + decoded.height, + "aspect ratio preserved → 480 × (320/640) = 240", + ) + } finally { + out.delete() + } + } + + @Test + fun thumbnailatorUpscalesByDefault() { + // Documents the trap: Thumbnailator's .size(w, h) WILL upscale a + // smaller source up to the box. ImageReencoder must gate the + // resize itself — see thumbnailatorNeverUpscalesWhenGated below + // for the pattern. + val src = makeSyntheticImage(200, 150) + val out = File.createTempFile("amethyst_smoke_upscale_", ".jpg") + try { + Thumbnails + .of(src) + .size(1920, 1920) + .outputFormat("jpg") + .outputQuality(0.9f) + .toFile(out) + + val decoded = ImageIO.read(out) + // 200×150 was upscaled to fit the 1920×1920 box (aspect kept). + assertEquals(1920, decoded.width) + assertEquals(1440, decoded.height) + } finally { + out.delete() + } + } + + @Test + fun thumbnailatorNeverUpscalesWhenGated() { + // The pattern ImageReencoder will use: skip the .size() call + // entirely when the source is already within the target box. + val src = makeSyntheticImage(200, 150) + val out = File.createTempFile("amethyst_smoke_no_upscale_", ".jpg") + try { + val targetMax = 1920 + val builder = Thumbnails.of(src).outputFormat("jpg").outputQuality(0.9f) + if (src.width > targetMax || src.height > targetMax) { + builder.size(targetMax, targetMax) + } else { + // Re-encode only — no resize. + builder.scale(1.0) + } + builder.toFile(out) + + val decoded = ImageIO.read(out) + assertEquals(200, decoded.width, "source within box → no resize") + assertEquals(150, decoded.height, "source within box → no resize") + } finally { + out.delete() + } + } + + private fun makeSyntheticImage( + width: Int, + height: Int, + ): BufferedImage { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val g = img.createGraphics() + try { + // Diagonal gradient so the encoder produces realistic + // entropy (a flat-color image compresses suspiciously well). + for (y in 0 until height) { + for (x in 0 until width) { + val r = (x * 255 / width) + val g0 = (y * 255 / height) + val b = ((x + y) * 255 / (width + height)) + img.setRGB(x, y, Color(r, g0, b).rgb) + } + } + } finally { + g.dispose() + } + return img + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSnifferTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSnifferTest.kt new file mode 100644 index 0000000000..9e9aa717cf --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageFormatSnifferTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.service.upload + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ImageFormatSnifferTest { + @Test + fun sniffsJpeg() { + val bytes = byteArrayOf(0xFF.b, 0xD8.b, 0xFF.b, 0xE0.b, 0x00, 0x10) + assertEquals(ImageFormat.Jpeg, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsPng() { + val bytes = byteArrayOf(0x89.b, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D) + assertEquals(ImageFormat.Png, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsBmp() { + val bytes = byteArrayOf('B'.b, 'M'.b, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00) + assertEquals(ImageFormat.Bmp, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsTiffLittleEndian() { + val bytes = byteArrayOf(0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00) + assertEquals(ImageFormat.Tiff, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsTiffBigEndian() { + val bytes = byteArrayOf(0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08) + assertEquals(ImageFormat.Tiff, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsStaticGif() { + val bytes = ascii("GIF89a") + byteArrayOf(0x10, 0x00, 0x10, 0x00) + assertEquals(ImageFormat.Gif(animated = false), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsGif87aStatic() { + val bytes = ascii("GIF87a") + byteArrayOf(0x10, 0x00, 0x10, 0x00) + assertEquals(ImageFormat.Gif(animated = false), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAnimatedGifViaNetscapeExtension() { + val bytes = + ascii("GIF89a") + + ByteArray(50) { 0x00 } + + ascii("NETSCAPE2.0") + + ByteArray(20) { 0x00 } + assertEquals(ImageFormat.Gif(animated = true), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsStaticWebpRiffOnly() { + val bytes = + ascii("RIFF") + byteArrayOf(0x10, 0x00, 0x00, 0x00) + ascii("WEBP") + + ascii("VP8L") + ByteArray(80) { 0x00 } + assertEquals(ImageFormat.WebP(animated = false), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAnimatedWebpViaVp8xAnimFlag() { + // RIFF + size + WEBP + VP8X chunk header + size + flags(0x02 = anim) + val bytes = + ascii("RIFF") + byteArrayOf(0x20, 0x00, 0x00, 0x00) + ascii("WEBP") + + ascii("VP8X") + byteArrayOf(0x0A, 0x00, 0x00, 0x00) + + byteArrayOf(0x02.b) + ByteArray(40) { 0x00 } + assertEquals(ImageFormat.WebP(animated = true), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAnimatedWebpViaAnimChunk() { + // RIFF + WEBP + ... + ANIM chunk somewhere in the prefix (no + // VP8X animation flag — covers files that put ANIM directly). + val bytes = + ascii("RIFF") + byteArrayOf(0x40, 0x00, 0x00, 0x00) + ascii("WEBP") + + ascii("VP8X") + byteArrayOf(0x0A, 0x00, 0x00, 0x00) + + byteArrayOf(0x00.b) + ByteArray(9) { 0x00 } + + ascii("ANIM") + ByteArray(30) { 0x00 } + assertEquals(ImageFormat.WebP(animated = true), ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAvifBrand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("avif") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Avif, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsAvifSequenceBrand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("avis") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Avif, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsHeicBrand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("heic") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Heic, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsHeifMif1Brand() { + val bytes = + byteArrayOf(0x00, 0x00, 0x00, 0x20) + ascii("ftyp") + ascii("mif1") + + ByteArray(32) { 0x00 } + assertEquals(ImageFormat.Heic, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsSvgWithXmlProlog() { + val bytes = ascii("""""") + assertEquals(ImageFormat.Svg, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun sniffsSvgWithoutProlog() { + val bytes = ascii("""""") + assertEquals(ImageFormat.Svg, ImageFormatSniffer.sniff(bytes)) + } + + @Test + fun returnsUnknownForRandomBytes() { + val bytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07) + assertTrue(ImageFormatSniffer.sniff(bytes) is ImageFormat.Unknown) + } + + @Test + fun returnsUnknownForEmptyBytes() { + assertTrue(ImageFormatSniffer.sniff(ByteArray(0)) is ImageFormat.Unknown) + } + + @Test + fun returnsUnknownForTooShort() { + assertTrue(ImageFormatSniffer.sniff(byteArrayOf(0xFF.b)) is ImageFormat.Unknown) + } + + @Test + fun returnsUnknownForEmptyFile() { + val empty = File.createTempFile("amethyst_empty_", ".bin") + try { + assertTrue(ImageFormatSniffer.sniff(empty) is ImageFormat.Unknown) + } finally { + empty.delete() + } + } + + @Test + fun returnsUnknownForMissingFile() { + val missing = File("/nonexistent/path/never/exists/file.bin") + assertTrue(ImageFormatSniffer.sniff(missing) is ImageFormat.Unknown) + } + + @Test + fun sniffsJpegFromFile() { + val jpeg = File.createTempFile("amethyst_sniff_jpeg_", ".jpg") + try { + val bytes = byteArrayOf(0xFF.b, 0xD8.b, 0xFF.b, 0xE0.b) + ByteArray(200) { 0x00 } + jpeg.writeBytes(bytes) + assertEquals(ImageFormat.Jpeg, ImageFormatSniffer.sniff(jpeg)) + } finally { + jpeg.delete() + } + } + + // ----- tiny helpers ----- + + private val Int.b: Byte get() = this.toByte() + + private val Char.b: Byte get() = this.code.toByte() + + private fun ascii(s: String): ByteArray = s.toByteArray(Charsets.US_ASCII) +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoderTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoderTest.kt new file mode 100644 index 0000000000..b9082b5fa5 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoderTest.kt @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.service.upload + +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import kotlinx.coroutines.test.runTest +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ImageReencoderTest { + private val createdFiles = mutableListOf() + + @BeforeTest + fun setup() { + createdFiles.clear() + } + + @AfterTest + fun cleanup() { + createdFiles.forEach { it.delete() } + // Clear any per-test pixel override. + System.clearProperty("amethyst.compression.maxPixels") + } + + @Test + fun reencodesJpegAtDesktopHigh() = + runTest { + val src = makeJpeg(4032, 3024) + val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + assertTrue(decoded.width <= 1920, "width within Desktop High box, got ${decoded.width}") + assertTrue(decoded.height <= 1920, "height within Desktop High box, got ${decoded.height}") + // Source was 4032×3024 → landscape → longer edge clamps to 1920. + assertEquals(1920, decoded.width, "longer edge = max-dim") + } + + @Test + fun reencodesJpegAtMedium() = + runTest { + val src = makeJpeg(2000, 1500) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + // Medium = 640 max-dim. Longer edge clamps to 640. + assertEquals(640, decoded.width) + } + + @Test + fun reencodesJpegAtLow() = + runTest { + val src = makeJpeg(2000, 1500) + val result = ImageReencoder.reencode(src, CompressionQuality.LOW) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + assertEquals(640, decoded.width) + } + + @Test + fun outputSizeMonotonicAcrossPresets() = + runTest { + val src = makeJpeg(2000, 1500) + val low = (ImageReencoder.reencode(src, CompressionQuality.LOW) as ReencodeResult.Reencoded).file.also(::track) + val medium = (ImageReencoder.reencode(src, CompressionQuality.MEDIUM) as ReencodeResult.Reencoded).file.also(::track) + val high = (ImageReencoder.reencode(src, CompressionQuality.HIGH) as ReencodeResult.Reencoded).file.also(::track) + // DESKTOP_HIGH at 1920 is bigger than 640-based presets, so monotonicity is + // strict only over the same-dim subset (LOW < MEDIUM < HIGH). + assertTrue(low.length() < medium.length(), "LOW (${low.length()}) < MEDIUM (${medium.length()})") + assertTrue(medium.length() < high.length(), "MEDIUM (${medium.length()}) < HIGH (${high.length()})") + } + + @Test + fun reencodesJpegAtHigh() = + runTest { + val src = makeJpeg(2000, 1500) + val result = ImageReencoder.reencode(src, CompressionQuality.HIGH) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + // High = 640 max-dim. Longer edge clamps to 640. + assertEquals(640, decoded.width) + } + + @Test + fun neverUpscalesSmallSource() = + runTest { + val src = makeJpeg(320, 240) + val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + assertEquals(320, decoded.width, "small source must keep its dimensions") + assertEquals(240, decoded.height, "small source must keep its dimensions") + } + + @Test + fun throwsInputTooLargeWhenAboveCap() = + runTest { + // Lower the cap so a tiny test JPEG triggers the guard. + System.setProperty("amethyst.compression.maxPixels", "1000") + val src = makeJpeg(100, 100) // 10 000 pixels > 1000 cap + assertFailsWith { + ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + } + } + + @Test + fun refusesAvifInput() = + runTest { + val src = makeFile("avif", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "avif".toByteArray() + ByteArray(32)) + val e = + assertFailsWith { + ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + } + assertEquals("avif", e.format) + } + + @Test + fun refusesHeicInput() = + runTest { + val src = makeFile("heic", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "heic".toByteArray() + ByteArray(32)) + assertFailsWith { + ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + } + } + + @Test + fun passesThroughAnimatedGif() = + runTest { + // Synthetic animated-GIF magic: GIF89a + NETSCAPE2.0 application extension. + val src = + makeFile( + "gif", + "GIF89a".toByteArray() + ByteArray(50) + "NETSCAPE2.0".toByteArray() + ByteArray(20), + ) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val pt = assertIs(result) + assertEquals(ImageReencoder.PassReason.Animated, pt.reason) + } + + @Test + fun passesThroughSvg() = + runTest { + val src = makeFile("svg", """""".toByteArray()) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val pt = assertIs(result) + assertEquals(ImageReencoder.PassReason.Vector, pt.reason) + } + + @Test + fun outputIsValidJpeg() = + runTest { + val src = makeJpeg(800, 600) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val reencoded = assertIs(result) + track(reencoded.file) + + val bytes = reencoded.file.readBytes() + assertTrue(bytes.size > 0, "output is non-empty") + // SOI marker + assertEquals(0xFF.toByte(), bytes[0]) + assertEquals(0xD8.toByte(), bytes[1]) + } + + @Test + fun reencodesPngWithAlphaToJpeg() = + runTest { + // Regression: TYPE_INT_ARGB inputs (typical PNG decode) + // used to crash the JPEG writer with "Bogus input + // colorspace". encodeJpeg now flattens onto a white RGB + // canvas before handing off to the writer. + val src = makePngWithAlpha(800, 600) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + assertTrue(decoded != null, "output must be a valid JPEG") + assertEquals(640, decoded.width, "Medium clamps long edge to 640") + } + + @Test + fun encodeFailedWrapsCauseWithoutCrashing() { + // Regression: CompressionException had a double-set cause + // (super(message, cause) + initCause(cause)) that threw + // IllegalStateException at construction time, masking the + // real encode error. + val original = javax.imageio.IIOException("Bogus input colorspace") + val wrapped = CompressionException.EncodeFailed(original) + assertEquals(original, wrapped.cause) + assertTrue(wrapped.message!!.contains("Bogus input colorspace")) + } + + @Test + fun outputLivesInAmethystTmpDir() = + runTest { + val src = makeJpeg(800, 600) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val reencoded = assertIs(result) + track(reencoded.file) + + assertEquals( + AmethystTempDir.rootDir().canonicalPath, + reencoded.file.parentFile.canonicalPath, + "temp files must land in AmethystTempDir, not /tmp", + ) + assertTrue( + reencoded.file.name.startsWith("amethyst_compress_"), + "temp file name must carry the sweep prefix", + ) + } + + // ---------- helpers ---------- + + private fun track(file: File) { + createdFiles += file + } + + private fun makePngWithAlpha( + width: Int, + height: Int, + ): File { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g = img.createGraphics() + try { + // Transparent left half, solid colored right half — the + // "Bogus input colorspace" trap fires regardless of + // pixel data, just on the color model. + for (y in 0 until height step 8) { + for (x in 0 until width step 8) { + val alpha = if (x < width / 2) 0 else 255 + val r = (x * 255 / width).coerceIn(0, 255) + val gg = (y * 255 / height).coerceIn(0, 255) + val b = ((x + y) * 255 / (width + height)).coerceIn(0, 255) + g.color = Color(r, gg, b, alpha) + g.fillRect(x, y, 8, 8) + } + } + } finally { + g.dispose() + } + val out = File.createTempFile("reencoder_alpha_", ".png") + track(out) + ImageIO.write(img, "png", out) + return out + } + + private fun makeJpeg( + width: Int, + height: Int, + ): File { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val g = img.createGraphics() + try { + for (y in 0 until height step 8) { + for (x in 0 until width step 8) { + val r = (x * 255 / width).coerceIn(0, 255) + val gg = (y * 255 / height).coerceIn(0, 255) + val b = ((x + y) * 255 / (width + height)).coerceIn(0, 255) + g.color = Color(r, gg, b) + g.fillRect(x, y, 8, 8) + } + } + } finally { + g.dispose() + } + val out = File.createTempFile("reencoder_src_", ".jpg") + track(out) + ImageIO.write(img, "jpg", out) + return out + } + + private fun makeFile( + extension: String, + bytes: ByteArray, + ): File { + val out = File.createTempFile("reencoder_src_", ".$extension") + track(out) + out.writeBytes(bytes) + return out + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestratorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestratorTest.kt new file mode 100644 index 0000000000..f6d564a672 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestratorTest.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.service.upload + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import kotlinx.coroutines.test.runTest +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UploadOrchestratorTest { + private val createdFiles = mutableListOf() + private val signer = NostrSignerInternal(KeyPair()) + + @BeforeTest fun setup() { + createdFiles.clear() + } + + @AfterTest fun cleanup() { + createdFiles.forEach { it.delete() } + } + + @Test + fun reencodesJpegAndUploadsCompressedBytes() = + runTest { + val src = makeJpeg(2000, 1500) + val fake = FakeBlossomClient() + val orchestrator = UploadOrchestrator(fake) + + val result = + orchestrator.upload( + file = src, + alt = "test", + serverBaseUrl = "https://example.test", + signer = signer, + quality = CompressionQuality.MEDIUM, + ) + + val captured = requireNotNull(fake.lastUploadedFile) + assertTrue( + captured.canonicalPath.startsWith(AmethystTempDir.rootDir().canonicalPath), + "uploaded file must be the re-encoded temp under AmethystTempDir, got $captured", + ) + assertTrue(captured.length() < src.length(), "compressed bytes should be smaller than source") + assertEquals("image/jpeg", result.metadata.mimeType) + // The metadata hash is computed on the SAME bytes that were uploaded. + val expectedSha = sha256Hex(captured.exists().let { fake.lastUploadedBytes!! }) + assertEquals(expectedSha, result.metadata.sha256) + } + + @Test + fun passesThroughAnimatedGifUnchanged() = + runTest { + // Synthetic animated-GIF magic so the sniffer returns Animated. + val src = makeAnimatedGif() + val fake = FakeBlossomClient() + val orchestrator = UploadOrchestrator(fake) + + val result = + orchestrator.upload( + file = src, + alt = null, + serverBaseUrl = "https://example.test", + signer = signer, + quality = CompressionQuality.MEDIUM, + ) + + assertEquals(src.canonicalPath, fake.lastUploadedFile!!.canonicalPath, "must upload original bytes for animated GIF") + assertEquals(src.length(), fake.lastUploadedFile!!.length()) + assertEquals("image/gif", result.metadata.mimeType) + } + + @Test + fun refusesAvifWithUnsupportedFormat() = + runTest { + val src = + writeBytes( + "avif", + byteArrayOf(0x00, 0x00, 0x00, 0x20) + "ftyp".toByteArray() + "avif".toByteArray() + ByteArray(32), + ) + val fake = FakeBlossomClient() + val orchestrator = UploadOrchestrator(fake) + + assertFailsWith { + orchestrator.upload( + file = src, + alt = null, + serverBaseUrl = "https://example.test", + signer = signer, + quality = CompressionQuality.MEDIUM, + ) + } + assertEquals(null, fake.lastUploadedFile, "must not call client.upload when reencoder refuses") + } + + @Test + fun cleansUpTempFileAfterSuccessfulUpload() = + runTest { + val src = makeJpeg(800, 600) + val fake = FakeBlossomClient() + val orchestrator = UploadOrchestrator(fake) + + orchestrator.upload( + file = src, + alt = null, + serverBaseUrl = "https://example.test", + signer = signer, + quality = CompressionQuality.MEDIUM, + ) + + val capturedPath = fake.lastUploadedFile!!.canonicalPath + assertFalse( + File(capturedPath).exists(), + "re-encoded temp must be deleted after a successful upload, still exists at $capturedPath", + ) + } + + @Test + fun cleansUpTempFileAfterUploadFailure() = + runTest { + val src = makeJpeg(800, 600) + val fake = FakeBlossomClient(failNext = true) + val orchestrator = UploadOrchestrator(fake) + + assertFailsWith { + orchestrator.upload( + file = src, + alt = null, + serverBaseUrl = "https://example.test", + signer = signer, + quality = CompressionQuality.MEDIUM, + ) + } + // FakeBlossomClient captures BEFORE it throws, so we have a path to check. + val capturedPath = fake.lastUploadedFile!!.canonicalPath + assertFalse( + File(capturedPath).exists(), + "re-encoded temp must be deleted even when upload throws, still exists at $capturedPath", + ) + } + + // ---------- helpers ---------- + + private fun makeJpeg( + width: Int, + height: Int, + ): File { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val g = img.createGraphics() + try { + for (y in 0 until height step 8) { + for (x in 0 until width step 8) { + val r = (x * 255 / width).coerceIn(0, 255) + val gg = (y * 255 / height).coerceIn(0, 255) + val b = ((x + y) * 255 / (width + height)).coerceIn(0, 255) + g.color = Color(r, gg, b) + g.fillRect(x, y, 8, 8) + } + } + } finally { + g.dispose() + } + val out = File.createTempFile("orchestrator_src_", ".jpg") + createdFiles += out + ImageIO.write(img, "jpg", out) + return out + } + + private fun makeAnimatedGif(): File { + val out = File.createTempFile("orchestrator_src_", ".gif") + createdFiles += out + out.writeBytes( + "GIF89a".toByteArray() + ByteArray(50) + "NETSCAPE2.0".toByteArray() + ByteArray(20), + ) + return out + } + + private fun writeBytes( + ext: String, + bytes: ByteArray, + ): File { + val out = File.createTempFile("orchestrator_src_", ".$ext") + createdFiles += out + out.writeBytes(bytes) + return out + } + + private fun sha256Hex(bytes: ByteArray): String = + java.security.MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + + /** + * Fake BlossomClient that snapshots the uploaded file's bytes + * (so we can assert on them AFTER the orchestrator's cleanup + * deletes the file) and returns a canned [BlossomUploadResult]. + */ + private class FakeBlossomClient( + private val failNext: Boolean = false, + ) : BlossomClient() { + var lastUploadedFile: File? = null + var lastUploadedBytes: ByteArray? = null + + override suspend fun upload( + file: File, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult { + lastUploadedFile = file + lastUploadedBytes = file.readBytes() + if (failNext) throw RuntimeException("simulated upload failure") + return BlossomUploadResult( + url = "$serverBaseUrl/${sha256HexInternal(lastUploadedBytes!!)}", + sha256 = sha256HexInternal(lastUploadedBytes!!), + size = file.length(), + type = contentType, + uploaded = System.currentTimeMillis() / 1000, + ) + } + + private fun sha256HexInternal(bytes: ByteArray): String = + java.security.MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt similarity index 100% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt diff --git a/desktopApp/plans/2026-06-08-feat-desktop-image-compression-plan.md b/desktopApp/plans/2026-06-08-feat-desktop-image-compression-plan.md new file mode 100644 index 0000000000..e2503171d3 --- /dev/null +++ b/desktopApp/plans/2026-06-08-feat-desktop-image-compression-plan.md @@ -0,0 +1,904 @@ +--- +title: "feat: Desktop Image Compression" +type: feat +status: active +date: 2026-06-08 +origin: docs/brainstorms/2026-06-08-desktop-image-compression-brainstorm.md +deepened: 2026-06-08 +--- + +# Desktop Image Compression + +## Enhancement Summary + +**Deepened on:** 2026-06-08 +**Review agents used:** architecture-strategist, performance-oracle, +security-sentinel, code-simplicity-reviewer, pattern-recognition-specialist, +best-practices-researcher, amy-expert, kotlin-multiplatform, +kotlin-coroutines-structured-concurrency, data-integrity-guardian, +compose-expert. + +### Showstoppers found by review (forced scope cuts) + +1. **No pure-Java WebP encoder exists in 2026.** `org.sejda.imageio:webp-imageio` is JNI-based and abandoned (last release 2017); TwelveMonkeys' WebP plugin is decode-only (wiki confirms). Conclusion: **WebP encoding dropped from v1**. JPEG-only output. WebP encode revisited if/when a usable pure-Java encoder appears or JNI is accepted. +2. **TwelveMonkeys has NO HEIC plugin.** Issue [#976](https://github.com/haraldk/TwelveMonkeys/issues/976) (2024) remains open. The earlier exploration mis-named `imageio-heif`; that plugin does not exist. Pure-Java HEIC decode is not viable. Conclusion: **HEIC input dropped from v1**. Input formats limited to JPEG / PNG / GIF / WebP / SVG / BMP / TIFF (the latter two via TwelveMonkeys' actual real plugins, low priority). HEIC support deferred to a JNI-libheif follow-up. + +### Key correctness fixes from review + +1. **Pre-decode pixel-count guard was broken as written.** `Thumbnails.of(file)` decodes internally — the 50 MP guard cannot fire in time. Fix: stream header dims via `ImageReader.getWidth(0)/getHeight(0)`, then call `Thumbnails.of(bufferedImage)` after a guarded `reader.read(0, paramWithSubsampling)`. (perf + sec) +2. **EXIF leak on fail-loud bypass.** "Send Original" must still run `stripExif` when source is JPEG and `keepExif=false`. Plan now resolves the brainstorm's Open Question #3 as YES. (data-integrity + sec) +3. **`stripExif` is JPEG-only.** For non-JPEG fall-back-to-original paths, EXIF cannot be stripped — surface this honestly in the failure dialog ("Send original — metadata may be present" vs JPEG's "Send original — EXIF stripped"). (sec) +4. **ICC profile loss on iPhone Display P3 photos.** Stock ImageIO JPEG writer drops ICC profile → visible ~5–8 ΔE desaturation on greens/blues. Phase 1 acceptance now requires ICC preservation. (perf) +5. **Settings storage pattern mismatch.** Plan said "mirror customFeeds StateFlow auto-save in setter" but that pattern lives in `SearchHistoryStore.kt` and `FeedDefinitionRepository`, NOT in `DesktopPreferences` (which is plain getter/setter). Introduce a dedicated `ImageCompressionStore` class. (patterns) +6. **`CompositionLocal` for cross-cutting settings.** 5+ read sites justify `LocalCompressionSettings = staticCompositionLocalOf()` provided once at `App()` level. (patterns) +7. **Compose component choices.** Replace `DropdownMenu` for quality with `SingleChoiceSegmentedButtonRow` (codebase convention: see `TorSettingsSection.kt:95`). Replace `AlertDialog` for failure dialog with `Dialog { Surface }` (AlertDialog state-isolation trap from `custom-feeds-alertdialog.md`). Use `FilterChip` for the per-post override pill (matches `AccountSwitcherDropdown.kt:111`). Use `.collectAsState()` (NOT `collectAsStateWithLifecycle`, which is Android-only) and never read `.value` inside composition. (compose-expert) +8. **Coroutines / cancellation.** `Thumbnails.of` is blocking and not cancellable mid-decode. Wrap with `ensureActive()` between stages, run on `Dispatchers.Default.limitedParallelism(1)` (CPU-bound + serial), and put cleanup behind `NonCancellable` on exception paths. Wait on user choice via `CompletableDeferred`. (coroutines) +9. **AWT headless mode.** `-Djava.awt.headless=true` must be set on CLI + tests before any ImageIO touch — defensive against macOS Dock-bounce / GUI-thread side effects from future transitive plugins. (amy) +10. **Temp dir on Linux tmpfs.** `java.io.tmpdir` defaults to `/tmp`, a tmpfs sized at ~50 % of RAM. Big decoded buffers there are an OOM-by-tmpfs risk on small VMs. Use `~/.amethyst/tmp/` (created mode 0700) — SSD-backed, single-user, no symlink race. Add boot-time orphan sweep (>24 h). (perf + sec + data-integrity) +11. **Phase serialization.** P4/P5/P6/P7/P8 all edit `ComposeNoteDialog`. Original "parallel" claim was unrealistic. New estimate: ~7 working days serialized. (architecture) +12. **Architecture: split responsibilities.** `MediaCompressor` retains its single EXIF-strip op; new `ImageReencoder` object handles re-encode + downscale + sniff. Returns `sealed ReencodeResult` (`Reencoded(File)` | `PassThrough(reason)` | `Refused(CompressionException)`) so the orchestrator collapses to one `when`. (architecture) +13. **Modern JPEG quality values.** 2014 Android values (q=40/50/80/90) are perceptually obsolete on 2026 displays. New presets use 0.65 / 0.75 / 0.90. (research) +14. **JFR `ImageCompressionEvent`** for memory/perf profiling on JDK 21 — ~10 lines, queryable via `jfr view`. (research) +15. **Supply chain pinning.** Add `verification-metadata.xml` for the two new deps; OWASP dep-check in CI; monitor TwelveMonkeys releases. (sec) + +### Scope cuts from review (simplicity) + +- **WebP output** dropped (no library exists) +- **HEIC input** dropped (no library exists) +- **5 quality presets → 3** (LOW, MEDIUM, DESKTOP_HIGH). The Android "Uncompressed (640px @ q=90)" quirk goes away — confusing on desktop. "High (640@80)" gets folded into "Medium" with modernized quality numbers. The brainstorm's preset choice was based on the assumption Android parity = value; review challenged this and we're cutting. +- **TIFF/BMP input** dropped (vanishingly rare on Nostr; fail-loud "format not supported" if encountered) +- **Before/After compare dialog (Phase 7)** dropped from v1 (YAGNI per simplicity; Compose state-isolation trap per memory; users have lived without on mobile for years). Moved to Future Considerations. +- **`OutputCodec` enum + setting** dropped (only JPEG remains) +- **Dedicated `Compressing` tracker state** simplified to inline text on existing `Uploading` state + +### Carried-forward decisions that survived review + +- Pipeline lives in `commons/src/jvmMain/` (KMP placement validated; AWT-loaded by existing code; CLI compatible after headless fix) +- Per-post override scope = whole post (no per-image override) +- Strip-EXIF by default + toggle (always-apply on bypass path too) +- Fail-loud + confirm-to-bypass on compression failure (but as `Dialog { Surface }`, not `AlertDialog`) +- Clipboard paste integrated; no PNG round-trip; filename `paste-YYYYMMDD-HHMMSS.jpg` +- Animated GIF / WebP / SVG pass through byte-identical (sniffer + `ReencodeResult.PassThrough`) +- AVIF input refused with "format not supported" (sniffer detects ftyp box) +- 50 MP `InputTooLarge` guard kept (cheap + critical against memory bombs) +- Sealed `CompressionException` hierarchy kept (`initCause` fix for chain preservation) + +--- + +## Overview + +Desktop currently uploads raw image bytes — a 4K screenshot or large +camera JPEG ships at full size to Blossom servers and across every relay. +This plan adds a JPEG-only re-encode/downscale pipeline shared between +desktop and the Amy CLI, with 3 quality presets (Low / Medium / Desktop +High), per-post override, EXIF-strip-by-default, clipboard-paste +integration, and a fail-loud failure dialog. WebP encoding and HEIC +input are out of scope for v1 (no pure-Java implementation exists in +2026). Android keeps its existing Zelory-based pipeline unchanged. + +(see brainstorm: [docs/brainstorms/2026-06-08-desktop-image-compression-brainstorm.md](../brainstorms/2026-06-08-desktop-image-compression-brainstorm.md)) + +## Problem Statement + +Today's desktop upload flow has no compression: + +```kotlin +// desktopApp/.../ComposeNoteDialog.kt:430 +orchestrator.upload(file, alt, serverBaseUrl, signer, stripExif = true) + +// commons/.../UploadOrchestrator.kt:45-86 +suspend fun upload(file, alt, serverBaseUrl, signer, stripExif = true): UploadResult { + val processedFile = if (stripExif) MediaCompressor.stripExif(file) else file + val metadata = MediaMetadataReader.compute(processedFile) + // ... auth, upload, delete +} +``` + +`MediaCompressor.stripExif()` is JPEG-only and uses `deleteOnExit()` +which leaks temp files in long-running desktop sessions +(`docs/temp-file-cleanup-analysis.md:25-31`). + +User symptoms: +1. Large JPEGs stall on slow uplinks. +2. Pasted screenshots upload as huge PNGs. +3. EXIF (including GPS) ships untouched on any non-JPEG. +4. Multi-image posts give no progress signal during compression. + +## Proposed Solution + +Introduce a JPEG-only re-encode pipeline in `commons/src/jvmMain/` built +on **Thumbnailator 0.4.21**, called from `UploadOrchestrator.upload()` +ahead of EXIF strip. Settings live in a new `ImageCompressionStore` +(StateFlow-backed wrapper over `DesktopPreferences`), surfaced through a +`LocalCompressionSettings` CompositionLocal, edited in a new "Media" +section of Desktop Settings. The compose dialog reads the default and +lets the user override per-post via a `FilterChip` + `DropdownMenu`. +Clipboard paste flows through the same pipeline (no PNG temp +round-trip). Failures surface in a `Dialog { Surface }` with per-file +"Send Original" or "Cancel" controls — and "Send Original" still strips +EXIF when source is JPEG. + +### Why this approach + +- **Pipeline in `commons/jvmMain`** — Amy CLI can reuse via the existing `UploadOrchestrator` it already calls from `DmCommands.kt:163`. Android keeps Zelory (separately-reviewable migration later). +- **JPEG-only v1** — forced by the lack of a pure-Java WebP encoder, but also simpler and universally compatible. +- **3 presets with modernized quality values** — Low (640 @ q=0.65), Medium (640 @ q=0.75), Desktop High (1920 @ q=0.90). 2014 Android values would produce visibly bad results on 2026 displays. +- **Thumbnailator** — pure-Java, MIT, progressive bilinear scaling, mature; widely used in JVM CMSes (Confluence, JIRA). +- **Strip EXIF by default** — privacy-first; always applies including the fail-loud "Send Original" bypass for JPEGs. +- **Fail loudly, not silently** — Android silently passes original on error; desktop surfaces a dialog so the user knowingly chooses. +- **`ImageReencoder` separate from `MediaCompressor`** — SRP. `MediaCompressor` stays a one-method utility (EXIF strip); `ImageReencoder` handles re-encode + downscale + sniff + format policy. + +## Current State Audit + +### What already exists (reuse, don't duplicate) + +| Component | Location | Status | +|---|---|---| +| `MediaCompressor.stripExif()` | `commons/src/jvmMain/.../service/upload/MediaCompressor.kt:28-50` | Unchanged. Fix `deleteOnExit()` leak. | +| `UploadOrchestrator.upload()` | `commons/.../service/upload/UploadOrchestrator.kt:45-86` | Modify — add `quality` param, slot `ImageReencoder` ahead of EXIF strip, eager temp cleanup | +| `MediaMetadataReader.compute()` | `commons/.../service/upload/MediaMetadata.kt` | Use as-is | +| `ClipboardPasteHandler` | `desktopApp/.../ui/media/ClipboardPasteHandler.kt:43` | Modify — return `BufferedImage`, no PNG round-trip, no `deleteOnExit()` | +| `MediaAttachmentRow` | `desktopApp/.../ui/media/MediaAttachmentRow.kt` | Unchanged (quality chip lives next to `ServerSelector`, not here) | +| `MediaServerSettings` | `desktopApp/.../ui/settings/MediaServerSettings.kt:70-98` | Template shape only — `ImageCompressionSettings` follows it | +| `SearchHistoryStore` | `desktopApp/.../SearchHistoryStore.kt` | **Canonical pattern** — `ImageCompressionStore` mirrors this | +| `DesktopUploadTracker` | `desktopApp/.../service/upload/DesktopUploadTracker.kt:27-53` | Reuse — append "Processing N/M" to existing `Uploading` text rather than new state | +| `DesktopPreferences` | `desktopApp/.../DesktopPreferences.kt` | Add 2 plain keys (read/written by `ImageCompressionStore`) | +| `ComposeNoteDialog:326-350` | `desktopApp/.../ui/ComposeNoteDialog.kt` | Add `QualitySelectorChip` next to `ServerSelector` in the existing options row | +| `LocalDesktopCache`, `LocalRelayManager` etc. | `desktopApp/.../Main.kt` (App scope) | Pattern reference — `LocalCompressionSettings` joins this set | +| Material3 `AlertDialog` | `ForceLogoutDialog`, `TorSettingsDialog` | Used for trivial confirms only | +| Material3 `Dialog { Surface }` | `FeedBuilderDialog.kt` | Used for richer content (failure dialog follows this) | +| Material3 `SingleChoiceSegmentedButtonRow` | `TorSettingsSection.kt:95`, `FeedBuilderDialog.kt:191` | Used for ordered enum settings (quality preset uses this) | +| `FilterChip` + anchored `DropdownMenu` | `AccountSwitcherDropdown.kt:111`, `DeckSidebar.kt:402` | Pattern for per-post override pill | +| `MaterialSymbols` (icons) | `commons/commonMain/.../icons/symbols/MaterialSymbols.kt` | New codepoints require font regen via `./tools/material-symbols-subset/subset.sh` | + +### Known gotchas the plan must fix + +1. **`deleteOnExit()` leaks** in `MediaCompressor.kt:44` and `ClipboardPasteHandler.kt:43`. Eager delete in `try/finally` (with `NonCancellable` on cancel path). +2. **AlertDialog state isolation** — `LaunchedEffect` and `produceState` don't fire inside `AlertDialog.text`. All effects hoisted to parent; dialogs receive plain values. +3. **`Thumbnails.of(file)` decodes internally** — bypasses our pixel-count guard. We do the read ourselves with `ImageReader.getWidth(0)` first. +4. **CLI AWT init** — `-Djava.awt.headless=true` set on CLI `application{}` block, CLI `Main.kt` top, and `:commons:jvmTest` JVM args. +5. **ICC profile preservation** — read ICC via `IIOMetadata`, embed in output via APP2 marker. +6. **macOS Display P3** — see #5. Without ICC preservation, P3 photos visibly desaturate. +7. **Multi-image batch on Linux** — temp dir = `~/.amethyst/tmp/`, not `/tmp` (tmpfs). +8. **Sniffer for animated WebP** must check `VP8X` flag byte (bit 1) in addition to `ANIM` chunk. + +## Technical Approach + +### Architecture + +``` +ComposeNoteDialog (existing) + ├─ activeQuality = LocalCompressionSettings.current.quality.collectAsState() + ├─ perPostOverride: CompressionQuality? (resets on dispose AND on send) + └─ Send (rememberCoroutineScope, dialog-scoped): + for each (idx, file) in attachedFiles: + tracker.update(Uploading(file = "...", subtext = "Processing $idx/$total")) + try { + result = ImageReencoder.reencode(file, quality, keepExif) // suspend + } catch (CompressionException) { + failures += FailedAttachment(file, exception, file.length()) + continue + } + when (result) { + is ReencodeResult.Reencoded -> orchestrator.upload(result.file, alt, ...) + is ReencodeResult.PassThrough -> orchestrator.upload(file, alt, ...) + is ReencodeResult.Refused -> impossible (thrown above) + } + if (failures.isNotEmpty()) { + val choice = CompletableDeferred() + pendingFailureDialog.value = FailureDialogState(failures, choice) + when (choice.await()) { + SendOriginal -> for (f in failures) orchestrator.upload(maybeStripExif(f), ...) + Cancel -> return + } + } + perPostOverride = null +``` + +``` +ImageReencoder (commons/.../service/upload/ImageReencoder.kt) [new] + sealed class ReencodeResult { + data class Reencoded(val tempFile: File) : ReencodeResult() + data class PassThrough(val reason: PassReason) : ReencodeResult() + // PassReason: Animated, Svg, BelowThreshold, UncompressedPreset + } + // Refused throws CompressionException instead of returning Refused. + // Keeps the "happy path" branch-free in the orchestrator. + + suspend fun reencode( + source: File, + quality: CompressionQuality, + ): ReencodeResult = withContext(compressionDispatcher) { + ensureActive() + val format = ImageFormatSniffer.sniff(source) + when (format) { + is GifAnimated, AnimatedWebP, Svg -> return@withContext PassThrough(Animated|Svg) + is Avif -> throw CompressionException.UnsupportedFormat("avif") + else -> { /* re-encode */ } + } + val (w, h) = readHeaderDims(source, format) ?: throw EncodeFailed(...) + if (w.toLong() * h > MAX_INPUT_PIXELS) throw InputTooLarge(w.toLong() * h) + if (w <= quality.maxDim && h <= quality.maxDim && + format is Jpeg && quality == LOW) { + // tiny shortcut: source already smaller than target — re-encode for q + } + ensureActive() + val img = decodeWithSubsampling(source, w, h, quality.maxDim) + ensureActive() + val temp = createTempFile() + try { + Thumbnails.of(img) + .size(quality.maxDim, quality.maxDim) + .outputFormat("jpg") + .outputQuality(quality.jpegQuality) + .useExifOrientation(true) + .imageType(BufferedImage.TYPE_INT_RGB) + // ICC: copy from input metadata; embed in output APP2 marker + .toFile(temp) + ReencodeResult.Reencoded(temp) + } catch (t: Throwable) { + withContext(NonCancellable) { temp.delete() } + throw if (t is CompressionException) t else EncodeFailed(t) + } + } + + private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1) +``` + +``` +UploadOrchestrator.upload() (modified) + + suspend fun upload( + file: File, alt: String?, serverBaseUrl: String, signer: NostrSigner, + stripExif: Boolean = true, + quality: CompressionQuality = CompressionQuality.DEFAULT, + ): UploadResult { + val reencodeResult = ImageReencoder.reencode(file, quality) + val finalFile = when (reencodeResult) { + is Reencoded -> reencodeResult.tempFile.also { temp = it } + is PassThrough -> { + if (stripExif && file.name.endsWith(".jpg|.jpeg")) { + MediaCompressor.stripExif(file).also { temp = it.takeIf { it != file } } + } else { + file + } + } + } + try { + val metadata = MediaMetadataReader.compute(finalFile) + val auth = BlossomAuth.createUploadAuth(metadata.sha256, metadata.size, alt ?: ..., signer) + val result = client.upload(finalFile, metadata.mimeType, serverBaseUrl, auth) + return UploadResult(result, metadata) + } finally { + withContext(NonCancellable) { temp?.delete() } + } + } +``` + +### Data model + +```kotlin +// commons/src/jvmMain/.../service/upload/CompressionQuality.kt +enum class CompressionQuality( + val displayName: String, + val maxDim: Int, + val jpegQuality: Float, +) { + LOW("Low", 640, 0.65f), + MEDIUM("Medium", 640, 0.75f), + DESKTOP_HIGH("Desktop High", 1920, 0.90f), + ; + companion object { val DEFAULT = DESKTOP_HIGH } +} + +// commons/src/jvmMain/.../service/upload/ImageFormat.kt +sealed class ImageFormat { + object Jpeg : ImageFormat() + object Png : ImageFormat() + object Bmp : ImageFormat() + object Tiff : ImageFormat() + data class Gif(val animated: Boolean) : ImageFormat() + data class WebP(val animated: Boolean) : ImageFormat() + object Svg : ImageFormat() + object Avif : ImageFormat() // refused + data class Unknown(val mime: String) : ImageFormat() +} + +// commons/src/jvmMain/.../service/upload/CompressionException.kt +sealed class CompressionException(message: String, cause: Throwable? = null) + : Exception(message, cause) { + init { if (cause != null) initCause(cause) } // chain preservation + class UnsupportedFormat(format: String) : + CompressionException("Format not supported: $format") + class InputTooLarge(pixels: Long) : + CompressionException("$pixels pixels exceeds 50 MP limit") + class EncodeFailed(cause: Throwable) : + CompressionException("Image encode failed: ${cause.message}", cause) +} +``` + +```kotlin +// desktopApp/src/jvmMain/.../service/ImageCompressionStore.kt +class ImageCompressionStore(private val prefs: DesktopPreferences) { + private val _quality = MutableStateFlow(prefs.imageQualityRaw) + val quality: StateFlow = _quality.asStateFlow() + fun setQuality(q: CompressionQuality) { + _quality.value = q + prefs.imageQualityRaw = q + } + private val _stripExif = MutableStateFlow(prefs.stripExifOnUpload) + val stripExif: StateFlow = _stripExif.asStateFlow() + fun setStripExif(v: Boolean) { + _stripExif.value = v + prefs.stripExifOnUpload = v + } +} + +// desktopApp/src/jvmMain/.../Main.kt — alongside existing CompositionLocals +val LocalCompressionSettings = staticCompositionLocalOf { + error("LocalCompressionSettings not provided") +} +``` + +### Concurrency model + +- **Dispatcher**: `private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1)` — CPU-bound; serial enforced regardless of caller. +- **Cancellation**: `ensureActive()` at format-sniff, post-header, post-decode boundaries. Pure ImageIO `read(...)` and Thumbnailator are blocking and non-interruptible mid-frame; we accept up to ~1 s of "ghost work" after cancel (mitigated by per-stage `ensureActive()` and `MAX_INPUT_PIXELS` cap). +- **Cleanup on cancel**: `try { ... } catch (t) { withContext(NonCancellable) { temp.delete() }; throw t }`. Plain `File.delete()` is non-suspending so a plain `finally` would also work; `NonCancellable` is belt-and-braces. +- **Failure dialog await**: `CompletableDeferred` in dialog-scoped state, completed by Confirm/Cancel button callbacks. Cancellation-aware. Pattern from `compose-side-effects` skill. +- **Lifetime**: dialog-scoped. Closing `ComposeNoteDialog` cancels compress + upload. Background-completion (the March 2026 plan's "app-scoped upload") is out of scope for v1. + +### Pre-decode guard (memory-bomb defense, perf optimization) + +```kotlin +private fun decodeWithSubsampling( + file: File, w: Int, h: Int, targetMaxDim: Int, +): BufferedImage = ImageIO.createImageInputStream(file).use { iis -> + val reader = ImageIO.getImageReaders(iis).next().apply { input = iis } + val pixels = w.toLong() * h + if (pixels > MAX_INPUT_PIXELS) throw InputTooLarge(pixels) + val downscaleRatio = ceil( + sqrt(pixels.toDouble() / (targetMaxDim.toDouble().pow(2))) + ).toInt().coerceAtLeast(1) + val param = reader.defaultReadParam.apply { + setSourceSubsampling(downscaleRatio, downscaleRatio, 0, 0) + } + try { reader.read(0, param) } finally { reader.dispose() } +} +``` + +This is the critical bit. Subsampling at decode time means a 50 MP source +decodes at ~2 MP for the 1920px target — 25× less heap, 5–10× faster. + +### Color management (ICC preservation) + +```kotlin +// pseudocode — concretize in Phase 1 +val srcIcc: ICC_Profile? = readIccFromMetadata(file) +val out = Thumbnails.of(img).size(...).outputQuality(q).asBufferedImage() +val writer = ImageIO.getImageWritersByMIMEType("image/jpeg").next() +val meta = writer.getDefaultImageMetadata(...) +srcIcc?.let { embedAsApp2(meta, it) } // ICC marker is multi-segment APP2 +writer.write(meta, IIOImage(out, null, null), null) +``` + +Verify on a Display P3 fixture (iPhone photo) that re-encoded output +shows the same color profile in `exiftool -ICC_Profile:all`. Without +this, green grass / blue sky shift visibly. + +### Threat model (new section per security review) + +This feature only processes images the user **explicitly attached** +(file picker, drag-drop, clipboard paste). It MUST NOT be reused for +parsing inbound network bytes (e.g., previewing relay-fetched images) +without re-review — TwelveMonkeys pure-Java parsers haven't been +publicly fuzzed at scale. If a future feature wants to use this +pipeline on inbound bytes, add an AFL/Jazzer fuzz pass first. + +### Dependency Changes + +```toml +# gradle/libs.versions.toml — additions +[versions] +thumbnailator = "0.4.21" + +[libraries] +thumbnailator = { module = "net.coobird:thumbnailator", version.ref = "thumbnailator" } +``` + +```kotlin +// commons/build.gradle.kts — jvmMain sourceSet +implementation(libs.thumbnailator) +``` + +```xml +// gradle/verification-metadata.xml — pin SHA-256 of thumbnailator-0.4.21.jar +``` + +```kotlin +// cli/build.gradle.kts — defensive headless +application { + applicationDefaultJvmArgs = (applicationDefaultJvmArgs ?: emptyList()) + + listOf("-Djava.awt.headless=true") +} + +// commons/build.gradle.kts — jvmTest task config +tasks.named("jvmTest") { + jvmArgs("-Djava.awt.headless=true") +} +``` + +**One** new dependency. TwelveMonkeys, sejda webp-imageio, imageio-heif +all dropped per review findings. JNI count: 0. + +### File placement summary + +| File | Status | Location | +|---|---|---| +| `MediaCompressor.kt` | Modify (fix `deleteOnExit`) | `commons/src/jvmMain/.../service/upload/` | +| `UploadOrchestrator.kt` | Modify (add `quality` param + reencode call + eager cleanup) | `commons/src/jvmMain/.../service/upload/` | +| `CompressionQuality.kt` | **New** | same package | +| `ImageFormat.kt` + `ImageFormatSniffer.kt` | **New** | same package | +| `CompressionException.kt` | **New** | same package | +| `ImageReencoder.kt` | **New** | same package | +| `ImageReencoderTest.kt` + `ImageFormatSnifferTest.kt` + `UploadOrchestratorTest.kt` | **New** | `commons/src/jvmTest/...` | +| `DesktopPreferences.kt` | Modify (add 2 simple keys) | `desktopApp/.../` | +| `ImageCompressionStore.kt` | **New** | `desktopApp/.../service/` | +| `LocalCompressionSettings` declaration in `Main.kt` | Add CompositionLocal | `desktopApp/.../` | +| `ImageCompressionSettings.kt` (composable) | **New** | `desktopApp/.../ui/settings/` | +| `RelaySettingsScreen` integration in `Main.kt:1648` | Modify | `desktopApp/.../` | +| `QualitySelectorChip.kt` (composable) | **New** | `desktopApp/.../ui/media/` | +| `ComposeNoteDialog.kt:326-350` | Modify (chip placement + per-post override + send-loop + failure flow) | `desktopApp/.../ui/` | +| `CompressionFailureDialog.kt` | **New** (`Dialog { Surface }`, not `AlertDialog`) | `desktopApp/.../ui/media/` | +| `ClipboardPasteHandler.kt` | Modify (return `BufferedImage`, no PNG temp, no `deleteOnExit`) | `desktopApp/.../ui/media/` | +| `cli/build.gradle.kts` | Modify (headless arg) | `cli/` | +| `cli/Main.kt` | Modify (headless set before any class load) | `cli/` | +| `commons/build.gradle.kts` jvmTest | Modify (headless arg) | `commons/` | +| `tools/material-symbols-subset/...` | Run regen if any new codepoint introduced | — | + +## Implementation Phases + +Total estimate: **7 working days serialized**. P3/P4/P5/P6/P7 all edit `ComposeNoteDialog` — parallelization not realistic. + +### Phase 0 — Dependency + headless + smoke test (0.5 d) + +| # | Task | File | +|---|---|---| +| 0.1 | Add Thumbnailator 0.4.21 to libs.versions.toml + verification metadata + commons jvmMain | `gradle/libs.versions.toml`, `commons/build.gradle.kts`, `gradle/verification-metadata.xml` | +| 0.2 | Add `-Djava.awt.headless=true` to CLI `application{}` block + `cli/Main.kt` first line + `commons:jvmTest` jvmArgs | `cli/build.gradle.kts`, `cli/Main.kt`, `commons/build.gradle.kts` | +| 0.3 | Smoke test: encode a 4032×3024 JPEG through Thumbnailator @ q=0.9, assert dims/bytes | `commons/src/jvmTest/.../smoke/CompressionSmokeTest.kt` | +| 0.4 | Smoke test: decode + re-encode a Display P3 sample JPEG, assert ICC profile present in output | same | +| 0.5 | Run `:commons:compileKotlinJvm` and `:cli:assemble` and `:desktopApp:compileKotlin` — confirm everything compiles | n/a | + +**Gate:** if ICC preservation can't work in JVM 21 stock ImageIO writer, document the gap and accept color shift on P3 sources (downgraded acceptance criterion). + +### Phase 1 — Core re-encode pipeline (2 d) + +| # | Task | File | +|---|---|---| +| 1.1 | `CompressionQuality` enum (3 presets + `DEFAULT = DESKTOP_HIGH`) | `commons/.../upload/CompressionQuality.kt` | +| 1.2 | `ImageFormat` sealed class + `ImageFormatSniffer` (magic bytes for JPEG/PNG/GIF/WebP/SVG/BMP/TIFF/AVIF; GIF NETSCAPE2.0 + WebP VP8X bit-1 for animation) | `commons/.../upload/ImageFormat.kt`, `ImageFormatSniffer.kt` | +| 1.3 | `CompressionException` sealed hierarchy with `initCause` chain | `commons/.../upload/CompressionException.kt` | +| 1.4 | `ImageReencoder` object: `reencode(File, CompressionQuality): ReencodeResult` | `commons/.../upload/ImageReencoder.kt` | +| 1.5 | Pre-decode pixel guard via `ImageReader.getWidth(0)/getHeight(0)` + `setSourceSubsampling` | same | +| 1.6 | ICC profile preservation: read source ICC via `IIOMetadata`, embed in output APP2 marker | same | +| 1.7 | `compressionDispatcher = Dispatchers.Default.limitedParallelism(1)` + `ensureActive()` between stages | same | +| 1.8 | `MAX_INPUT_PIXELS = 50_000_000` constant (tunable via system property) | same | +| 1.9 | Use `~/.amethyst/tmp/` (created mode 0700) for temp files. Helper `amethystTempDir(): Path` resolving once at process start | `commons/.../upload/AmethystTempDir.kt` (new) | +| 1.10 | Boot-time orphan sweep: on first call, delete `amethyst_compress_*` and `amethyst_paste_*` older than 24 h | same | +| 1.11 | Modify `UploadOrchestrator.upload(quality = DEFAULT)` — call `ImageReencoder.reencode`, branch on `ReencodeResult`, eager `try/finally` cleanup (no `deleteOnExit`) | `commons/.../upload/UploadOrchestrator.kt` | +| 1.12 | Remove `deleteOnExit()` from `MediaCompressor.stripExif()` — caller (`UploadOrchestrator`) owns cleanup | `commons/.../upload/MediaCompressor.kt` | +| 1.13 | Unit tests: 3 presets × monotonic file size + correct max-dim | `ImageReencoderTest.kt` | +| 1.14 | Unit tests: never-upscale (320×240 → DESKTOP_HIGH dims unchanged) | same | +| 1.15 | Unit tests: 51 MP synthetic input → `InputTooLarge` | same | +| 1.16 | Unit tests: animated GIF → `ReencodeResult.PassThrough(Animated)`; orchestrator uploads original bytes | `UploadOrchestratorTest.kt` | +| 1.17 | Unit tests: animated WebP via VP8X bit-1 → PassThrough | same | +| 1.18 | Unit tests: SVG → PassThrough | same | +| 1.19 | Unit tests: AVIF (ftyp box) → `UnsupportedFormat` | same | +| 1.20 | Unit tests: `MediaMetadataReader.compute()` runs on the actually-uploaded file in all 3 orchestrator branches; `metadata.hash == sha256(uploadedBody)` | `UploadOrchestratorTest.kt` | +| 1.21 | Unit tests: ICC profile present in output for Display P3 input | `ImageReencoderTest.kt` | +| 1.22 | Unit tests: no `/tmp/amethyst_*` files after upload completion | `UploadOrchestratorTest.kt` | + +**Deliverable:** all compression logic shipped & tested in commons. No desktop UI changes yet. + +### Phase 2 — Settings storage + CompositionLocal (0.5 d) + +| # | Task | File | +|---|---|---| +| 2.1 | Add 2 plain keys to `DesktopPreferences`: `KEY_IMAGE_QUALITY` (default `"DESKTOP_HIGH"`), `KEY_IMAGE_STRIP_EXIF` (default `true`). Plain getters/setters; enum round-trips via `name`. | `desktopApp/.../DesktopPreferences.kt` | +| 2.2 | New `ImageCompressionStore(prefs)` class mirroring `SearchHistoryStore.kt` — wraps prefs in MutableStateFlow, exposes read-only StateFlow, setter writes both | `desktopApp/.../service/ImageCompressionStore.kt` | +| 2.3 | New `val LocalCompressionSettings = staticCompositionLocalOf()` | `desktopApp/.../Main.kt` | +| 2.4 | Provide `LocalCompressionSettings` at `App()` level alongside existing CompositionLocals; instantiate once | same | +| 2.5 | Unit test: round-trip each setting through a `Preferences.userNodeForTesting()`-equivalent mock | `ImageCompressionStoreTest.kt` | + +### Phase 3 — Settings UI (0.75 d) + +| # | Task | File | +|---|---|---| +| 3.1 | New `ImageCompressionSettings()` composable: titled section with quality `SingleChoiceSegmentedButtonRow` (Low / Medium / Desktop High) + EXIF `Switch`. Reads from `LocalCompressionSettings.current.quality.collectAsState()` and `.stripExif.collectAsState()`. | `desktopApp/.../ui/settings/ImageCompressionSettings.kt` | +| 3.2 | Help text under each control. EXIF: "Removes camera, GPS, timestamp before upload. Turn off only if you want photo metadata preserved." | same | +| 3.3 | Note explaining default applies unless overridden per-post in compose | same | +| 3.4 | Integrate into `RelaySettingsScreen` / Settings flow (Main.kt:1648 region) | `desktopApp/.../Main.kt` | +| 3.5 | Manual UI smoke: launch desktop, open settings, change each control, restart app, confirm persisted | n/a | + +### Phase 4 — ComposeNoteDialog wiring + per-post chip (1.25 d) + +| # | Task | File | +|---|---|---| +| 4.1 | New `QualitySelectorChip` composable: `FilterChip(selected = override != null, label = "Quality: $activeQuality")` + anchored `DropdownMenu` with 3 items + "Reset to default" row | `desktopApp/.../ui/media/QualitySelectorChip.kt` | +| 4.2 | In `ComposeNoteDialog`, hold `var perPostOverride by remember { mutableStateOf(null) }` | `desktopApp/.../ui/ComposeNoteDialog.kt` | +| 4.3 | Place `QualitySelectorChip` in the existing options row (line 326-350) alongside `ServerSelector` (NOT inside `MediaAttachmentRow`) | same | +| 4.4 | Pass `activeQuality` to each `orchestrator.upload(...)` call (line 430 region) | same | +| 4.5 | Reset `perPostOverride = null` after successful send | same | +| 4.6 | `DisposableEffect(Unit) { onDispose { perPostOverride = null } }` for paranoid reset on dialog dismissal (catches the case where dialog state is hoisted in parent rather than re-instantiated) | same | +| 4.7 | `LaunchedEffect` on `attachedFiles.size` — sync the chip's `activeQuality` value display | same | +| 4.8 | Manual test: attach JPEG, choose Low, send → file shrinks; open compose again → control reads Desktop High | n/a | + +### Phase 5 — Batch progress (inline) (0.25 d) + +| # | Task | File | +|---|---|---| +| 5.1 | In `ComposeNoteDialog` send loop, before each `reencode` + `upload`, update tracker text to `"Processing $idx/$total: $filename"`. Reuse existing `Uploading` state — do NOT introduce a new state class | `desktopApp/.../ui/ComposeNoteDialog.kt`, `DesktopUploadTracker.kt` | +| 5.2 | Manual test: 3-image post shows progress 1/3 → 2/3 → 3/3 | n/a | + +### Phase 6 — Clipboard paste integration (0.5 d) + +| # | Task | File | +|---|---|---| +| 6.1 | Refactor `ClipboardPasteHandler.getClipboardFiles()` to return `ClipboardImage(image: BufferedImage, suggestedName: String)` for image flavor, in addition to existing file flavor | `desktopApp/.../ui/media/ClipboardPasteHandler.kt` | +| 6.2 | Remove `deleteOnExit()` and the PNG temp roundtrip — paste returns the BufferedImage directly | same | +| 6.3 | Add `ImageReencoder.reencode(BufferedImage, filenameHint, quality): ReencodeResult` overload | `commons/.../upload/ImageReencoder.kt` | +| 6.4 | In `ComposeNoteDialog` paste handler, route `ClipboardImage` through the BufferedImage overload | `ComposeNoteDialog.kt` | +| 6.5 | Suggested filename: `paste-${ISO8601_BASIC}.jpg` | `ClipboardPasteHandler.kt` | +| 6.6 | Manual test: paste screenshot, send, verify resulting upload's mime is `image/jpeg` and dims look right | n/a | + +### Phase 7 — Fail-loud confirm dialog (0.75 d) + +| # | Task | File | +|---|---|---| +| 7.1 | New `CompressionFailureDialog` using `Dialog { Surface { Column { Text + LazyColumn(failures) + Row(buttons) } } }` (NOT `AlertDialog` — content needs scroll + lifecycle for future per-file actions) | `desktopApp/.../ui/media/CompressionFailureDialog.kt` | +| 7.2 | `data class FailedAttachment(file: File, exception: CompressionException, originalBytes: Long, sourceFormat: ImageFormat)` | same | +| 7.3 | Per-failure row: filename + "could not be compressed: ${e.message}" + "original size: X MB". Show "EXIF will be stripped" tag if source is JPEG and `stripExif=true`; "metadata may be present" if source is non-JPEG | same | +| 7.4 | Confirm button label: `"Send Original" + (failures.size)`; Cancel: `"Cancel Post"` | same | +| 7.5 | In `ComposeNoteDialog`, `var pendingFailureDialog by remember { mutableStateOf(null) }`. On send-loop completion with non-empty failures, create state with `CompletableDeferred`; `choice.await()` suspends; button callbacks call `choice.complete(...)` | `ComposeNoteDialog.kt` | +| 7.6 | "Send Original" path: for each failed attachment, if `stripExif=true` AND source is JPEG, run `MediaCompressor.stripExif(file)` first, then `orchestrator.upload(...)`. For non-JPEG, upload raw (with the dialog text being honest about that) | same | +| 7.7 | Map exception types to friendly labels: `InputTooLarge` → "larger than 50 megapixels"; `UnsupportedFormat` → "format not supported (e.g., AVIF)"; `EncodeFailed` → "encoder failed (${cause})" | `CompressionFailureDialog.kt` | +| 7.8 | Unit test: 2 failed + 3 succeeded → dialog renders 2 entries; on confirm, all 5 events publish (2 with stripExif applied for JPEG, 3 with compressed bytes) | `ComposeNoteDialogTest.kt` (if existing pattern allows) | + +### Phase 8 — Manual QA + edge cases (1 d) + +| # | Task | +|---|---| +| 8.1 | 10 MB JPEG @ Desktop High → < 2 MB output | +| 8.2 | Same JPEG @ Low → < 200 KB output | +| 8.3 | 320×240 source @ DESKTOP_HIGH → dims unchanged, re-encoded at q=0.9 | +| 8.4 | Animated GIF → byte-identical pass-through (sha256 match against original) | +| 8.5 | Animated WebP (with `ANIM` chunk + VP8X bit-1) → pass-through | +| 8.6 | SVG → pass-through | +| 8.7 | AVIF → `CompressionFailureDialog` with "format not supported" | +| 8.8 | 51 MP synthetic PNG → `CompressionFailureDialog` with "larger than 50 megapixels" | +| 8.9 | Corrupt JPEG → `CompressionFailureDialog` with encoder error | +| 8.10 | Display P3 iPhone JPEG → output retains ICC profile (`exiftool -ICC_Profile:all` shows non-empty) | +| 8.11 | Paste screenshot → upload succeeds; filename `paste-…jpg`; mime `image/jpeg` | +| 8.12 | 3-image post → progress shows 1/3, 2/3, 3/3 | +| 8.13 | Per-post override applies to all 3 attachments | +| 8.14 | Per-post override resets after send | +| 8.15 | Per-post override resets on dialog dismiss without send | +| 8.16 | No `~/.amethyst/tmp/amethyst_*` files remain after a 5-upload session + close | +| 8.17 | Boot sweep deletes a manually-planted orphan > 24 h old; leaves a newer one | +| 8.18 | Mid-send dialog dismissal cancels remaining attachments; no orphan temps | +| 8.19 | `./gradlew :amethyst:compileDebugKotlin` passes (Android unbroken) | +| 8.20 | `./gradlew :cli:assemble` passes (CLI unbroken) | +| 8.21 | `./gradlew spotlessApply` produces no diff | +| 8.22 | Pre-commit hooks pass (no `--no-verify`) | + +### Phase Dependency Graph + +``` +P0 ─→ P1 ─→ P2 ─→ P3 ─→ P4 ─→ P5 ─→ P6 ─→ P7 ─→ P8 + ↘ (P1.20 sha-correctness covers P7's bypass path) +``` + +P3 through P7 all touch `ComposeNoteDialog` and/or its options row, so +they serialize. Total realistic: **7 working days for one engineer.** + +## Alternative Approaches Considered + +| Approach | Why Rejected | +|---|---| +| Migrate Android off Zelory now | Two-year-stable behavior; risk:reward bad. Separate plan later. | +| imgscalr instead of Thumbnailator | Worse downscale quality at high ratios. | +| `javax.imageio.ImageIO` + `Graphics2D`, no new dep | Hand-rolling multi-pass progressive scaling = more code we own. | +| JNI libwebp for WebP output | Bundles native libs per OS. Defer until users actually ask. | +| JNI libheif for HEIC input | Same — defer; users can convert client-side. | +| sejda webp-imageio | JNI under the hood + abandoned (last release 2017). | +| TwelveMonkeys `imageio-heif` | Doesn't exist. | +| Auto-codec detection per relay | Premature; codec is JPEG-only in v1. | +| Smart-auto (size-based) compression | Rejected at brainstorm — predictability over magic. | +| NIP-78 sync of settings | Heavier; deferred. | +| 5 quality presets (incl. Android-parity "Uncompressed 640@90") | Confusing on desktop; review forced cut. | +| Drag-drop pre-encode preview | Out of scope per brainstorm Q9. | +| Per-image override | Power-user; v1 is per-post. | +| Compare dialog v1 | YAGNI per simplicity; Compose state-isolation trap. Moved to Future. | +| `Compressing` tracker state class | Cosmetic; inline text on existing `Uploading` works. | +| `AlertDialog` for failure dialog | State-isolation trap; switched to `Dialog { Surface }`. | +| `DropdownMenu` for quality preset | Codebase prefers `SingleChoiceSegmentedButtonRow` for ordered enum settings. | +| Polluting `MediaAttachmentRow` with post-level options | Conflates "attachments list" with "post-level config." Use the existing options row. | + +## System-Wide Impact + +### Interaction Graph + +``` +User clicks Send in ComposeNoteDialog (dialog-scoped coroutineScope) + ├─ for each (idx, file): + │ update tracker text "Processing idx/total: name" + │ val r = ImageReencoder.reencode(file, activeQuality) [compressionDispatcher] + │ ├─ ensureActive() + │ ├─ sniffer → ImageFormat + │ ├─ if Animated/Svg → PassThrough; if Avif → throw UnsupportedFormat + │ ├─ ImageReader.getWidth(0)/getHeight(0) (pre-decode) + │ ├─ if pixels > MAX → throw InputTooLarge + │ ├─ reader.read(0, paramWithSubsampling) [memory-bounded] + │ ├─ ensureActive() + │ ├─ Thumbnails.of(img).size(maxDim).outputQuality(q).useExifOrientation(true).toFile(temp) + │ └─ embed ICC → Reencoded(temp) + │ catch CompressionException → failures += FailedAttachment(...) + │ if Reencoded → orchestrator.upload(temp, alt, ...); finally cleanup temp + │ if PassThrough → orchestrator.upload(file, alt, ..., stripExif=true) + ├─ if failures.isNotEmpty(): + │ pendingFailureDialog = FailureDialogState(failures, CompletableDeferred()) + │ when (choice.await()): + │ SendOriginal → for each failed: maybeStripExif → orchestrator.upload + │ Cancel → return + └─ perPostOverride = null +``` + +### Error & Failure Propagation + +| Source | Where caught | User-visible behavior | +|---|---|---| +| `InputTooLarge` | At header-dim guard, pre-decode | `CompressionFailureDialog` shows the file, user can send original (still EXIF-stripped if JPEG) | +| `UnsupportedFormat` | At sniffer | same dialog with "format not supported" | +| `EncodeFailed` | At encode step (Thumbnailator throws) | same dialog with `${e.cause.message}` | +| `IOException` (disk full mid-temp-write) | Bubbles up as `EncodeFailed(cause)` | same dialog | +| `OutOfMemoryError` | Should be impossible (pre-decode pixel guard + subsampling). If it does happen, JVM state is undefined — exception escapes; user sees a generic "send failed" toast (existing path) | n/a | +| Blossom upload `IOException` | In `client.upload` (unchanged) | Existing upload error toast | +| Dialog dismiss mid-send | `coroutineScope.cancel()` → `ensureActive()` throws → cleanup in `finally`/`NonCancellable` | Tracker resets | + +### State Lifecycle Risks + +| Risk | Mitigation | +|---|---| +| Temp files orphaned on JVM crash | Boot-time sweep of `~/.amethyst/tmp/amethyst_*` > 24 h old. Plus eager `try { } finally { withContext(NonCancellable) { temp.delete() } }` for normal exit. | +| Compression mid-flight when user cancels | `ensureActive()` between stages catches cancellation; cleanup via `NonCancellable`. Up to ~1 s of ghost work for in-progress decode/encode is acceptable. | +| Per-post override leaks across posts | Reset on send AND in `DisposableEffect.onDispose` when dialog leaves composition. | +| Compare-dialog cache file orphan | Moot — Compare dialog dropped from v1. | +| `Uploading` text stuck after failure | `ComposeNoteDialog` send-loop always resets tracker in a `finally`. | + +### API Surface Parity + +| Surface | Before | After | +|---|---|---| +| `UploadOrchestrator.upload` | `(file, alt, server, signer, stripExif=true)` | `(file, alt, server, signer, stripExif=true, quality=DEFAULT)` — backward-compatible default | +| `MediaCompressor` | EXIF strip only | Unchanged (re-encode lives in new `ImageReencoder`) | +| `ImageReencoder` | n/a | **New** — `reencode(File, quality)` + `reencode(BufferedImage, name, quality)` | +| `CompressionQuality` | n/a | **New** — 3-value enum | +| `CompressionException` | n/a | **New** — sealed with `initCause` | +| Failure mode | Silent fallback on Android | **New** on desktop: fail-loud + confirm-to-bypass | + +### Integration Test Scenarios + +1. JPEG round-trip: 10 MB JPEG → compressed → uploaded → other client renders correctly. +2. Multi-image mixed: 1 PNG + 1 animated GIF + 1 SVG → progress 1/3, 2/3, 3/3; PNG re-encoded; GIF + SVG pass through. +3. AVIF rejection: drop AVIF → failure dialog → user confirms Send Original → raw AVIF uploads. +4. Per-post override applies to all: 3 photos, override Low → all 3 land at 640px / q=0.65. +5. Override reset: post sent → re-open compose → reads Desktop High. +6. Override reset on dismiss: open compose, set Low, dismiss without send, re-open → reads Desktop High. +7. Settings persistence: change default to Low + EXIF off → restart app → defaults persist. +8. No temp leak: 5 uploads + 3 cancels + 2 pastes → `~/.amethyst/tmp/` empty after close. +9. Boot sweep: manually plant 30-h-old file, launch → sweep removes it; plant 1-h-old file → sweep keeps it. +10. Display P3 retention: iPhone P3 JPEG → uploaded file shows ICC in `exiftool`. +11. CLI parity (deferred): `amy upload` reuses pipeline — separate plan; future consideration. +12. Animated GIF byte identity: download from Blossom → sha256 == original. + +## Acceptance Criteria + +### Functional + +- [ ] Desktop Settings → Media section with: Quality (`SingleChoiceSegmentedButtonRow` of Low / Medium / Desktop High), Strip EXIF (`Switch`, default on). +- [ ] Compose dialog shows current default quality + lets user override per-post via `FilterChip` + `DropdownMenu` in the existing options row. +- [ ] Per-post override applies to ALL attachments on the post. +- [ ] Per-post override resets after send AND on dialog dismissal. +- [ ] 3 quality presets: + - LOW: max-dim 640, JPEG q=0.65 + - MEDIUM: max-dim 640, JPEG q=0.75 + - DESKTOP_HIGH: max-dim 1920, JPEG q=0.90 (default) +- [ ] Never upscale — source < preset max-dim is not resized; re-encoded at preset quality. +- [ ] Output is always JPEG (mime `image/jpeg`). +- [ ] HEIC inputs trigger `UnsupportedFormat` → failure dialog (HEIC out of v1). +- [ ] AVIF inputs trigger `UnsupportedFormat` → failure dialog. +- [ ] JPEG / PNG / WebP (static) inputs re-encode. +- [ ] Animated GIF, animated WebP (VP8X bit-1 + ANIM chunk), SVG pass through byte-identical. +- [ ] Inputs > 50 MP trigger `InputTooLarge` → failure dialog. +- [ ] Clipboard paste routes through pipeline; filename `paste-YYYYMMDD-HHMMSS.jpg`. +- [ ] Multi-image posts show "Processing N/M" progress. +- [ ] EXIF stripped by default on JPEG output (re-encode naturally drops; pass-through path invokes `stripExif`). +- [ ] "Keep EXIF" toggle preserves EXIF on JPEG pass-through only (compressed outputs have no EXIF by design). +- [ ] Compression failure → `CompressionFailureDialog` with per-file row + Send Original / Cancel buttons. +- [ ] "Send Original" path applies `stripExif` for JPEG sources when EXIF toggle is on. Non-JPEG sources upload raw and the dialog text says so. +- [ ] Settings persist in `DesktopPreferences` across JVM restarts. +- [ ] CLI: `amy dm send-file` continues to work (uses default `CompressionQuality.DEFAULT` via orchestrator default param). + +### Non-functional + +- [ ] 4032×3024 JPEG compresses end-to-end in < 1 s on 2020+ Apple Silicon. +- [ ] 4032×3024 JPEG at Desktop High → < 2 MB output. +- [ ] Pure-Java; no JNI. Total dep footprint addition ≤ 1 MB (Thumbnailator only). +- [ ] No `deleteOnExit()` in new/modified code paths. +- [ ] No silent compression failures (every failure surfaces a dialog or is a guarded pass-through). +- [ ] Memory: peak heap during compression ≤ ~250 MB for a 50 MP source (with subsampling decode). +- [ ] Display P3 ICC profile preserved on output. +- [ ] CLI runs with `-Djava.awt.headless=true`; no AWT GUI thread spawned. +- [ ] Linux: temp files land in `~/.amethyst/tmp/` (mode 0700), not `/tmp` (tmpfs). + +### Quality Gates + +- [ ] `./gradlew :commons:compileKotlinJvm` passes +- [ ] `./gradlew :commons:jvmTest --tests "*Compression*"` passes +- [ ] `./gradlew :commons:jvmTest --tests "*UploadOrchestrator*"` passes +- [ ] `./gradlew :commons:jvmTest --tests "*ImageReencoder*"` passes +- [ ] `./gradlew :commons:jvmTest --tests "*ImageFormatSniffer*"` passes +- [ ] `./gradlew :commons:jvmTest --tests "*ImageCompressionStore*"` passes +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew :amethyst:compileDebugKotlin` passes (Android unbroken) +- [ ] `./gradlew :cli:assemble` passes +- [ ] `./gradlew spotlessApply` produces no diff +- [ ] Pre-commit hooks pass +- [ ] Manual QA in Phase 8 fully green + +## Success Metrics + +- **Bandwidth saved per upload:** ≥ 70 % size reduction on typical 10 MB JPEG at Desktop High; ≥ 90 % at Medium. Recorded via JFR `ImageCompressionEvent` (input size, output size, peak heap, duration, codec, preset). Queryable via `jfr view ImageCompressionEvent `. +- **Time-to-upload improvement:** ≥ 50 % faster wall-clock on a typical 10 MB photo / 5 Mbps uplink. +- **Failure visibility:** zero `catch (_: Exception)` in compression paths. Every failure surfaces a dialog. +- **Temp file leak rate:** 0 orphans in `~/.amethyst/tmp/` after a session. +- **ICC fidelity:** Display P3 source → output Color profile == source profile (verified via `exiftool` in CI). + +## Dependencies & Risks + +### New dependencies + +| Lib | Version | License | Source | Risk | +|---|---|---|---|---| +| Thumbnailator | 0.4.21 | MIT | net.coobird | Low — mature, widely used in JVM CMSes | + +(Note: NO TwelveMonkeys, NO sejda. Both dropped per review.) + +### Risks + +| Risk | Probability | Impact | Mitigation | +|---|---|---|---| +| ICC preservation gap in stock JPEG writer | Medium | Medium | Phase 0 gate verifies; downgrade acceptance to "no color management v1" if ImageIO can't embed. | +| Thumbnailator memory spike on huge images | Low | Low | Subsampling decode + 50 MP guard. | +| `BufferedImage` color profile differs across JDKs | Low | Low | Pin tests to JDK 21. | +| FilterChip + DropdownMenu UX cluttered in compose row | Low | Low | Phase 4 manual UX review. Can fall back to plain text + on-click dialog. | +| Animated WebP detection misses edge cases | Medium | Low | VP8X bit-1 + `ANIM` chunk per spec; treat false negatives as "compress as static" → user can re-attach. | +| Boot-time temp sweep deletes user file | Very Low | Medium | Strict prefix discipline (`amethyst_compress_*`, `amethyst_paste_*`). Never sweep without prefix match. | +| Headless flag breaks something else on CLI | Very Low | Low | CLI is JVM-only and never touches AWT GUI today; verified. | +| Supply chain | Low | Medium | Pin Thumbnailator SHA-256 in `verification-metadata.xml`. OWASP dep-check in CI. | + +## Future Considerations + +- **WebP output** — once a pure-Java encoder exists, or once we accept JNI libwebp. Settings hook ready (would re-introduce `OutputCodec` enum + per-codec quality). +- **HEIC input** — same: pure-Java decoder when it appears, otherwise JNI libheif. +- **AVIF input** — same as HEIC. +- **Before/After compare dialog** — moved here. Implementation pattern: `Dialog { Surface { ... } }` with `LaunchedEffect` in PARENT scope (per `custom-feeds-alertdialog.md`); cache cleanup via `DisposableEffect`. +- **Pipelined compress + upload** — `Channel(capacity = 1)` between two coroutines saves wall-clock on multi-image posts (~5 s on 5-image post). Safe upgrade after v1. +- **Migrate Android off Zelory** to the shared `commons/jvmMain` pipeline — separate plan; this v1 proves the abstraction. +- **CLI: `amy upload`** — + ``` + amy upload [--server URL] [--quality low|medium|desktop-high] + [--keep-exif] [--alt TEXT] + [--account NAME] [--json] + ``` + Reuses `ImageReencoder` + `UploadOrchestrator.upload`. Exit codes 0 OK · 1 runtime · 2 bad args. Pairs with future `amy post --attach `. +- **NIP-78 cross-device sync of compression settings** — settings follow user between desktops. +- **Per-image override** — power-user mode for mixed-content posts. +- **Custom quality slider (1–100) + arbitrary max-dim** — power-user mode. +- **Video compression** — separate effort; depends on FFmpeg or VLC bundling. +- **Iterative target-size compression** — "compress until ≤ 500 KB." +- **Modern WebP via native libwebp** — escape hatch flag for compression-conscious users on trusted hosts. + +## Documentation Plan + +- [ ] Update `commons/ARCHITECTURE.md` `service/upload` section: list `ImageReencoder` and `CompressionQuality`. +- [ ] Update `docs/temp-file-cleanup-analysis.md`: mark desktop `clipboard_*` and `stripped_*` leaks as FIXED; add the new `~/.amethyst/tmp/` policy. +- [ ] No README needed in `desktopApp/` for one settings section. +- [ ] In-code: minimal KDoc on `ImageReencoder.reencode()` and `CompressionQuality`. No prose paragraphs. + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-06-08-desktop-image-compression-brainstorm.md](../brainstorms/2026-06-08-desktop-image-compression-brainstorm.md) +- Key decisions carried forward (some revised during deepening, see Enhancement Summary above): + 1. Shared pipeline in `commons/jvmMain` (extended via new `ImageReencoder`). + 2. JPEG-only v1 (forced by lack of pure-Java WebP encoder). + 3. 3 quality presets including new Desktop High; default = Desktop High. + 4. HEIC dropped (no library); AVIF detected and refused; animated formats pass through. + 5. Strip EXIF default-on; toggle to keep; bypass path still strips on JPEG. + 6. Fail-loud + confirm-to-bypass on failure (`Dialog { Surface }`, not `AlertDialog`). + 7. Per-post override scope = whole post; resets on send AND on dismiss. + 8. Settings via new `ImageCompressionStore` + `LocalCompressionSettings` CompositionLocal. + 9. Clipboard paste integrated; no PNG temp roundtrip. + 10. Compare dialog deferred to Future. + +### Internal references + +- `commons/src/jvmMain/.../service/upload/MediaCompressor.kt:28-50` — existing `stripExif()` +- `commons/src/jvmMain/.../service/upload/UploadOrchestrator.kt:45-86` — orchestration +- `commons/src/jvmMain/.../service/upload/MediaMetadata.kt` — `MediaMetadataReader.compute` +- `commons/ARCHITECTURE.md:114,188-192` — `service/upload` JVM placement +- `desktopApp/src/jvmMain/.../ui/ComposeNoteDialog.kt:326-350,430-435` — options row + send path +- `desktopApp/src/jvmMain/.../ui/media/ClipboardPasteHandler.kt:43` — current PNG-temp leak +- `desktopApp/src/jvmMain/.../ui/media/MediaAttachmentRow.kt` — attachments thumbnails (unchanged) +- `desktopApp/src/jvmMain/.../ui/settings/MediaServerSettings.kt:70-98` — settings panel template +- `desktopApp/src/jvmMain/.../SearchHistoryStore.kt` — canonical Store + StateFlow + prefs pattern +- `desktopApp/src/jvmMain/.../service/upload/DesktopUploadTracker.kt:27-53` — tracker state +- `desktopApp/src/jvmMain/.../ui/SearchScreen.kt:564-580` — Material3 AlertDialog example (trivial confirms) +- `desktopApp/src/jvmMain/.../ui/deck/FeedBuilderDialog.kt:191` — `SingleChoiceSegmentedButtonRow` precedent + `Dialog { Surface }` precedent +- `desktopApp/src/jvmMain/.../ui/account/AccountSwitcherDropdown.kt:111` — `FilterChip` + anchored `DropdownMenu` precedent +- `desktopApp/src/jvmMain/.../ui/deck/LocalFeedProvider.kt` — `CompositionLocal` precedent +- `cli/src/main/kotlin/.../cli/commands/DmCommands.kt:31,163` — Amy's existing call into `UploadOrchestrator` +- `cli/ROADMAP.md:65` — Blossom uploads roadmap (`amy upload` future) +- `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` — sibling plan (shipped Phases 0-3; compression was its gap) +- `docs/temp-file-cleanup-analysis.md:25-44` — temp-leak baseline +- `commons/src/commonMain/.../icons/symbols/MaterialSymbols.kt` — icon set (no new codepoints needed v1) +- Memory note `custom-feeds-alertdialog.md` — AlertDialog state-isolation trap +- Memory note (`MEMORY.md`) — `kotlinx.collections.immutable` available; `Preferences` for desktop persistence + +### External references + +- [Thumbnailator (0.4.21)](https://github.com/coobird/thumbnailator) +- [Thumbnailator scaling quality issue #179](https://github.com/coobird/thumbnailator/issues/179) +- [TwelveMonkeys WebP wiki (decode only — confirms no encoder)](https://github.com/haraldk/TwelveMonkeys/wiki/WebP-Plugin) +- [TwelveMonkeys HEIC request #976 (confirms no HEIC plugin)](https://github.com/haraldk/TwelveMonkeys/issues/976) +- [JDK 21 JFR memory events / `jfr view`](https://docs.oracle.com/en/java/javase/21/jfapi/) +- [WebP container spec (animation detection)](https://developers.google.com/speed/webp/docs/riff_container) +- [Quarkus EXIF-stripper tutorial (2025) — same architecture style](https://www.the-main-thread.com/p/quarkus-image-metadata-stripper-exif-removal-tutorial) +- [libwebp CVE-2023-4863 (informational; we ship no native code)](https://nvd.nist.gov/vuln/detail/CVE-2023-4863) +- [Blossom protocol](https://github.com/hzrd149/blossom) + +## Resolved-from-Brainstorm Questions + +- **Q2 / HEIC decoder:** **Dropped from v1.** Pure-Java HEIC plugin does not exist in 2026 (TwelveMonkeys #976 confirms). JNI libheif rejected for v1 to keep installer JNI-free. +- **Q4 / WebP encoder:** **Dropped from v1.** No pure-Java WebP encoder exists (sejda fork is JNI + abandoned; TwelveMonkeys WebP is decode-only). Output is JPEG-only. +- **Q5 / Never upscale:** Locked. Source dim < preset max-dim → no resize; re-encode at preset quality only. +- **Q8 / Compose dialog placement:** Quality chip lives in the existing options row of `ComposeNoteDialog:326-350` next to `ServerSelector`, NOT inside `MediaAttachmentRow`. `FilterChip(selected = override != null)` + anchored `DropdownMenu`. Reset on send AND on `DisposableEffect.onDispose`. +- **Q10 / Pasted clipboard filename:** `paste-YYYYMMDD-HHMMSS.jpg` (always JPEG in v1; mime `image/jpeg`). No PNG temp roundtrip. +- **Q11 / Compare anchor:** **Compare dialog deferred to Future Considerations.** When it ships: `Dialog { Surface }` with effect hoisted to parent. +- **Q14 / Animated formats:** Pass through byte-identical. Detection via `ImageFormatSniffer`: GIF NETSCAPE2.0 extension; WebP VP8X bit-1 + `ANIM` chunk. +- **Brainstorm Q12 / Failure mode:** Confirmed: fail-loud + confirm-to-bypass. Resolved further during deepening: bypass path still applies `stripExif` for JPEG sources. + +## Resolved-during-Deepening + +- **EXIF on bypass:** YES — `stripExif` runs on JPEG bypass when `stripExif=true`. Non-JPEG bypass uploads raw; dialog text is explicit about which case applies. +- **Visual thumbnail in compare:** Moot — Compare dialog dropped from v1. +- **`Compare` icon codepoint:** Moot — Compare dropped. +- **Settings StateFlow pattern:** Use new `ImageCompressionStore` (mirrors `SearchHistoryStore`), not raw `DesktopPreferences`. `LocalCompressionSettings` CompositionLocal at App scope. +- **Compose components:** `SingleChoiceSegmentedButtonRow` for quality, `FilterChip + DropdownMenu` for chip, `Dialog { Surface }` (not `AlertDialog`) for failure dialog, `.collectAsState()` (not `collectAsStateWithLifecycle`) for state reads. +- **Coroutines:** `Dispatchers.Default.limitedParallelism(1)` for `compressionDispatcher`. `ensureActive()` between stages. `NonCancellable` for cleanup. `CompletableDeferred` for failure dialog await. +- **AWT headless:** `-Djava.awt.headless=true` on CLI application{} block, top of `cli/Main.kt`, and `:commons:jvmTest` jvmArgs. +- **Temp dir:** `~/.amethyst/tmp/` mode 0700 with boot-time sweep of orphans > 24 h. +- **ICC profile:** Preserve on output via APP2 marker. Phase 0 gate. +- **Pre-decode guard:** Stream header dims via `ImageReader.getWidth(0)/getHeight(0)` THEN subsampled `reader.read(0, param)`. Never call `Thumbnails.of(file)` directly. +- **Phase order:** P3 → P4 → P5 → P6 → P7 serialized; ~7 working days total. + +## Open Questions + +- Do we have known-good Display P3 test fixtures (real iPhone photos) for ICC-preservation tests? Source from a contributor if not. +- Should the boot-time temp sweep also delete files in `~/.amethyst/tmp/` that DON'T match the `amethyst_*` prefix (i.e., be a strict allowlist for the directory)? Lean: YES — the directory is ours. +- Where exactly is `~/.amethyst/tmp/` for Windows / Linux? Windows: `%LOCALAPPDATA%\Amethyst\tmp\`; Linux: `$XDG_CACHE_HOME/amethyst/tmp/` (or `~/.cache/amethyst/tmp/`). Confirm consistency with `DesktopImageCacheFactory` precedent. +- Acceptable to ship without ICC profile preservation if Phase 0 reveals stock ImageIO can't embed cleanly? Lean: YES, with a follow-up issue — color shift is a regression vs current "ship raw" but matches Android Zelory behavior. +- Should Phase 7's "Send Original" button on a multi-failure dialog send ALL failures with one click, or one-at-a-time per row? Lean: one click sends all (atomic user decision). diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 92d5b123be..df3bb92416 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -113,4 +113,24 @@ object DesktopPreferences { set(value) { prefs.putBoolean(KEY_SIDEBAR_COLLAPSED, value) } + + // Image compression — the typed accessors live in + // ImageCompressionStore, which wraps these raw String/Boolean prefs + // in StateFlow for Compose reactivity. Defaults match + // CompressionQuality.DEFAULT and the "strip EXIF by default" + // privacy stance. + private const val KEY_IMAGE_QUALITY = "image_quality" + private const val KEY_IMAGE_STRIP_EXIF = "image_strip_exif" + + internal var imageQualityRaw: String + get() = prefs.get(KEY_IMAGE_QUALITY, "DESKTOP_HIGH") + set(value) { + prefs.put(KEY_IMAGE_QUALITY, value) + } + + internal var imageStripExif: Boolean + get() = prefs.getBoolean(KEY_IMAGE_STRIP_EXIF, true) + set(value) { + prefs.putBoolean(KEY_IMAGE_STRIP_EXIF, value) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ImageCompressionStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ImageCompressionStore.kt new file mode 100644 index 0000000000..b7dc1b5111 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ImageCompressionStore.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 + +import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Image upload compression settings, persisted via [DesktopPreferences]. + * Wraps the raw prefs in `StateFlow` so Compose can react when the user + * flips the toggles in the Media settings panel. + * + * Mirrors the [SearchHistoryStore] pattern: object singleton, init + * loads from prefs, setters write through to both StateFlow and + * prefs in one shot. + * + * Defaults: [CompressionQuality.DESKTOP_HIGH] (the new 1920px preset) + * and `stripExif = true` (privacy-first — turn off only to keep + * camera/lens metadata on photography uploads). + */ +object ImageCompressionStore { + private val _quality = MutableStateFlow(CompressionQuality.fromName(DesktopPreferences.imageQualityRaw)) + val quality: StateFlow = _quality.asStateFlow() + + private val _stripExif = MutableStateFlow(DesktopPreferences.imageStripExif) + val stripExif: StateFlow = _stripExif.asStateFlow() + + fun setQuality(quality: CompressionQuality) { + _quality.value = quality + DesktopPreferences.imageQualityRaw = quality.name + } + + fun setStripExif(value: Boolean) { + _stripExif.value = value + DesktopPreferences.imageStripExif = value + } +} 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 39b24355d9..2805abe27b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -119,13 +119,16 @@ import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard +import com.vitorpamplona.amethyst.desktop.ui.settings.ImageCompressionSettings import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection 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.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent @@ -1352,28 +1355,31 @@ fun MainContent( scope.launch { val innerEvent = event.unwrapAndUnsealOrNull(iAccount.signer) ?: return@launch when (innerEvent) { - is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -> { - val innerNote = localCache.getOrCreateNote(innerEvent.id) - val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) - if (innerNote.event == null) { - innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + // Any DM-group event (kind 14 text, kind 15 encrypted file, and any + // future NIP-17 variant) routes into the room by its participant set. + is ChatroomKeyable -> { + if (innerEvent.isIncluded(iAccount.pubKey)) { + val innerNote = localCache.getOrCreateNote(innerEvent.id) + val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (innerNote.event == null) { + innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + } + iAccount.chatroomList.addMessage( + innerEvent.chatroomKey(iAccount.pubKey), + innerNote, + ) } - iAccount.chatroomList.addMessage( - innerEvent.chatroomKey(iAccount.pubKey), - innerNote, - ) } - is com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -> { - val innerNote = localCache.getOrCreateNote(innerEvent.id) - val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) - if (innerNote.event == null) { - innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + // Self-authored NIP-37 draft wrapped to self. Store it so it isn't + // dropped; the desktop chat UI doesn't render drafts in the room feed + // yet, so it is intentionally not added to a chatroom. + is DraftWrapEvent -> { + val draftNote = localCache.getOrCreateNote(innerEvent.id) + val draftAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (draftNote.event == null) { + draftNote.loadEvent(innerEvent, draftAuthor, emptyList()) } - iAccount.chatroomList.addMessage( - innerEvent.chatroomKey(iAccount.pubKey), - innerNote, - ) } is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> { @@ -1784,6 +1790,12 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) + // Image Compression Settings + ImageCompressionSettings() + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + // Tor Settings com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( torStatus = torStatus, 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 be1a200191..a8d6ac087b 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 @@ -42,6 +42,9 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -294,6 +297,7 @@ class DesktopLocalCache : ICacheProvider { trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } + addQuoteBoosts(event, note, repliesTo) return true } @@ -313,9 +317,35 @@ class DesktopLocalCache : ICacheProvider { trackNoteAuthor(note, event.pubKey) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } + addQuoteBoosts(event, note, repliesTo) return true } + /** + * NIP-18 quote reposts: a note carrying a `q` tag is a quote-repost of the quoted + * note, so it counts as a boost in the quoted note's repost counter alongside + * kind:6/kind:16 reposts. The quoted note is kept out of `replyTo` so the quote + * still renders as a root post; targets already replied to are skipped to avoid + * double-counting, and self-quotes are ignored. + */ + private fun addQuoteBoosts( + event: Event, + note: Note, + repliesTo: List, + ) { + event.taggedQuotes().forEach { qTag -> + val quoted = + when (qTag) { + is QEventTag -> getOrCreateNote(qTag.eventId) + is QAddressableTag -> getOrCreateAddressableNote(qTag.address) + else -> null + } + if (quoted != null && quoted != note && quoted !in repliesTo) { + quoted.addBoost(note) + } + } + } + /** * Consumes a kind 7 reaction event. * Links reaction to target notes via e-tags and a-tags. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 135ad52fbe..707275d5d2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -62,19 +62,27 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker import com.vitorpamplona.amethyst.desktop.ui.compose.RelayPickerState import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler +import com.vitorpamplona.amethyst.desktop.ui.media.CompressionPreviewDialog import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow +import com.vitorpamplona.amethyst.desktop.ui.media.PreviewItem +import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip +import com.vitorpamplona.amethyst.desktop.ui.media.buildPreview +import com.vitorpamplona.amethyst.desktop.ui.media.cleanupPreviewTemps import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -170,6 +178,25 @@ fun ComposeNoteDialog( var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } var postAsPicture by remember { mutableStateOf(false) } + // Image compression: global default + optional per-post override. + // Override resets after every successful send so the next post + // starts from the saved default again. + val defaultQuality by ImageCompressionStore.quality.collectAsState() + val stripExifSetting by ImageCompressionStore.stripExif.collectAsState() + var perPostQualityOverride by remember { mutableStateOf(null) } + val activeQuality = perPostQualityOverride ?: defaultQuality + + // Preview-then-publish state. When images are attached, the + // publish button reads "Preview" and a click here triggers the + // CompressionPreviewDialog: every attachment is run through the + // ImageReencoder eagerly, the user reviews the result, and the + // actual upload uses the cached temp files (preCompressed). + var pendingPreview by remember { mutableStateOf?>(null) } + val hasImages = + attachedFiles.any { + it.extension.lowercase() in IMAGE_EXTENSIONS + } + // Relay picker state val connectedRelays by relayManager.connectedRelays.collectAsState() val allRelays by relayManager.availableRelays.collectAsState() @@ -210,11 +237,134 @@ fun ComposeNoteDialog( } } - Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) { + // Shared upload + publish flow. Called by either the main button + // (when there are no images, hence no preview gate) or by the + // CompressionPreviewDialog's Publish button (when the user + // confirms the preview). The Reencoded items in `useOriginalPaths` + // get their compressed temp deleted and ship the original bytes + // (bypassReencode=true) — same code path as Failed items. + val runPublish: (List?, Set) -> Unit = { selectedItems, useOriginalPaths -> + scope.launch { + isPosting = true + errorMessage = null + try { + pendingPreview = null + val uploadResults = mutableListOf() + + val total = selectedItems?.size ?: attachedFiles.size + val sources: List> = + selectedItems?.map { it.source to it } + ?: attachedFiles.map { it to null } + + for ((idx, pair) in sources.withIndex()) { + val (file, item) = pair + val n = idx + 1 + val prefix = if (total > 1) "$n/$total: " else "" + uploadTracker.startUpload("$prefix${file.name}") + val isUseOriginal = item?.source?.canonicalPath in useOriginalPaths + val result = + when { + item is PreviewItem.Reencoded && !isUseOriginal -> + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + stripExif = stripExifSetting, + quality = activeQuality, + preCompressed = item.compressedFile, + ) + item is PreviewItem.Reencoded && isUseOriginal -> { + // User opted out of the compressed + // version — drop the temp before we + // ship the original. + item.compressedFile.delete() + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + stripExif = stripExifSetting, + quality = activeQuality, + bypassReencode = true, + ) + } + item is PreviewItem.Failed -> + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + stripExif = stripExifSetting, + quality = activeQuality, + bypassReencode = true, + ) + else -> + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + stripExif = stripExifSetting, + quality = activeQuality, + ) + } + uploadTracker.onSuccess(result) + uploadResults.add(result) + } + + perPostQualityOverride = null + + val finalContent = + buildString { + append(content) + for (result in uploadResults) { + result.blossom.url?.let { url -> + if (isNotBlank()) append("\n") + append(url) + } + } + } + + if (postAsPicture) { + val pictureMetas = buildPictureMetas(uploadResults) + publishPicture( + description = content, + images = pictureMetas, + account = account, + relayManager = relayManager, + relays = selectedRelays, + ) + } else { + val imetaTags = buildIMetaTags(uploadResults) + publishNote( + content = finalContent, + account = account, + relayManager = relayManager, + replyTo = replyTo, + quoteOf = quoteOf, + imetaTags = imetaTags, + relays = selectedRelays, + ) + } + onDismiss() + } catch (e: Exception) { + errorMessage = "Failed: ${e.message}" + uploadTracker.onError(e.message ?: "Unknown error") + } finally { + isPosting = false + } + } + } + + Dialog( + onDismissRequest = { if (!isPosting) onDismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { Card( modifier = Modifier - .width(600.dp) + .width(780.dp) .padding(16.dp) .dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget) .then( @@ -322,7 +472,7 @@ fun ComposeNoteDialog( onRemove = { attachedFiles.remove(it) }, ) - // Server selector + post type — shown when files are attached + // Server selector + per-post quality + post type — shown when files are attached if (attachedFiles.isNotEmpty()) { Spacer(Modifier.height(4.dp)) Row( @@ -335,11 +485,18 @@ fun ComposeNoteDialog( onServerSelected = { selectedServer = it }, ) + // Quality override chip — only when images are attached + // (no point picking a JPEG preset for a video upload). + if (hasImages) { + QualitySelectorChip( + activeQuality = activeQuality, + isOverride = perPostQualityOverride != null, + onSelect = { perPostQualityOverride = it }, + onReset = { perPostQualityOverride = null }, + ) + } + // Post type toggle — only when images are attached - val hasImages = - attachedFiles.any { - it.extension.lowercase() in IMAGE_EXTENSIONS - } if (hasImages) { PostTypeSelector( isPicture = postAsPicture, @@ -416,77 +573,58 @@ fun ComposeNoteDialog( errorMessage = "Note cannot be empty" return@Button } - - scope.launch { - isPosting = true - errorMessage = null - - try { - // Upload attached files and collect results - val uploadResults = mutableListOf() - for (file in attachedFiles) { - uploadTracker.startUpload(file.name) - val result = - orchestrator.upload( - file = file, - alt = null, - serverBaseUrl = selectedServer, - signer = account.signer, + // When there are images and we don't have a + // preview yet, the publish button acts as a + // "Preview" gate — build the preview, show it, + // and let the user confirm before any upload. + if (hasImages && pendingPreview == null) { + scope.launch { + isPosting = true + errorMessage = null + try { + pendingPreview = + buildPreview( + attachments = attachedFiles.toList(), + imageExtensions = IMAGE_EXTENSIONS, + quality = activeQuality, ) - uploadTracker.onSuccess(result) - uploadResults.add(result) + } finally { + isPosting = false } - - // Append uploaded URLs to content - val finalContent = - buildString { - append(content) - for (result in uploadResults) { - result.blossom.url?.let { url -> - if (isNotBlank()) append("\n") - append(url) - } - } - } - - if (postAsPicture) { - val pictureMetas = buildPictureMetas(uploadResults) - publishPicture( - description = content, - images = pictureMetas, - account = account, - relayManager = relayManager, - relays = selectedRelays, - ) - } else { - val imetaTags = buildIMetaTags(uploadResults) - publishNote( - content = finalContent, - account = account, - relayManager = relayManager, - replyTo = replyTo, - quoteOf = quoteOf, - imetaTags = imetaTags, - relays = selectedRelays, - ) - } - onDismiss() - } catch (e: Exception) { - errorMessage = "Failed: ${e.message}" - uploadTracker.onError(e.message ?: "Unknown error") - } finally { - isPosting = false } + return@Button } + runPublish(null, emptySet()) }, enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()), ) { - Text(if (isPosting) "Publishing..." else "Publish") + Text( + when { + isPosting -> if (pendingPreview == null && hasImages) "Compressing…" else "Publishing…" + hasImages && pendingPreview == null -> "Preview" + else -> "Publish" + }, + ) } } } } } + + pendingPreview?.let { items -> + CompressionPreviewDialog( + items = items, + stripExifSetting = stripExifSetting, + onPublish = { itemsToUpload, useOriginalPaths -> + runPublish(itemsToUpload, useOriginalPaths) + }, + onCancel = { + cleanupPreviewTemps(items) + pendingPreview = null + isPosting = false + }, + ) + } } private fun buildIMetaTags(results: List): List = @@ -540,6 +678,8 @@ private fun ServerSelector( Text( selectedServer, style = MaterialTheme.typography.labelSmall, + maxLines = 1, + softWrap = false, ) } androidx.compose.material3.DropdownMenu( @@ -565,25 +705,42 @@ private fun PostTypeSelector( isPicture: Boolean, onToggle: (Boolean) -> Unit, ) { - Row( - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + var expanded by remember { mutableStateOf(false) } + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { Text( - "Post as:", + "Post as: ", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - androidx.compose.material3.FilterChip( - selected = !isPicture, - onClick = { onToggle(false) }, - label = { Text("Note", style = MaterialTheme.typography.labelSmall) }, - ) - androidx.compose.material3.FilterChip( - selected = isPicture, - onClick = { onToggle(true) }, - label = { Text("Picture", style = MaterialTheme.typography.labelSmall) }, - ) + Box { + androidx.compose.material3.TextButton(onClick = { expanded = true }) { + Text( + if (isPicture) "Picture" else "Note", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + softWrap = false, + ) + } + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + androidx.compose.material3.DropdownMenuItem( + text = { Text("Note", style = MaterialTheme.typography.bodySmall) }, + onClick = { + onToggle(false) + expanded = false + }, + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Picture", style = MaterialTheme.typography.bodySmall) }, + onClick = { + onToggle(true) + expanded = 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 09532abae1..c746d64487 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 @@ -39,9 +39,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -95,6 +97,8 @@ import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.ciphers.AESGCM import kotlinx.coroutines.launch @@ -188,11 +192,26 @@ fun ChatPane( val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) } val isGroup = users.size > 1 + // NIP-14 group subject/name, updated reactively as subject-tagged messages arrive. + val subjectFlow = remember(roomKey) { account.chatroomList.getOrCreatePrivateChatroom(roomKey).subject } + val subject by subjectFlow.collectAsState() + var showSubjectDialog by remember { mutableStateOf(false) } + // Load room into message state LaunchedEffect(roomKey) { messageState.load(roomKey) } + if (showSubjectDialog) { + GroupSubjectDialog( + roomKey = roomKey, + currentSubject = subject ?: "", + account = account, + cacheProvider = cacheProvider, + onClose = { showSubjectDialog = false }, + ) + } + Box(modifier = modifier.fillMaxSize()) { Column( modifier = @@ -230,6 +249,7 @@ fun ChatPane( if (isGroup) { GroupChatroomHeader( users = users, + subject = subject, onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, ) } else { @@ -248,6 +268,21 @@ fun ChatPane( } } } + + // Rename group (set NIP-14 subject) — groups only + if (isGroup) { + IconButton( + onClick = { showSubjectDialog = true }, + modifier = Modifier.size(40.dp), + ) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Rename group", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } HorizontalDivider() @@ -874,3 +909,72 @@ private suspend fun sendEncryptedFiles( account.sendNip17EncryptedFile(template) } } + +/** + * Dialog to set or change a group's NIP-14 subject (name). Mirrors Android's + * NewChatroomSubjectDialog: it sends a normal NIP-17 message carrying a + * `subject` tag (plus an optional accompanying message) to every room member, + * so all participants pick up the new name. + */ +@Composable +private fun GroupSubjectDialog( + roomKey: ChatroomKey, + currentSubject: String, + account: IAccount, + cacheProvider: ICacheProvider, + onClose: () -> Unit, +) { + val scope = rememberCoroutineScope() + var groupName by remember { mutableStateOf(currentSubject) } + var message by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onClose, + title = { Text("Group name") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = groupName, + onValueChange = { groupName = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Subject") }, + placeholder = { Text("A name for this group") }, + singleLine = true, + ) + OutlinedTextField( + value = message, + onValueChange = { message = it }, + modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp), + label = { Text("Message (optional)") }, + ) + } + }, + confirmButton = { + TextButton( + enabled = groupName.isNotBlank(), + onClick = { + scope.launch { + try { + val pTags = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it)?.toPTag() } + val template = + ChatMessageEvent.build(message, pTags) { + groupName.ifBlank { null }?.let { changeSubject(it) } + } + account.sendNip17PrivateMessage(template) + } catch (e: Exception) { + println("Failed to set group subject: ${e.message}") + } + } + onClose() + }, + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onClose) { + Text("Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt index 760974048f..96a771e6d3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt @@ -91,16 +91,23 @@ fun ChatroomHeader( * Shared chatroom header for a group conversation. * Displays multiple user avatars and a combined room name. * + * When the group has a NIP-14 [subject] (group name), it is shown as the bold + * title with the participant list as a secondary line; otherwise the participant + * list is the title. + * * @param users List of users in the group conversation + * @param subject Optional NIP-14 group subject/name * @param modifier Layout modifier (defaults to standard padding) * @param onClick Called when the header is tapped */ @Composable fun GroupChatroomHeader( users: List, + subject: String? = null, modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { + val participants = users.joinToString(", ") { it.toBestDisplayName() } Column( modifier = Modifier @@ -124,12 +131,21 @@ fun GroupChatroomHeader( Column(modifier = Modifier.padding(start = 10.dp)) { Text( - text = users.joinToString(", ") { it.toBestDisplayName() }, + text = subject?.takeIf { it.isNotBlank() } ?: participants, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (!subject.isNullOrBlank()) { + Text( + text = participants, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } 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 18afcbc7bb..fc7dd95cc8 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 @@ -101,6 +101,10 @@ class ChatroomListState( // Track pubkeys we've already requested metadata for private val fetchedMetadataKeys = mutableSetOf() + // Timestamp (createdAt) of the newest message seen by the user per room, set when a room is + // opened. A room is unread when its newest incoming message is newer than this mark. + private val lastSeen = mutableMapOf() + init { scope.launch(Dispatchers.IO) { while (isActive) { @@ -116,6 +120,14 @@ class ChatroomListState( fun selectRoom(roomKey: ChatroomKey) { _selectedRoom.value = roomKey + // Mark everything currently in the room as seen so it stops showing as unread. + val newest = + account.chatroomList.rooms + .get(roomKey) + ?.newestMessage + ?.createdAt() ?: 0L + lastSeen[roomKey] = maxOf(lastSeen[roomKey] ?: 0L, newest) + scope.launch(Dispatchers.IO) { refreshRooms() } } fun clearSelection() { @@ -199,6 +211,10 @@ class ChatroomListState( // Skip rooms with no messages if (chatroom.messages.isEmpty()) continue + // Hide rooms whose latest message is from a muted/blocked author or otherwise filtered. + val newestMessage = chatroom.newestMessage + if (newestMessage != null && !account.isAcceptable(newestMessage)) continue + val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) } // Collect pubkeys without profile info @@ -219,10 +235,14 @@ class ChatroomListState( ?.let { "$it..." } ?: "Unknown" } - val newestMessage = chatroom.newestMessage val lastPreview = decryptPreview(newestMessage?.event) val lastTimestamp = newestMessage?.createdAt() ?: 0L + // Unread when the newest message is incoming (not authored by us) and newer than the + // last time the user opened this room. + val incoming = newestMessage != null && newestMessage.author?.pubkeyHex != account.pubKey + val hasUnread = incoming && lastTimestamp > (lastSeen[key] ?: 0L) + val item = ConversationItem( roomKey = key, @@ -232,7 +252,7 @@ class ChatroomListState( lastMessagePreview = lastPreview, lastMessageTimestamp = lastTimestamp, isGroup = key.users.size > 1, - hasUnread = !chatroom.ownerSentMessage && newestMessage != null, + hasUnread = hasUnread, ) if (chatroom.ownerSentMessage) { 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 32e6e1de45..909b97e26d 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 @@ -20,15 +20,19 @@ */ package com.vitorpamplona.amethyst.desktop.ui.chats +import androidx.compose.foundation.border 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.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -39,6 +43,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -49,6 +54,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +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 @@ -71,6 +77,15 @@ fun NewDmDialog( onDismiss: () -> Unit, ) { val scope = rememberCoroutineScope() + // Recipients selected so far. One → 1:1 chat, more than one → group chat. + val selected = remember { mutableStateListOf() } + + fun toggle(user: User) { + val existing = selected.firstOrNull { it.pubkeyHex == user.pubkeyHex } + if (existing != null) selected.remove(existing) else selected.add(user) + } + + fun isSelected(user: User) = selected.any { it.pubkeyHex == user.pubkeyHex } val searchState = remember { SearchBarState(cacheProvider, scope) } val searchText by searchState.searchText.collectAsState() val bech32Results by searchState.bech32Results.collectAsState() @@ -146,10 +161,38 @@ fun NewDmDialog( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - "New Message", + if (selected.size > 1) "New Group" else "New Message", style = MaterialTheme.typography.titleLarge, ) + // Selected recipients as removable chips. Add more to form a group. + if (selected.isNotEmpty()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(selected) { user -> + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 10.dp, end = 4.dp, top = 2.dp, bottom = 2.dp), + ) { + Text( + user.toBestDisplayName(), + style = MaterialTheme.typography.labelLarge, + ) + IconButton(onClick = { toggle(user) }, modifier = Modifier.size(24.dp)) { + Icon(MaterialSymbols.Clear, contentDescription = "Remove", modifier = Modifier.size(16.dp)) + } + } + } + } + } + } + OutlinedTextField( value = searchText, onValueChange = { searchState.updateSearchText(it) }, @@ -184,11 +227,8 @@ fun NewDmDialog( if (user != null) { UserSearchCard( user = user, - onClick = { - onUserSelected( - ChatroomKey(setOf(user.pubkeyHex)), - ) - }, + onClick = { toggle(user) }, + modifier = selectedModifier(isSelected(user)), ) } else { // Minimal card for unloaded users @@ -210,11 +250,8 @@ fun NewDmDialog( items(cachedUsers) { user -> UserSearchCard( user = user, - onClick = { - onUserSelected( - ChatroomKey(setOf(user.pubkeyHex)), - ) - }, + onClick = { toggle(user) }, + modifier = selectedModifier(isSelected(user)), ) } @@ -222,11 +259,8 @@ fun NewDmDialog( items(relaySearchResults) { user -> UserSearchCard( user = user, - onClick = { - onUserSelected( - ChatroomKey(setOf(user.pubkeyHex)), - ) - }, + onClick = { toggle(user) }, + modifier = selectedModifier(isSelected(user)), ) } @@ -262,7 +296,34 @@ fun NewDmDialog( } } } + + Button( + onClick = { + onUserSelected(ChatroomKey(selected.map { it.pubkeyHex }.toSet())) + }, + enabled = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + when (selected.size) { + 0 -> "Select recipients" + 1 -> "Message" + else -> "Create group (${selected.size})" + }, + ) + } } } } } + +/** Border highlight for a selected recipient card in the new-DM picker. */ +@Composable +private fun selectedModifier(selected: Boolean): Modifier = + if (selected) { + Modifier + .fillMaxWidth() + .border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium) + } else { + Modifier + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt index 2d398d54ef..d0d89b4037 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt @@ -20,14 +20,19 @@ */ package com.vitorpamplona.amethyst.desktop.ui.media +import com.vitorpamplona.amethyst.commons.service.upload.AmethystTempDir import java.awt.Image import java.awt.Toolkit import java.awt.datatransfer.DataFlavor import java.awt.image.BufferedImage import java.io.File +import java.text.SimpleDateFormat +import java.util.Date import javax.imageio.ImageIO object ClipboardPasteHandler { + private val PASTE_TIMESTAMP = SimpleDateFormat("yyyyMMdd-HHmmss") + fun getClipboardFiles(): List { val clipboard = Toolkit.getDefaultToolkit().systemClipboard return try { @@ -40,8 +45,15 @@ object ClipboardPasteHandler { clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor) -> { val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return emptyList() val buffered = toBufferedImage(image) - val tempFile = File.createTempFile("clipboard_", ".png") - tempFile.deleteOnExit() + // Temp lives under AmethystTempDir so the boot-time orphan + // sweep recovers it if the JVM crashes before the upload + // pipeline gets a chance to consume it. The intermediate + // PNG is paid for by clipboard semantics — Java's + // clipboard image flavor only exposes BufferedImage, so + // PNG is the lossless container we materialize before + // the orchestrator decides to re-encode. + val timestamp = synchronized(PASTE_TIMESTAMP) { PASTE_TIMESTAMP.format(Date()) } + val tempFile = AmethystTempDir.createTempFile("amethyst_paste_${timestamp}_", ".png") ImageIO.write(buffered, "png", tempFile) listOf(tempFile) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt new file mode 100644 index 0000000000..66698f4b92 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException +import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality +import com.vitorpamplona.amethyst.commons.service.upload.ImageFormat +import com.vitorpamplona.amethyst.commons.service.upload.ImageFormatSniffer +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import java.io.File +import javax.imageio.ImageIO + +/** + * What the preview dialog renders for a single attachment. + * + * Built once per `Preview` click in the compose dialog; if the user + * confirms via `Publish`, the items flow into the upload loop and the + * orchestrator takes ownership of the cached temp files. + */ +sealed class PreviewItem { + abstract val source: File + abstract val originalSize: Long + abstract val originalDims: Pair? + + /** A reencoded image — the dialog shows side-by-side stats + a click-to-zoom. */ + data class Reencoded( + override val source: File, + override val originalSize: Long, + override val originalDims: Pair?, + val compressedFile: File, + val compressedSize: Long, + val compressedDims: Pair, + val quality: CompressionQuality, + ) : PreviewItem() { + val savings: Double get() = 1.0 - (compressedSize.toDouble() / originalSize.toDouble()) + } + + /** ImageReencoder said pass-through (animated GIF/WebP, SVG, BypassByUser). */ + data class PassThrough( + override val source: File, + override val originalSize: Long, + override val originalDims: Pair?, + val reason: PassReason, + ) : PreviewItem() + + /** Reencode threw — user can still publish; original ships raw. */ + data class Failed( + override val source: File, + override val originalSize: Long, + override val originalDims: Pair?, + val exception: CompressionException, + val sourceFormat: ImageFormat, + ) : PreviewItem() + + /** Non-image attachment — shown for context, uploaded straight through. */ + data class NonImage( + override val source: File, + override val originalSize: Long, + ) : PreviewItem() { + override val originalDims: Pair? = null + } +} + +/** + * Reads source dimensions from a file via ImageIO header (no decode). + * Returns null on any failure — preview keeps rendering, just without + * the dim string. + */ +internal fun readDimensions(file: File): Pair? = + try { + ImageIO.createImageInputStream(file).use { iis -> + iis ?: return null + val readers = ImageIO.getImageReaders(iis) + if (!readers.hasNext()) return null + val reader = readers.next() + reader.input = iis + try { + reader.getWidth(0) to reader.getHeight(0) + } finally { + reader.dispose() + } + } + } catch (_: Exception) { + null + } + +/** + * Build the preview list for a batch of attachments. Runs ImageReencoder + * eagerly so the dialog renders complete state once. Non-image files + * are not sent through the reencoder. + * + * IMPORTANT: callers own the lifecycle of any [PreviewItem.Reencoded.compressedFile] + * temps returned here. On Cancel: delete each. On Publish: hand off + * to [com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator] + * via its `preCompressed` parameter — the orchestrator cleans up. + */ +suspend fun buildPreview( + attachments: List, + imageExtensions: Set, + quality: CompressionQuality, +): List = + attachments.map { file -> + val originalSize = file.length() + val isImage = file.extension.lowercase() in imageExtensions + if (!isImage) { + return@map PreviewItem.NonImage(file, originalSize) + } + val originalDims = readDimensions(file) + try { + when (val result = ImageReencoder.reencode(file, quality)) { + is ReencodeResult.Reencoded -> { + val compressedDims = readDimensions(result.file) ?: (0 to 0) + PreviewItem.Reencoded( + source = file, + originalSize = originalSize, + originalDims = originalDims, + compressedFile = result.file, + compressedSize = result.file.length(), + compressedDims = compressedDims, + quality = quality, + ) + } + is ReencodeResult.PassThrough -> + PreviewItem.PassThrough( + source = file, + originalSize = originalSize, + originalDims = originalDims, + reason = result.reason, + ) + } + } catch (e: CompressionException) { + PreviewItem.Failed( + source = file, + originalSize = originalSize, + originalDims = originalDims, + exception = e, + sourceFormat = ImageFormatSniffer.sniff(file), + ) + } + } + +/** Delete any temp files held by Reencoded items. Called on Cancel. */ +fun cleanupPreviewTemps(items: List) { + items.forEach { item -> + if (item is PreviewItem.Reencoded) { + item.compressedFile.delete() + } + } +} + +/** Compact byte formatter for the preview rows. */ +internal fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + if (bytes < 1024 * 1024) return "%.1f KB".format(bytes / 1024.0) + return "%.1f MB".format(bytes / (1024.0 * 1024.0)) +} + +internal fun formatDims(dims: Pair?): String = dims?.let { "${it.first}×${it.second}" } ?: "unknown" + +internal fun friendlyFailReason(e: CompressionException): String = + when (e) { + is CompressionException.UnsupportedFormat -> "format not supported (${e.format})" + is CompressionException.InputTooLarge -> "image is larger than ${e.limit / 1_000_000} megapixels" + is CompressionException.EncodeFailed -> "encoder error (${e.cause?.message ?: e.message})" + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreviewDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreviewDialog.kt new file mode 100644 index 0000000000..1a830fa471 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreviewDialog.kt @@ -0,0 +1,607 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason + +/** + * Preview-then-publish dialog: every attachment gets a row showing + * what will actually be uploaded. Re-encoded rows show original-vs- + * compressed thumbnails with dim/size/savings stats; click a row + * to open a side-by-side zoom. Pass-through and fail rows render + * with a badge so the user knows what's being shipped untouched. + * + * Built with `Dialog { Surface }` (not AlertDialog) — see the + * compose-expert review notes in the plan: AlertDialog cramps wide + * content and the state-isolation trap prevents LaunchedEffect / + * subscription patterns inside its `text` slot. + */ +@Composable +fun CompressionPreviewDialog( + items: List, + stripExifSetting: Boolean, + onPublish: (items: List, useOriginalPaths: Set) -> Unit, + onCancel: () -> Unit, +) { + var zoomed by remember { mutableStateOf(null) } + + // Per-row "use original" state. Toggling true means: drop the + // compressed temp, upload the user's original bytes instead. Only + // meaningful on Reencoded rows — other variants always ship the + // original anyway. + val useOriginal = remember { mutableStateMapOf() } + + fun isUseOriginal(item: PreviewItem) = useOriginal[item.source.canonicalPath] == true + + fun toggleUseOriginal(item: PreviewItem) { + useOriginal[item.source.canonicalPath] = !isUseOriginal(item) + } + + val useOriginalPaths: Set = + items + .filter(::isUseOriginal) + .mapTo(mutableSetOf()) { it.source.canonicalPath } + + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.width(820.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + "Preview & publish", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + summaryLine(items), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items, key = { it.source.canonicalPath }) { item -> + when (item) { + is PreviewItem.Reencoded -> + ReencodedRow( + item = item, + useOriginal = isUseOriginal(item), + stripExifSetting = stripExifSetting, + onToggleUseOriginal = { toggleUseOriginal(item) }, + onZoom = { zoomed = item }, + ) + is PreviewItem.PassThrough -> PassThroughRow(item = item) + is PreviewItem.Failed -> FailedRow(item = item, stripExifSetting = stripExifSetting) + is PreviewItem.NonImage -> NonImageRow(item = item) + } + } + } + + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onCancel) { + Text("Back") + } + Spacer(Modifier.width(8.dp)) + Button(onClick = { onPublish(items, useOriginalPaths) }) { + Text("Publish (${items.size})") + } + } + } + } + } + + zoomed?.let { item -> + ZoomCompareDialog(item = item, onDismiss = { zoomed = null }) + } +} + +// ---------- row composables ---------- + +@Composable +private fun ReencodedRow( + item: PreviewItem.Reencoded, + useOriginal: Boolean, + stripExifSetting: Boolean, + onToggleUseOriginal: () -> Unit, + onZoom: () -> Unit, +) { + // Both thumbnails stay full-bleed for context, but when the user + // opts to upload the original, the compressed half is dimmed so + // it visually reads "this side is being thrown away." + val compressedAlpha = if (useOriginal) 0.3f else 1f + + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = !useOriginal) { onZoom() }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Thumbnail(item.source) + Spacer(Modifier.width(8.dp)) + Text( + "→", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.alpha(compressedAlpha), + ) + Spacer(Modifier.width(8.dp)) + Thumbnail(item.compressedFile, dimmed = useOriginal) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Spacer(Modifier.height(2.dp)) + if (useOriginal) { + Text( + "${formatDims(item.originalDims)} · ${formatBytes(item.originalSize)} · " + + "compression skipped — original uploads as-is", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + reencodedMetadataHint(item, useOriginal = true, stripExif = stripExifSetting), + style = MaterialTheme.typography.labelSmall, + color = + if (stripExifSetting) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.error + }, + ) + } else { + Text( + "${formatDims(item.originalDims)} → ${formatDims(item.compressedDims)} · ${item.quality.chipLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "${formatBytes(item.originalSize)} → ${formatBytes(item.compressedSize)} · saves ${"%.0f".format(item.savings * 100)}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + reencodedMetadataHint(item, useOriginal = false, stripExif = stripExifSetting), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (!useOriginal) { + Text( + "Click to compare", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + UseOriginalToggle(useOriginal = useOriginal, onToggle = onToggleUseOriginal) + } + } + } +} + +/** + * Small text-button toggle for the "use original instead of + * compressed" choice on a Reencoded row. Two-state label so the verb + * is unambiguous — a bare icon could be misread. + */ +@Composable +private fun UseOriginalToggle( + useOriginal: Boolean, + onToggle: () -> Unit, +) { + TextButton( + onClick = onToggle, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + colors = + ButtonDefaults.textButtonColors( + contentColor = + if (useOriginal) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) { + Text( + if (useOriginal) "Using original — undo" else "Use original", + style = MaterialTheme.typography.labelSmall, + ) + } +} + +/** + * Metadata-strip wording for a Reencoded row, which differs based on + * whether the user is uploading the compressed JPEG (always stripped + * by re-encode) or the original (depends on the strip-EXIF setting + + * source format). + */ +private fun reencodedMetadataHint( + item: PreviewItem.Reencoded, + useOriginal: Boolean, + stripExif: Boolean, +): String { + if (!useOriginal) return "All EXIF, GPS, camera tags stripped (re-encoded to JPEG)" + // Original is being uploaded. Behavior depends on strip-EXIF setting + // + source format. + val sourceIsJpeg = + item.source.name.lowercase().let { + it.endsWith(".jpg") || it.endsWith(".jpeg") + } + return when { + stripExif && sourceIsJpeg -> "EXIF, GPS, camera tags stripped from original before upload" + stripExif && !sourceIsJpeg -> "Metadata preserved — strip only runs on JPEG; original is non-JPEG" + else -> "Metadata preserved (EXIF strip off in settings)" + } +} + +@Composable +private fun PassThroughRow(item: PreviewItem.PassThrough) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Thumbnail(item.source) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Spacer(Modifier.height(2.dp)) + Text( + "${formatDims(item.originalDims)} · ${formatBytes(item.originalSize)} · uploaded as-is", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + passThroughMetadataHint(item.reason), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip( + onClick = {}, + label = { Text(passReasonBadge(item.reason), style = MaterialTheme.typography.labelSmall) }, + colors = AssistChipDefaults.assistChipColors(), + ) + } + } +} + +@Composable +private fun FailedRow( + item: PreviewItem.Failed, + stripExifSetting: Boolean, +) { + val isJpeg = item.sourceFormat is com.vitorpamplona.amethyst.commons.service.upload.ImageFormat.Jpeg + val privacy = + when { + stripExifSetting && isJpeg -> "EXIF, GPS, camera tags stripped before upload" + stripExifSetting && !isJpeg -> "Metadata preserved — strip only runs on JPEG; this is ${item.sourceFormat::class.simpleName}" + else -> "Metadata preserved (EXIF strip off in settings)" + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.20f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Thumbnail(item.source) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Text( + "Could not compress: ${friendlyFailReason(item.exception)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Text( + "Will send original (${formatBytes(item.originalSize)})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + privacy, + style = MaterialTheme.typography.labelSmall, + color = + if (stripExifSetting && isJpeg) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.error + }, + ) + } + } + } +} + +@Composable +private fun NonImageRow(item: PreviewItem.NonImage) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Text( + item.source.extension + .uppercase() + .take(4), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Text( + "${formatBytes(item.originalSize)} · uploaded as-is (non-image)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Metadata preserved — EXIF strip applies to JPEG only", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun Thumbnail( + file: java.io.File, + dimmed: Boolean = false, +) { + AsyncImage( + model = file, + contentDescription = file.name, + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(4.dp)) + .alpha(if (dimmed) 0.4f else 1f), + contentScale = ContentScale.Crop, + ) +} + +/** Metadata-strip wording for the pass-through rows. */ +private fun passThroughMetadataHint(reason: PassReason): String = + when (reason) { + PassReason.Animated -> "Metadata preserved (animated — re-encode would drop frames)" + PassReason.Vector -> "No raster metadata (SVG)" + PassReason.BypassByUser -> "Metadata preserved per your override" + PassReason.NotAnImage -> "Metadata preserved (non-image — no re-encode applies)" + } + +// ---------- zoom sub-dialog ---------- + +@Composable +private fun ZoomCompareDialog( + item: PreviewItem.Reencoded, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.width(960.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + item.source.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Saves ${"%.0f".format(item.savings * 100)}% (${formatBytes(item.originalSize)} → ${formatBytes(item.compressedSize)}) at ${item.quality.chipLabel}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ZoomSide(label = "Original", dims = item.originalDims, sizeBytes = item.originalSize, file = item.source) + ZoomSide(label = "Compressed", dims = item.compressedDims, sizeBytes = item.compressedSize, file = item.compressedFile) + } + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = onDismiss) { Text("Close") } + } + } + } + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.ZoomSide( + label: String, + dims: Pair?, + sizeBytes: Long, + file: java.io.File, +) { + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "${formatDims(dims)} · ${formatBytes(sizeBytes)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + AsyncImage( + model = file, + contentDescription = "$label: ${file.name}", + modifier = + Modifier + .fillMaxWidth() + .height(420.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentScale = ContentScale.Fit, + ) + } +} + +// ---------- helpers ---------- + +private fun summaryLine(items: List): String { + val reencoded = items.count { it is PreviewItem.Reencoded } + val passthrough = items.count { it is PreviewItem.PassThrough } + val failed = items.count { it is PreviewItem.Failed } + val nonImage = items.count { it is PreviewItem.NonImage } + val totalSaving = + items + .filterIsInstance() + .sumOf { it.originalSize - it.compressedSize } + val parts = mutableListOf() + if (reencoded > 0) parts.add("$reencoded reencoded (saves ${formatBytes(totalSaving)})") + if (passthrough > 0) parts.add("$passthrough pass-through") + if (failed > 0) parts.add("$failed couldn't be compressed") + if (nonImage > 0) parts.add("$nonImage non-image") + return parts.joinToString(" · ") +} + +private fun passReasonBadge(reason: PassReason): String = + when (reason) { + PassReason.Animated -> "Animated · uploaded as-is" + PassReason.Vector -> "Vector · uploaded as-is" + PassReason.BypassByUser -> "Original · uploaded as-is" + PassReason.NotAnImage -> "Not an image · uploaded as-is" + } 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 5032375b28..8a39e9f83c 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 @@ -39,10 +39,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -102,6 +108,7 @@ private sealed class DownloadState { ) : DownloadState() } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LightboxOverlay( urls: List, @@ -120,6 +127,21 @@ fun LightboxOverlay( val awtWindow = LocalAwtWindow.current val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current + // Snackbar-like banner state for the "Copied … to clipboard" + // confirmation. Auto-dismisses after 2.5 s. + var copyConfirmation by remember { mutableStateOf(null) } + LaunchedEffect(copyConfirmation) { + if (copyConfirmation != null) { + delay(2500) + copyConfirmation = null + } + } + + fun copyUrlToClipboard(url: String) { + Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(url), null) + copyConfirmation = url + } + LaunchedEffect(Unit) { focusRequester.requestFocus() } @@ -274,10 +296,7 @@ fun LightboxOverlay( onExpandMenu = { menuExpanded = true }, onDismissMenu = { menuExpanded = false }, onSave = { triggerSave() }, - onCopyUrl = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(urls[currentIndex]), null) - }, + onCopyUrl = { copyUrlToClipboard(urls[currentIndex]) }, onOpenInBrowser = { Desktop.getDesktop().browse(URI(urls[currentIndex])) }, @@ -286,10 +305,27 @@ fun LightboxOverlay( ) } } else { - ZoomableImage( - url = currentUrl, + // Hover tooltip shows the full Blossom URL; single-click + // copies it to the clipboard + triggers the snackbar. + val tooltipState = rememberTooltipState(isPersistent = false) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above, 8.dp), + tooltip = { + PlainTooltip { + Text( + currentUrl, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + state = tooltipState, modifier = contentModifier, - ) + ) { + ZoomableImage( + url = currentUrl, + onTap = { copyUrlToClipboard(currentUrl) }, + ) + } } // Download banner (top) @@ -373,6 +409,44 @@ fun LightboxOverlay( } } + // Copy-to-clipboard confirmation snackbar. Slides in below + // the download banner so the two can stack if both fire. + AnimatedVisibility( + visible = copyConfirmation != null, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = if (downloadState is DownloadState.Idle) 0.dp else 40.dp), + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + copyConfirmation?.let { url -> + Row( + modifier = + Modifier + .fillMaxWidth() + .background(Color(0xFF2E7D32)) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + MaterialSymbols.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "Copied $url to clipboard", + color = Color.White, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + softWrap = false, + ) + } + } + } + // "..." menu (bottom right) — only for images; videos get it in the controls bar // Hidden in fullscreen to keep the view immersive if (!isVideo && viewMode != ViewMode.FULLSCREEN) { @@ -384,10 +458,7 @@ fun LightboxOverlay( onExpandMenu = { menuExpanded = true }, onDismissMenu = { menuExpanded = false }, onSave = { triggerSave() }, - onCopyUrl = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(urls[currentIndex]), null) - }, + onCopyUrl = { copyUrlToClipboard(urls[currentIndex]) }, onOpenInBrowser = { Desktop.getDesktop().browse(URI(urls[currentIndex])) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/QualitySelectorChip.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/QualitySelectorChip.kt new file mode 100644 index 0000000000..30c01e10d7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/QualitySelectorChip.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality + +/** + * Per-post compression quality selector. Visually matches the + * sibling [ServerSelector] and [PostTypeSelector]: a "Quality:" label + * + a [TextButton] that opens a [DropdownMenu] of presets. Each + * dropdown row carries a one-line summary (dimension + tradeoff). + * + * When the user has chosen something other than the saved default, + * the dropdown shows a "Reset to default" row at the bottom so the + * override can be cleared without sending a post. + */ +@Composable +fun QualitySelectorChip( + activeQuality: CompressionQuality, + isOverride: Boolean, + onSelect: (CompressionQuality) -> Unit, + onReset: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + androidx.compose.foundation.layout.Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + "Quality: ", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box { + TextButton(onClick = { expanded = true }) { + Text( + activeQuality.chipLabel, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + softWrap = false, + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + CompressionQuality.entries.forEach { preset -> + DropdownMenuItem( + text = { + Column { + Text( + preset.chipLabel, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (preset == activeQuality) FontWeight.SemiBold else FontWeight.Normal, + ) + Text( + preset.summary, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onSelect(preset) + expanded = false + }, + ) + } + if (isOverride) { + DropdownMenuItem( + text = { + Text( + "Reset to default", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { + onReset() + expanded = false + }, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt index 9813b0807f..e26094d955 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt @@ -43,6 +43,7 @@ import coil3.compose.AsyncImage fun ZoomableImage( url: String, modifier: Modifier = Modifier, + onTap: (() -> Unit)? = null, ) { var scale by remember { mutableFloatStateOf(1f) } var offsetX by remember { mutableFloatStateOf(0f) } @@ -52,7 +53,7 @@ fun ZoomableImage( modifier = modifier .fillMaxSize() - .pointerInput(Unit) { + .pointerInput(onTap) { detectTapGestures( onDoubleTap = { scale = 1f @@ -60,7 +61,9 @@ fun ZoomableImage( offsetY = 0f }, onTap = { - // Consume single taps so they don't propagate to backdrop + // Single tap → caller handles (e.g. copy URL). + // Consumed either way so backdrop dismiss never fires. + onTap?.invoke() }, ) }.pointerInput(Unit) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt new file mode 100644 index 0000000000..b44b29cc76 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ImageCompressionSettings.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.settings + +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.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality +import com.vitorpamplona.amethyst.desktop.ImageCompressionStore + +/** + * Settings panel for image upload compression — quality preset + + * strip-EXIF toggle. Backed by [ImageCompressionStore]; changes + * persist to [com.vitorpamplona.amethyst.desktop.DesktopPreferences] + * immediately. + * + * Per-post overrides (in the compose dialog) read the current + * default from this store but do not write back. + */ +@Composable +fun ImageCompressionSettings(modifier: Modifier = Modifier) { + val quality by ImageCompressionStore.quality.collectAsState() + val stripExif by ImageCompressionStore.stripExif.collectAsState() + + Column(modifier = modifier.fillMaxWidth()) { + Text( + "Image Compression", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(12.dp)) + + Text( + "Default quality", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow { + CompressionQuality.entries.forEachIndexed { index, preset -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = CompressionQuality.entries.size), + onClick = { ImageCompressionStore.setQuality(preset) }, + selected = preset == quality, + ) { + Text(preset.displayName) + } + } + } + Spacer(Modifier.height(4.dp)) + Text( + quality.summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Strip metadata before upload", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(2.dp)) + Text( + "Removes camera, GPS, and timestamp data from uploaded photos.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(0.dp)) + Switch( + checked = stripExif, + onCheckedChange = ImageCompressionStore::setStripExif, + ) + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt new file mode 100644 index 0000000000..2641907f78 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.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.desktop.cache + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A NIP-18 quote-repost is a kind:1 note that carries a `q` tag pointing at the + * quoted note. Amethyst's repost counter reads `Note.boosts`, which historically + * only collected kind:6/kind:16 reposts — so quote-reposts never showed in the + * quoted note's reaction row. These tests pin the fix: consuming a `q`-tagged note + * adds it as a boost of the quoted note. + */ +class DesktopLocalCacheQuoteBoostTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + // The exact event reported in the task: a kind:1 quote-repost of 9430…ab10. + private val quoteJson = + """ + { + "id": "e45b2897ea5550f68421e0dc3387c2ab2fdcb5f303f91f88199cf45abb18a6b5", + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "created_at": 1781031744, + "kind": 1, + "tags": [ + ["alt", "A short note: Few"], + ["p", "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68", "wss://nostr.wine/"], + ["q", "943082a78d462df27a9cf2267366a2b9533817a6d32234e15a7e4f0f7df0ab10", "wss://spatia-arcana.com/", "deab79dafa1c2be4b4a6d3aca1357b6caa0b744bf46ad529a5ae464288579e68"], + ["client", "Amethyst"] + ], + "content": "Few", + "sig": "3d35780c14d295167b62a760f2409a50e444eb13381ddc0b851becb476c67564f486882a4ea1a2f0247acbfd7c58e37085101b5db6712d972d42e5baafc63520" + } + """.trimIndent() + + private val quotedId = "943082a78d462df27a9cf2267366a2b9533817a6d32234e15a7e4f0f7df0ab10" + + @Test + fun `a quote-repost counts as a boost of the quoted note`() { + val cache = DesktopLocalCache() + val quote = Event.fromJson(quoteJson) + + // wasVerified = true: this test pins the boost wiring, not signature checks. + val consumed = cache.consume(quote, relayUrl, wasVerified = true) + assertTrue(consumed, "The quote-repost should be consumed") + + val quotedNote = cache.getNoteIfExists(quotedId) + assertTrue(quotedNote != null, "The quoted note placeholder should exist") + assertEquals(1, quotedNote.boosts.size, "The quote should count as one boost") + assertEquals(quote.id, quotedNote.boosts.first().idHex) + } + + @Test + fun `a quote-repost authored locally is counted via a fresh signed event`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + val original = + signer.sign( + createdAt = 1_700_000_000, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "the original post", + ) + cache.consume(original, relayUrl, wasVerified = true) + + val quote = + signer.sign( + createdAt = 1_700_000_100, + kind = TextNoteEvent.KIND, + tags = arrayOf(QEventTag(original.id).toTagArray()), + content = "quoting the original", + ) + cache.consume(quote, relayUrl, wasVerified = true) + + val originalNote = cache.getNoteIfExists(original.id) + assertTrue(originalNote != null) + assertEquals(1, originalNote.boosts.size, "The quote should boost the original") + assertEquals(quote.id, originalNote.boosts.first().idHex) + } + + @Test + fun `a plain note with no quote tag does not boost anything`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + val target = + signer.sign( + createdAt = 1_700_000_000, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "target", + ) + cache.consume(target, relayUrl, wasVerified = true) + + val plain = + signer.sign( + createdAt = 1_700_000_100, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "no quotes here", + ) + cache.consume(plain, relayUrl, wasVerified = true) + + assertEquals(0, cache.getNoteIfExists(target.id)?.boosts?.size) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 737ec6acf4..ea4bcc99a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ genaiImageDescription = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "2.2.1" +jlatexmath = "1.4" markdown = "f92ef49c9d" material3 = "1.9.0" media3 = "1.10.1" @@ -59,7 +60,6 @@ securityCryptoKtx = "1.1.0" slf4j = "2.0.18" spotless = "8.6.0" streamWebrtcAndroid = "1.3.10" -tarsosdsp = "2.5" translate = "17.0.3" jetbrainsCompose = "1.11.0" unifiedpush = "3.0.10" @@ -70,6 +70,7 @@ zoomable = "2.12.0" composemediaplayer = "0.10.0" jcodec = "0.2.5" commonsImaging = "1.0.0-alpha6" +thumbnailator = "0.4.21" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" @@ -145,6 +146,7 @@ coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } +thumbnailator = { group = "net.coobird", name = "thumbnailator", version.ref = "thumbnailator" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" } jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" } @@ -176,6 +178,9 @@ 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" } +jlatexmath-android = { module = "com.github.rikkahub.jlatexmath-android:jlatexmath", version.ref = "jlatexmath" } +jlatexmath-font-greek = { module = "com.github.rikkahub.jlatexmath-android:jlatexmath-font-greek", version.ref = "jlatexmath" } +jlatexmath-font-cyrillic = { module = "com.github.rikkahub.jlatexmath-android:jlatexmath-font-cyrillic", version.ref = "jlatexmath" } 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" } @@ -195,7 +200,6 @@ secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-km secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr256k1-kmp", version.ref = "schnorr256k1Kmp" } stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" } -tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt index 88befaaa2c..1acffd1427 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "Android" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt new file mode 100644 index 0000000000..cb720f48f9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.paging + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlin.concurrent.Volatile + +/** + * Per-relay `until`+`limit` pagination cursors for **one scope** (one account, or one conversation): + * how far back each relay has been *asked* to load, and how far it has actually *delivered*. + * + * Pure paging state, no orchestration — the loading / stall / exhaustion / live status a caller shows + * around paging is a separate concern, layered on top by a driver (in this library, `BackwardRelayPager`). + * It carries no key on purpose: the caller holds one instance per scope on whatever object owns that + * scope, so the cursors share that object's lifetime and the object graph is the partition — not a map + * kept in here. + * + * Why `until`+`limit` and not a time window: a `since`/`until` slice that comes back empty can't tell + * "nothing older here" from "just a quiet gap". `until`+`limit` returns the N newest events older than + * `until`, skipping gaps — so an **empty page + EOSE is the gap-proof stop** ([isDone]). (A short page, + * fewer than the limit, is the relay capping the response, not the bottom.) + * + * Two cursors per relay, kept apart so a relay never pages past what it was asked: + * - [requestedUntilFor] — the `until` its REQ carries; moves only in [advance]. Untouched on EOSE, so a + * finished page just parks (no re-REQ) until advanced again — this is what makes paging demand-driven. + * - reached ([reachedUntilFor]) — the oldest `created_at` it has delivered; moves on EOSE. This is the + * "loaded back to here" point, and the next [advance] starts just below it. + * + * No locks of its own: each relay's callbacks are serialized, the cursor fields are `@Volatile`, and the + * relay map is the thread-safe [LargeCache] (keyed by [NormalizedRelayUrl], which orders consistently + * with `equals`). + */ +class RelayLoadingCursors { + private class RelayCursor { + // The `until` the REQ carries; null until the relay is first advanced. Moves only in advance(). + @Volatile var requestedUntil: Long? = null + + // The oldest created_at this relay has delivered; null until its first non-empty page. Moves on + // EOSE. This is the "loaded back to here" point; the next page starts just below it. + @Volatile var reachedUntil: Long? = null + + // Set once the relay answered an empty page with EOSE: there is nothing older on it. + @Volatile var done: Boolean = false + + // Per-page tallies, reset by [advance]: how many events arrived and the oldest among them. + @Volatile var pageCount: Int = 0 + + @Volatile var pageOldest: Long = Long.MAX_VALUE + } + + private val cursors = LargeCache() + + /** + * The history floor this scope pages down from (e.g. `now − liveTail`, pinned by the owner on first + * advance). Kept here so it persists with the scope and doesn't drift on recompute — a relay that + * hasn't delivered yet reports this floor as its reached point, and a moving floor would make a + * demand-driven loader re-fire on it. + */ + @Volatile + var floor: Long? = null + + private fun cursor(relay: NormalizedRelayUrl) = cursors.getOrCreate(relay) { RelayCursor() } + + /** The `until` [relay]'s REQ currently carries. Only meaningful once it has been [advance]d. */ + fun requestedUntilFor(relay: NormalizedRelayUrl): Long? = cursor(relay).requestedUntil + + /** The oldest point [relay] has reached, or [start] if it hasn't delivered yet. */ + fun reachedUntilFor( + relay: NormalizedRelayUrl, + start: Long, + ): Long = cursor(relay).reachedUntil ?: start + + /** True once [relay] answered an empty page with EOSE — nothing older to ask it for. */ + fun isDone(relay: NormalizedRelayUrl): Boolean = cursor(relay).done + + /** + * Steps [relay] to its next, older page: points its REQ just below the oldest event it has delivered + * (or [start] for its very first page) and clears the page tally. No-op (returns false) if the relay + * has already paged to the bottom ([isDone]). The owner re-issues the relay's REQ after this. + */ + fun advance( + relay: NormalizedRelayUrl, + start: Long, + ): Boolean { + val c = cursor(relay) + if (c.done) return false + val reached = c.reachedUntil + c.requestedUntil = + when { + // Resume just below the oldest event already delivered. Covers both normal page-to-page + // advance and a post-[rewindTo] resume (which un-arms the relay — requestedUntil back to + // null — but keeps the rewound reached point, so the next page picks up at the boundary + // instead of restarting at the floor and re-streaming the still-held tail). + reached != null -> reached - 1 + // Very first page for this relay (nothing delivered, nothing requested yet). + c.requestedUntil == null -> start + // Armed but still mid-page (no EOSE yet) — keep asking from the same top. + else -> start + } + c.pageCount = 0 + c.pageOldest = Long.MAX_VALUE + return true + } + + /** Records one event for [relay] in the current page. */ + fun onEvent( + relay: NormalizedRelayUrl, + createdAt: Long, + ) { + val c = cursor(relay) + c.pageCount++ + if (createdAt < c.pageOldest) c.pageOldest = createdAt + } + + /** + * Finalizes [relay] for the page on its EOSE: an empty page marks it [isDone]; otherwise the reached + * cursor drops to the oldest event the page returned. The requested cursor is left alone so the relay + * parks until [advance] is called again. + */ + fun onEose(relay: NormalizedRelayUrl) { + val c = cursor(relay) + if (c.pageCount == 0) { + c.done = true + } else { + // The reached cursor must move strictly older every page (the next page asks `until = + // reached - 1`). A relay that returns events but none older than we already have — a + // misbehaving relay echoing the same newest events — would otherwise pin the cursor and a + // demand-driven loader would re-request the same window forever. Treat that as the bottom. + val prev = c.reachedUntil + if (prev == null || c.pageOldest < prev) { + c.reachedUntil = c.pageOldest + } else { + c.done = true + } + } + } + + /** + * Realigns the window after the cache prunes messages out of it: for each `relay → newestPrunedUntil` + * entry, rewinds that relay so it no longer claims to hold anything at or below [newestPrunedUntil] + * (the newest cursor-space `created_at` among the messages pruned from that relay — for gift wraps the + * **outer-wrap** time, recovered from the rumor's [host][com.vitorpamplona.quartz.nip59Giftwrap.HostStub]). + * + * Without this, a relay that already paged past the pruned band — or reached `done` — would never + * re-request the dropped messages: its [reachedUntil] still points below them, so the next [advance] + * starts even older and skips the hole entirely. + * + * The rewind pulls [reachedUntil] back up to just above [newestPrunedUntil] (so the next page's + * `until` re-includes it), clears [done] (there *is* older data to re-fetch again), and un-arms the + * relay (requested cursor back to null) so paging stays demand-driven — the dropped band comes back + * only when the on-screen marker advances the relay again, not eagerly on the next re-subscribe. + * + * Bounds: + * - A relay with no cursor yet (never paged) is skipped — there is no window position to misalign. + * - The rewind never moves [reachedUntil] above the pinned [floor] (history lives strictly below it; + * a pruned message newer than the floor is the live tail's concern, not this window's). + * - A relay whose reached point is already shallower than the pruned band needs no rewind. + */ + fun rewindTo(newestPrunedUntil: Map) { + val floorAt = floor ?: return + newestPrunedUntil.forEach { (relay, prunedUntil) -> + val c = cursors.get(relay) ?: return@forEach + val reached = c.reachedUntil ?: return@forEach + // Re-include the newest pruned event: the next page asks `until = reached - 1`, so reached must + // sit one tick above it. Never climb above the floor. + val target = minOf(prunedUntil + 1, floorAt) + if (reached < target) { + c.reachedUntil = target + c.requestedUntil = null + c.done = false + c.pageCount = 0 + c.pageOldest = Long.MAX_VALUE + } + } + } + + /** Relays from [all] that have been armed (advanced at least once) and are not yet [isDone]. */ + fun armedRelays(all: Collection): List = + all.filter { + val c = cursor(it) + c.requestedUntil != null && !c.done + } + + /** + * The oldest point reached across [relays] — the minimum reached cursor (how far back paging has + * gone). Relays that haven't delivered count as [start]. Null when [relays] is empty. + */ + fun deepestReached( + relays: Collection, + start: Long, + ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(it).reachedUntil ?: start } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt new file mode 100644 index 0000000000..125a3da2ba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.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.nip01Core.relay.client.paging + +/** How far back one relay has paged through its history, for a per-relay progress display. */ +data class RelayPagingProgress( + // The oldest createdAt this relay has loaded down to (its `until` cursor). This is the "loaded back + // to" point; it slides down (older) as the relay pages further back. + val reachedUntil: Long, + // The relay answered an empty page: it has nothing older, it has reached the bottom of its window. + val done: Boolean, + // The relay isn't answering right now (auth-walled CLOSE / unreachable / slow). It is NOT abandoned + // — its subscription stays open and it keeps trying to catch up — but it isn't currently advancing. + val stalled: Boolean, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt index b971711e4b..8fdd3c92d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt @@ -22,8 +22,19 @@ package com.vitorpamplona.quartz.nip59Giftwrap import com.vitorpamplona.quartz.nip01Core.core.HexKey +/** + * A lightweight reference to the host event a [WrappedEvent] was extracted from — kept on the inner + * event so callers can broadcast / delete / locate the outer wrap without holding the full event. + * + * [createdAt] is the host's own `created_at` (e.g. the kind:1059 gift-wrap timestamp, randomized per + * NIP-59), carried here so a decrypted rumor self-describes its outer-wrap time. The history pager + * cursors page gift wraps by that outer time, so the prune path uses it to realign the per-relay + * download window when a wrapped message is pruned (the chatroom only keeps the inner rumor, whose + * `created_at` is the real message time, not the wrap time). + */ class HostStub( val id: HexKey, val pubKey: HexKey, val kind: Int, + val createdAt: Long, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index 3591a5d7a1..ad2c9cf873 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -70,7 +70,7 @@ class SealedRumorEvent( val event = rumor.mergeWith(this) if (event is WrappedEvent) { - event.host = host ?: HostStub(this.id, this.pubKey, this.kind) + event.host = host ?: HostStub(this.id, this.pubKey, this.kind, this.createdAt) } innerEventId = event.id diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index af7298adf8..7d19424ddc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -74,7 +74,7 @@ open class GiftWrapEvent( val gift = fromJson(giftStr) if (gift is WrappedEvent) { - gift.host = HostStub(this.id, this.pubKey, this.kind) + gift.host = HostStub(this.id, this.pubKey, this.kind, this.createdAt) } innerEventId = gift.id diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt index dca8125f51..486753cef2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag @Immutable -abstract class ReplaceableVideoEvent( +abstract class AddressableVideoEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -45,7 +45,7 @@ abstract class ReplaceableVideoEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, kind, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), PublishedAtProvider, VideoEvent, RootScope { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt index 2facb76e93..dbd5434a59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt @@ -40,7 +40,7 @@ class VideoHorizontalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34235 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt index e293129f49..0f46b0433e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt @@ -40,7 +40,7 @@ class VideoVerticalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34236 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt index b007a052eb..3a49a2df69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils expect fun platform(): String expect fun currentTimeSeconds(): Long + +expect fun currentTimeMillis(): Long diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index 90ca1272c8..b8f1a09ec8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -36,6 +36,8 @@ object TimeUtils { fun now() = currentTimeSeconds() + fun nowMillis() = currentTimeMillis() + fun tenSecondsFromNow() = now() + TEN_SECONDS fun tenSecondsAgo() = now() - TEN_SECONDS diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt new file mode 100644 index 0000000000..0f878d0ee0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video + +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Kinds 34235/34236 (legacy NIP-71 videos) are parameterized replaceable + * events: their address MUST include the `d` tag. A wrong (empty-dTag) + * address makes LocalCache consume the event into a different + * AddressableNote than the one `a` tags point to, so quotes/reposts of + * these videos never resolve on screen. + */ +class AddressableVideoEventAddressTest { + // Fixture from a real kind-34236 event published by the Divine client + // (bfe2f224…, "Lunchtime for our Koi"); the horizontal test reuses the + // same data synthetically. + private val pubkey = "3b6187c08b9dd5617150ea047e788a0fdd44b4394cb5566cba76f683ddc027d2" + private val dTag = "7af7cae314483a84dcc204824cef10aace246a69c819734412330e2a25f459a1" + + private fun assertAddressUsesDTag( + kind: Int, + event: AddressableVideoEvent, + ) { + assertEquals(dTag, event.dTag()) + assertEquals(Address(kind, pubkey, dTag), event.address()) + // The kind:pubkey:dTag wire format is fixed by NIP-01, so it is + // asserted literally instead of via Address.assemble (which is + // what addressTag() calls internally). + assertEquals("$kind:$pubkey:$dTag", event.addressTag()) + } + + @Test + fun verticalVideoAddressUsesDTag() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag), arrayOf("title", "Lunchtime for our Koi")), + content = "Lunchtime for our Koi", + sig = "", + ) + + assertAddressUsesDTag(VideoVerticalEvent.KIND, event) + } + + @Test + fun horizontalVideoAddressUsesDTag() { + val event = + VideoHorizontalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "", + ) + + assertAddressUsesDTag(VideoHorizontalEvent.KIND, event) + } + + @Test + fun videoWithoutDTagFallsBackToEmptyAddress() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("title", "No d tag")), + content = "", + sig = "", + ) + + assertEquals("", event.dTag()) + assertEquals(Address(VideoVerticalEvent.KIND, pubkey, ""), event.address()) + assertEquals("${VideoVerticalEvent.KIND}:$pubkey:", event.addressTag()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt new file mode 100644 index 0000000000..f6dd98567b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards the kind-range vs class-hierarchy invariant across the whole + * EventFactory: parameterized replaceables (30000..39999) must derive their + * address from the `d` tag, while plain replaceables (10000..19999, plus + * kinds 0 and 3) must ignore stray `d` tags. A typed class extending the + * wrong base (the kind-34235/34236 bug, fixed in this branch) splits the + * cache between the event's own address and the address `a` tags reference. + * + * Known offenders that predate this guard are allowlisted below so the test + * catches NEW mismatches; shrink the lists as they get fixed. + */ +class EventFactoryKindRangeTest { + /** + * NIP-87 ecash kinds that are addressable per spec but currently extend + * plain Event, so they have no address at all and never replace older + * versions in the cache. + */ + private val knownNonAddressable = setOf(38000, 38172, 38173) + + /** + * NIP-51-style list kinds whose shared PrivateTagArrayEvent hierarchy + * reads `d` tags even though plain replaceables must ignore them; a + * stray `d` tag on a malformed event fragments their cache address. + */ + private val knownDTagReaders = + setOf( + 10004, + 10005, + 10006, + 10007, + 10009, + 10012, + 10013, + 10015, + 10017, + 10018, + 10020, + 10023, + 10040, + 10054, + 10081, + 10086, + 10087, + 10088, + 10089, + 10090, + 10101, + 10102, + ) + + private val probeDTag = "probe-d-tag" + + private fun probe(kind: Int) = EventFactory.create("", "", 0L, kind, arrayOf(arrayOf("d", probeDTag)), "", "") + + @Test + fun addressableKindsReadTheirDTag() { + val violations = + (30000 until 40000).mapNotNull { kind -> + val event = probe(kind) + when { + // Unknown kinds parse as a bare Event; only typed classes are checked. + event::class == Event::class -> null + kind in knownNonAddressable -> null + event !is AddressableEvent -> "kind $kind (${event::class.simpleName}) does not implement AddressableEvent" + event.dTag() != probeDTag -> "kind $kind (${event::class.simpleName}) ignores its d tag" + else -> null + } + } + + assertEquals(emptyList(), violations) + } + + @Test + fun plainReplaceableKindsIgnoreStrayDTags() { + val violations = + (listOf(0, 3) + (10000 until 20000)).mapNotNull { kind -> + val event = probe(kind) + if (kind !in knownDTagReaders && event::class != Event::class && event is AddressableEvent && event.dTag() != "") { + "kind $kind (${event::class.simpleName}) addresses itself by a stray d tag" + } else { + null + } + } + + assertEquals(emptyList(), violations) + } +} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt index 9f2b842edb..fee99bbf15 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt @@ -31,3 +31,5 @@ actual fun currentTimeSeconds(): Long { // NSDate().timeIntervalSince1970 returns seconds since 1970-01-01 00:00:00 UTC return (NSDate().timeIntervalSince1970).toLong() } + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt new file mode 100644 index 0000000000..1c1a43f1e1 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.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.nip01Core.relay.client.paging + +import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.collectUntilEose +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins down the relay-side contract the whole [RelayLoadingCursors] / `BackwardRelayPager` design rests on, + * against the in-process relay: a backward `until`+`limit` walk returns each event **exactly once** + * (no re-download), in **newest-first** capped pages, and an **empty page + EOSE** is the gap-proof + * stop. If a relay ever stopped honouring this (e.g. oldest-first, or ignoring `until`), these break — + * which is exactly the signal the pager's correctness depends on. + */ +class UntilLimitPagingRelayTest : RelayClientTest() { + @Test + fun backwardUntilLimitWalkCoversEveryEventOnceAndStopsOnEmptyPage() = + runBlocking { + // 250 regular events, createdAt 1..250 (distinct pubkeys so none collapse). + defaultRelay.preload(SyntheticEvents.batch(TOTAL, kind = KIND)) + + val seenIds = mutableSetOf() + var totalReceived = 0 + var pages = 0 + var until: Long? = null + + while (pages < SAFETY_CAP) { + val (events, eose) = + client.collectUntilEose( + defaultRelayUrl, + Filter(kinds = listOf(KIND), until = until, limit = LIMIT), + ) + assertTrue(eose, "every page must end with EOSE") + + if (events.isEmpty()) break // gap-proof stop: empty page = nothing older + + pages++ + assertTrue(events.size <= LIMIT, "page must respect the limit") + // Newest-first + cursor honoured: nothing newer than the cursor leaks into a later page. + until?.let { cursor -> assertTrue(events.all { it.createdAt <= cursor }, "page must be older than the cursor") } + + events.forEach { e: Event -> + seenIds.add(e.id) + totalReceived++ + } + until = events.minOf { it.createdAt } - 1 + } + + // No re-download: total delivered equals the corpus, and every id is distinct. + assertEquals(TOTAL, totalReceived, "no event should be delivered twice across pages") + assertEquals(TOTAL, seenIds.size, "every event fetched exactly once") + // 250 / 100 → 100 + 100 + 50, then an empty page stops the walk. + assertEquals(3, pages) + } + + @Test + fun anEmptyRelayAnswersOneEmptyPageWithEose() = + runBlocking { + val (events, eose) = + client.collectUntilEose( + defaultRelayUrl, + Filter(kinds = listOf(KIND), until = null, limit = LIMIT), + ) + assertTrue(eose) + assertEquals(0, events.size) + } + + companion object { + private const val KIND = 1 + private const val TOTAL = 250 + private const val LIMIT = 100 + private const val SAFETY_CAP = 10 + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt index d4b4f6b5e5..d6979a6e06 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "JVM" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt index 7e4b5fc506..0b02bf8b58 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt @@ -38,3 +38,12 @@ actual fun currentTimeSeconds(): Long { return ts.tv_sec } } + +@OptIn(ExperimentalForeignApi::class) +actual fun currentTimeMillis(): Long { + memScoped { + val ts = alloc() + clock_gettime(CLOCK_REALTIME, ts.ptr) + return ts.tv_sec * 1000 + ts.tv_nsec / 1_000_000 + } +} diff --git a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt index 75f7e49519..49c1daf844 100644 --- a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt +++ b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt @@ -26,3 +26,5 @@ import platform.Foundation.timeIntervalSince1970 actual fun platform() = "macOS" actual fun currentTimeSeconds(): Long = (NSDate().timeIntervalSince1970).toLong() + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() diff --git a/settings.gradle.kts b/settings.gradle.kts index eb3c2a3768..f30ae000ba 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,7 +26,6 @@ dependencyResolutionManagement { mavenCentral() maven { url = uri("https://jitpack.io") } maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") } - maven { url = uri("https://mvn.0110.be/releases") } } }