mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge branch 'main' into feat/desktop-vlcj-to-kdroidfilter
This commit is contained in:
+45
-43
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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
@@ -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>`.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+12
@@ -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
|
||||
@@ -16,7 +16,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "./gradlew spotlessApply 2>/dev/null",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+72
@@ -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) }
|
||||
}
|
||||
+24
-4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+142
-21
@@ -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) }
|
||||
}
|
||||
|
||||
+39
-3
@@ -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
|
||||
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
+173
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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 }}")
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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),
|
||||
|
||||
+40
-22
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+157
@@ -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
|
||||
}
|
||||
}
|
||||
+39
-136
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -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()
|
||||
|
||||
+32
-1
@@ -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,
|
||||
|
||||
+31
-1
@@ -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()
|
||||
|
||||
+5
-3
@@ -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(
|
||||
|
||||
+3
-3
@@ -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(
|
||||
|
||||
+16
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+48
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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
|
||||
+59
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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))
|
||||
+222
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-72
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -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()
|
||||
}
|
||||
}
|
||||
+148
@@ -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('/')
|
||||
|
||||
+8
-1
@@ -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()
|
||||
|
||||
+155
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+110
-36
@@ -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) }
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+8
-1
@@ -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),
|
||||
)
|
||||
|
||||
+145
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-15
@@ -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(
|
||||
+4
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+4
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+160
-5
@@ -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
|
||||
|
||||
+7
-40
@@ -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()
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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())
|
||||
)
|
||||
|
||||
|
||||
+161
@@ -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)
|
||||
}
|
||||
+1
@@ -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
Reference in New Issue
Block a user