Merge branch 'main' into feat/desktop-vlcj-to-kdroidfilter

This commit is contained in:
Róbert Nagy
2026-06-11 13:50:42 +03:00
committed by GitHub
256 changed files with 14718 additions and 1513 deletions
+45 -43
View File
@@ -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 <https://github.com/nostr-protocol/nips> — use
`/nip <number>` 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
+3 -3
View File
@@ -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/`
+2 -2
View File
@@ -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
+64 -355
View File
@@ -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/<skill-name>/` 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<T>`.
+1 -1
View File
@@ -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"
+12
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply 2>/dev/null",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh",
"timeout": 120
}
]
+23 -25
View File
@@ -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<T>`.
- Expose a `StateFlow<T>` 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.<featureFlow>`.
- **Are you rendering "my follows", "my mutes", "my relays"?** → `account.<feature>.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).
@@ -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.<property>.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<T> = _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.
+8 -3
View File
@@ -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
+1 -1
View File
@@ -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.
+9 -4
View File
@@ -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
@@ -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
+36 -22
View File
@@ -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<T> (abstract base) │
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
│ ChangesFlowFilter │
│ FeedContentState, FeedState — the flow the UI collects │
└─────────────────────────────────────────────────────────────┘
▲
│ uses
│
┌─────────────────────────────────────────────────────────────┐
│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │
│ FeedFilter<T> (abstract) │
│ amethyst/.../ui/dal/ (Android-only additions) │
│ AdditiveComplexFeedFilter<T, U> │
│ 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.<featureFlow> │
│ model/LocalCache.kt + account.<feature>.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<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. 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<T>`. Has `feed(): List<T>` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache).
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`. 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<T, U> : FeedFilter<T>()`: like `AdditiveFeedFilter` but the incoming items (`Set<U>`) 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<Note, Set<HexKey>>` (or plain `FeedFilter<Note>` if additivity doesn't matter). Implement:
1. **Define the filter.** Extend `AdditiveFeedFilter<Note>` (or plain `FeedFilter<Note>` if additivity doesn't matter; `AdditiveComplexFeedFilter<T, U>` 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<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
- If additive: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
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
@@ -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<Note>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter<Note, Set<Note>>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter<Note>` |
| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter<T, U>` (Android-only) |
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `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<Note, Set<Note>>() {
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "Hashtag-$hashtag"
@@ -28,7 +29,7 @@ class HashtagFeedFilter(
override fun feed(): List<Note> {
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() }
}
}
```
+6 -6
View File
@@ -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
@@ -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
+2 -3
View File
@@ -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
+32 -44
View File
@@ -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<String> = emptyList()): String
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = 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<String>) : Nip19Result()
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
}
// Encode TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
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")
}
```
@@ -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
+27 -21
View File
@@ -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<NormalizedRelayUrl>)
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<Event>(
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<Event>(Filter(search = "bitcoin"))
```
---
-23
View File
@@ -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.
+8 -2
View File
@@ -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<T>
│ ├── 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<T>`
+14 -8
View File
@@ -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
+5 -3
View File
@@ -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)
@@ -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.
@@ -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(
@@ -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<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -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
@@ -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<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -253,6 +264,11 @@ class AccountLanguagePreferences(
): String? = languagePreferences.value["$source,$target"]
}
@Stable
class AccountMediaPreferences(
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
)
@Stable
class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
@@ -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",
)
@@ -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<Note>,
) {
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<Note>()
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<NormalizedRelayUrl, Long>()
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
val roomNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
// 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)
@@ -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) }
}
@@ -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,
)
@@ -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<Boolean> {
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<Spectrum>() }
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<Boolean> {
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<out AudioDeviceInfo>?) {
isBluetooth.value = audioManager.isBluetoothOutput()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
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) }
}
@@ -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
@@ -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 {
@@ -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<Spectrum>? = 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<String, MutableSharedFlow<Spectrum>>(16, 0.75f, true)
private val sinkByPlayer = ConcurrentHashMap<Any, SpectrumAudioBufferSink>()
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<Spectrum> = flowFor(mediaId)
private fun flowFor(mediaId: String): MutableSharedFlow<Spectrum> =
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)
}
}
}
@@ -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<String>()
// 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<NormalizedRelayUrl>()
// 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<String>()
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","<subId>",{...}]` 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 }
}
}
}
@@ -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<NormalizedRelayUrl>,
) {
if (relays.isNotEmpty()) append(" $name=${relays.map { it.url }}")
}
}
@@ -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<AccountQueryState>() {
// 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),
@@ -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<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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<Boolean> = windowLoad.loading
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// 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<User, List<Job>>()
private val userJobMap = mutableMapOf<User, List<Job>>()
@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"
}
}
@@ -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<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
private val pager = BackwardRelayPager("giftwrap.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
companion object {
private const val TAG = "DMPagination"
}
}
@@ -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
}
}
@@ -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<Float>(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
}
}
@@ -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<Color>,
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 }
@@ -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
@@ -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()}",
@@ -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<Route.ComposeSettings> { ComposeSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
@@ -325,6 +325,8 @@ sealed class Route {
@Serializable object ReactionsSettings : Route()
@Serializable object AudioVisualizerSettings : Route()
@Serializable object BottomBarSettings : Route()
@Serializable object HomeTabsSettings : Route()
@@ -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,
@@ -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<User>,
priority: Set<HexKey>,
): List<User> =
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<HexKey> = { 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()
@@ -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(
@@ -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(
@@ -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<VisualizerStyle> = account.settings.syncedSettings.media.audioVisualizer
fun changeAudioVisualizer(style: VisualizerStyle) =
launchSigner {
account.changeAudioVisualizer(style)
}
fun updateZapAmounts(
amountSet: List<Long>,
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)
@@ -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,
@@ -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<Note>, 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<Note>, 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<Note>, 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() }
}
}
}
@@ -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
@@ -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,
)
}
}
@@ -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))
@@ -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<Boolean> =
when (protocol) {
DmReplyProtocol.NIP17 -> giftWrapsHistory.loadingMore
DmReplyProtocol.NIP04 -> nip04History.loadingMore
}
val statusFlow: StateFlow<PagingStatus> =
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,
)
}
}
}
}
}
@@ -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<Note?>(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<Note?>,
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()
}
}
}
@@ -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<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(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<SelectedMedia>) {
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()
}
}
@@ -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<List<RelayReachCursor>?>(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('/')
@@ -36,9 +36,16 @@ class ChatroomQueryState(
class ChatroomFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ChatroomQueryState>() {
// 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()
@@ -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<ChatroomQueryState>,
) : PerUserAndFollowListEoseManager<ChatroomQueryState, String>(client, allKeys) {
private val pager = BackwardRelayPager("convo.nip04.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = 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<RelayBasedFilter>? {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}
@@ -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<ChatroomQueryState>,
) : PerUserAndFollowListEoseManager<ChatroomQueryState, String>(client, allKeys) {
private val windowLoad = WindowLoadTracker("convo.nip04.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
override fun updateFilter(
key: ChatroomQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? =
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) },
)
}
}
@@ -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<NormalizedRelayUrl, Set<HexKey>>,
val fromMeRelays: Map<NormalizedRelayUrl, Set<HexKey>>,
) {
val all: Set<NormalizedRelayUrl> get() = toMeRelays.keys + fromMeRelays.keys
}
private fun addAll(
map: MutableMap<NormalizedRelayUrl, MutableSet<HexKey>>,
relays: Collection<NormalizedRelayUrl>,
keys: Collection<HexKey>,
) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) }
fun nip04DmRelayRouting(
group: Set<HexKey>?,
account: Account?,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
): Nip04DmRelayRouting? {
if (group.isNullOrEmpty() || account == null) return null
val userOutboxRelays = account.homeRelays.flow.value
val userInboxRelays = account.dmRelays.flow.value
val groupOutboxRelays = mutableSetOf<NormalizedRelayUrl>()
val groupInboxRelays = mutableSetOf<NormalizedRelayUrl>()
// relay -> counterpart keys whose messages-to-me we ask that relay for (authors set, #p=[me]).
val toMe = mutableMapOf<NormalizedRelayUrl, MutableSet<HexKey>>()
// relay -> counterpart keys whose copy of my messages we ask that relay for (#p set, authors=[me]).
val fromMe = mutableMapOf<NormalizedRelayUrl, MutableSet<HexKey>>()
// 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<HexKey>,
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<HexKey>,
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<HexKey>?,
account: Account?,
windowStart: Long,
): List<RelayBasedFilter>? {
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<RelayBasedFilter> =
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) }
@@ -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)
@@ -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)
@@ -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)
@@ -35,9 +35,16 @@ class ChatroomListState(
class ChatroomListFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ChatroomListState>() {
// 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),
)
@@ -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<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(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<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
override fun user(key: ChatroomListState) = key.account.userProfile()
override fun updateFilter(
key: ChatroomListState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}
@@ -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<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
private val windowLoad = WindowLoadTracker("rooms.nip04.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
override fun updateFilter(
key: ChatroomListState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? =
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<User, List<Job>>()
private val userJobMap = mutableMapOf<User, List<Job>>()
@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(
@@ -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,
),
)
@@ -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,
),
)
@@ -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<List<RelayReachCursor>?>(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<Boolean>,
status: StateFlow<PagingStatus>,
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
@@ -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<Int, OptionTag> = newStateMapPollOptions()
@@ -266,7 +268,6 @@ open class ShortNotePostViewModel :
// ZapPolls
var canUseZapPoll by mutableStateOf(false)
var wantsZapPoll by mutableStateOf(false)
var zapPollOptions: SnapshotStateMap<Int, String> = newStateMapZapPollOptions()
var zapPollValueMaximum by mutableStateOf<Long?>(null)
var zapPollValueMinimum by mutableStateOf<Long?>(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<Int, String> =
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<Int, String>.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()
@@ -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)
@@ -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())
)
@@ -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<Spectrum>,
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)
}
@@ -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),
@@ -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: `<filesDir>/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" }
@@ -190,6 +190,8 @@
<string name="generate_a_new_key">إنشاء مفتاح جديد</string>
<string name="loading_feed">بإنتظار التحديثات</string>
<string name="loading_account">جاري تحميل الحساب</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"خطأ في تحميل الردود:"</string>
<string name="try_again">المحاولة مرة اخرى</string>
<string name="notification_feed_is_empty">لا توجد إشعارات حتى الآن.</string>
@@ -179,6 +179,8 @@
<string name="create_a_new_account">একটি নতুন অ্যাকাউন্ট তৈরি করুন</string>
<string name="generate_a_new_key">একটি নতুন চাবি তৈরি করুন</string>
<string name="loading_feed">পাতাটি লোড করা হচ্ছে</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"প্রতিউত্তরগুলি লোড করা যাচ্ছে না: "</string>
<string name="try_again">আবার চেষ্টা করুন</string>
<string name="feed_is_empty">পাতাটি খালি</string>
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<!-- Call notifications and UI -->
<!-- Settings search -->
<!-- Settings search keywords: an English concept/protocol index that supplements the
@@ -267,6 +267,8 @@
<string name="generate_a_new_key">Vygenerovat nový klíč</string>
<string name="loading_feed">Načítání zdroje</string>
<string name="loading_account">Načítání účtu</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Chyba při načítání odpovědí: "</string>
<string name="try_again">Zkusit znovu</string>
<string name="notification_feed_is_empty">Zatím žádná oznámení.</string>
@@ -2111,6 +2113,15 @@
<string name="video_player_settings_action_pip_description">Přehrávat video v plovoucím okně (skryto, pokud není podporováno)</string>
<string name="video_player_settings_action_cast">Odeslat do zařízení</string>
<string name="video_player_settings_action_cast_description">Odeslat video do přijímače Chromecast ve vaší Wi-Fi (skryto pro místní soubory)</string>
<string name="audio_visualizer_settings">Vizualizér zvuku</string>
<string name="audio_visualizer_settings_description">Vyberte animaci zobrazenou při přehrávání zvukových poznámek.</string>
<string name="audio_visualizer_off">Vypnuto</string>
<string name="audio_visualizer_bars">Spektrální pruhy</string>
<string name="audio_visualizer_waves">Color Waves</string>
<string name="audio_visualizer_radial">Radiální prstenec</string>
<string name="audio_visualizer_aurora">Aurora Glow</string>
<string name="audio_visualizer_classic">Klasická křivka</string>
<string name="audio_visualizer_static">Statický obrázek</string>
<string name="profile_image_of_user">Obrázek profilu %1$s</string>
<string name="relay_info">Relé %1$s</string>
<string name="expand_relay_list">Rozbalit seznam relací</string>
@@ -2862,6 +2873,20 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">Tento cíl byl ukončen</string>
<string name="goal_progress">%1$s financováno z %2$s sats cíle</string>
<string name="fundraiser_ends">Končí %1$s</string>
<string name="fundraiser_onchain_donation">On-chain dar</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d druh</item>
<item quantity="few">Birdex · %1$d druhy</item>
<item quantity="many">Birdex · %1$d druhů</item>
<item quantity="other">Birdex · %1$d druhů</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="one">%1$s +%2$d další</item>
<item quantity="few">%1$s +%2$d další</item>
<item quantity="many">%1$s +%2$d dalších</item>
<item quantity="other">%1$s +%2$d dalších</item>
</plurals>
<string name="goal_amount_label">Cílová částka (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Popište svůj cíl</string>
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<!-- Call notifications and UI -->
<!-- Settings search -->
<!-- Settings search keywords: an English concept/protocol index that supplements the
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<!-- Call notifications and UI -->
<!-- Settings search -->
<!-- Settings search keywords: an English concept/protocol index that supplements the
@@ -267,6 +267,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="generate_a_new_key">Neuen Schlüssel generieren</string>
<string name="loading_feed">Feed wird geladen</string>
<string name="loading_account">Konto wird geladen</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Fehler beim Laden der Antworten: "</string>
<string name="try_again">Erneut versuchen</string>
<string name="notification_feed_is_empty">Noch keine Benachrichtigungen.</string>
@@ -2080,6 +2082,15 @@ anz der Bedingungen ist erforderlich</string>
<string name="video_player_settings_action_pip_description">Video in einem schwebenden Fenster abspielen (ausgeblendet, wenn nicht unterstützt)</string>
<string name="video_player_settings_action_cast">An Gerät streamen</string>
<string name="video_player_settings_action_cast_description">Sendet das Video an einen Chromecast-Empfänger in deinem WLAN (für lokale Dateien ausgeblendet)</string>
<string name="audio_visualizer_settings">Audio-Visualisierer</string>
<string name="audio_visualizer_settings_description">Wähle die Animation, die beim Abspielen von Audio-Notizen angezeigt wird.</string>
<string name="audio_visualizer_off">Aus</string>
<string name="audio_visualizer_bars">Spektrum-Balken</string>
<string name="audio_visualizer_waves">Color Waves</string>
<string name="audio_visualizer_radial">Radialer Ring</string>
<string name="audio_visualizer_aurora">Aurora Glow</string>
<string name="audio_visualizer_classic">Klassische Wellenform</string>
<string name="audio_visualizer_static">Statisches Bild</string>
<string name="profile_image_of_user">Profilbild von %1$s</string>
<string name="relay_info">%1$s weiterleiten</string>
<string name="expand_relay_list">Rela-Liste erweitern</string>
@@ -2815,6 +2826,16 @@ anz der Bedingungen ist erforderlich</string>
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">Dieses Ziel wurde geschlossen</string>
<string name="goal_progress">%1$s finanziert von %2$s Sats Ziel</string>
<string name="fundraiser_ends">Endet %1$s</string>
<string name="fundraiser_onchain_donation">On-Chain-Spende</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d Art</item>
<item quantity="other">Birdex · %1$d Arten</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="one">%1$s +%2$d weitere</item>
<item quantity="other">%1$s +%2$d weitere</item>
</plurals>
<string name="goal_amount_label">Zielbetrag (Sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Beschreibe dein Ziel</string>
@@ -145,6 +145,8 @@
<string name="generate_a_new_key">Δημιουργία νέου κλειδιού</string>
<string name="loading_feed">Φόρτωση ροής</string>
<string name="loading_account">Φόρτωση λογαριασμού</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Σφάλμα κατά τη φόρτωση απαντήσεων: "</string>
<string name="try_again">Προσπαθήστε ξανά</string>
<string name="feed_is_empty">Η Ροή είναι κενή.</string>
@@ -7,6 +7,8 @@
<string name="scan_qr">Scan QR</string>
<string name="show_anyway">Show Anyway</string>
<string name="post_not_found_short">👀</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<!-- Call notifications and UI -->
<!-- Settings search -->
<!-- Settings search keywords: an English concept/protocol index that supplements the
@@ -144,6 +144,8 @@
<string name="login">Ensaluti</string>
<string name="generate_a_new_key">Generi novan ŝlosilon</string>
<string name="loading_feed">Ŝargante fluon</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Eraro ŝargante respondojn: "</string>
<string name="try_again">Provi denove</string>
<string name="feed_is_empty">Fluo estas malplena.</string>
@@ -258,6 +258,8 @@
<string name="generate_a_new_key">Generar una nueva clave</string>
<string name="loading_feed">Cargando el tablón…</string>
<string name="loading_account">Cargando cuenta</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Error al cargar las respuestas: "</string>
<string name="try_again">Intentar otra vez</string>
<string name="notification_feed_is_empty">Aún no hay notificaciones.</string>
@@ -254,6 +254,8 @@
<string name="generate_a_new_key">Generar una clave nueva</string>
<string name="loading_feed">Cargando feed</string>
<string name="loading_account">Cargando cuenta</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Error al cargar las respuestas: "</string>
<string name="try_again">Intentar de nuevo</string>
<string name="notification_feed_is_empty">Aún no hay notificaciones.</string>
@@ -254,6 +254,8 @@
<string name="generate_a_new_key">Generar una clave nueva</string>
<string name="loading_feed">Cargando feed</string>
<string name="loading_account">Cargando cuenta</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Error al cargar las respuestas: "</string>
<string name="try_again">Intentar de nuevo</string>
<string name="notification_feed_is_empty">Aún no hay notificaciones.</string>
@@ -196,6 +196,8 @@
<string name="generate_a_new_key">ساختن جفت کلید جدید</string>
<string name="loading_feed">در حال بارگیری خبرنامه</string>
<string name="loading_account">بارگذاری حساب کاربری</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">" :خطا در بارگیری پاسخ ها"</string>
<string name="try_again">دوباره تلاش کنید</string>
<string name="notification_feed_is_empty">هنوز هیچ اعلانی نیست.</string>
@@ -147,6 +147,8 @@
<string name="generate_a_new_key">Luo uusi avain</string>
<string name="loading_feed">Ladataan syöte</string>
<string name="loading_account">Ladataan tiliä</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Virhe ladattaessa vastauksia: "</string>
<string name="try_again">Yritä uudelleen</string>
<string name="feed_is_empty">Syöte on tyhjä.</string>
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<!-- Call notifications and UI -->
<!-- Settings search -->
<!-- Settings search keywords: an English concept/protocol index that supplements the
@@ -243,6 +243,8 @@
<string name="generate_a_new_key">Générer une nouvelle clé</string>
<string name="loading_feed">Chargement du flux</string>
<string name="loading_account">Chargement du compte</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="error_loading_replies">"Erreur lors du chargement des réponses : "</string>
<string name="try_again">Essayer à nouveau</string>
<string name="notification_feed_is_empty">Aucune notification pour le moment.</string>

Some files were not shown because too many files have changed in this diff Show More