Merge remote-tracking branch 'origin/main' into claude/beautiful-ride-n6y3s2

This commit is contained in:
Claude
2026-06-11 20:54:50 +00:00
162 changed files with 9255 additions and 2112 deletions
+14 -43
View File
@@ -12,8 +12,10 @@ architecture while sharing the back end components with the android counterpart.
a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53
audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
production listener AND speaker paths both run on moq-lite to interop with the
nostrnests reference relay. The IETF code is kept as a reference + unit-test
@@ -23,27 +25,13 @@ implementation for any future IETF target; see
Canonical NIP specs live at <https://github.com/nostr-protocol/nips> — use
`/nip <number>` to pull a specific one (it fetches the spec file directly).
## Verify, Don't Guess (standing instruction)
## Verify, Don't Guess
A plausible-sounding explanation is cheap; being right is not. Before
asserting what a problem is or how something behaves:
1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm
guessing" or "haven't verified" — never dress an untested guess up as a
diagnosis. Use "I verified X by running Y" only when you actually did.
2. **Reproduce before diagnosing.** If a claim is checkable in under a
minute, check it before stating it. This repo gives you the means:
`./gradlew test`, the per-module tests, and `amy` (the CLI exists partly
to drive `quartz`/`commons` for interop checks). Write the failing case
first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix`
(reproduce-first) or `/investigate` (competing hypotheses + refutation).
3. **Predict, then run.** Before running a command, state the output you
expect. A mismatch is the cheapest signal that your model is wrong.
4. **Don't commit to one cause.** A single immediate explanation stops you
from looking. Hold 23 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:**
+3 -3
View File
@@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application:
If the build fails, check:
1. **JDK Version**: Requires JDK 17+
1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`)
```bash
java -version
```
@@ -39,7 +39,7 @@ If the build fails, check:
./gradlew :desktopApp:packageMsi
# Linux
./gradlew :desktopApp:packageDeb
./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm
```
Outputs will be in `desktopApp/build/compose/binaries/`
Outputs will be in `desktopApp/build/compose/binaries/main/`
+2 -2
View File
@@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
1. **Locate the component** in the amethyst module:
```bash
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
find amethyst/src -name "*$ARGUMENTS*"
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
```
@@ -18,7 +18,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
- Android Compose specifics vs standard Compose
3. **Identify what can be shared**:
- Pure Composable functions → `shared-ui/commonMain/`
- Pure Composable functions → `commons/commonMain/`
- Business logic → `quartz/commonMain/`
- Platform-specific → create expect/actual
+64 -355
View File
@@ -1,339 +1,37 @@
# AmethystMultiplatform Skills Creation Plan
## Overview
Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns.
**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase.
## Skills to Implement
### 1. kotlin-multiplatform ✅ COMPLETED
**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual
**SKILL.md sections:**
- Mental model: KMP hierarchy as dependency graph
- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain}
- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149)
- expect/actual mechanics with 24+ examples from codebase
- iOS framework setup for Quartz distribution
**Bundled resources:**
- `references/source-set-hierarchy.md` - Visual diagram + examples
- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns
- `scripts/validate-kmp-structure.sh` - Verify source set dependencies
- `assets/kmp-hierarchy-diagram.png` - Visual graph
**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples.
**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/`
---
### 2. gradle-expert ✅ COMPLETED
**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting
**SKILL.md sections:**
- Build architecture: 4 modules, dependency flow
- Version catalog mastery (libs.versions.toml)
- Module dependency patterns (api vs implementation)
- Android-specific: compileSdk, proguard
- Desktop packaging: TargetFormat, distributions
- Build performance: daemon, parallel, caching
- Common errors: compose version conflicts, secp256k1 JNI variants
**Bundled resources:**
- `references/build-commands.md` - Common gradle tasks
- `references/dependency-graph.md` - Module visualization
- `references/version-catalog-guide.md` - Version catalog patterns
- `references/common-errors.md` - Troubleshooting guide
- `scripts/analyze-build-time.sh` - Performance report
- `scripts/fix-dependency-conflicts.sh` - Conflict patterns
**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts).
**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/`
---
### 3. kotlin-expert ✅ DRAFT COMPLETE
**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified
**SKILL.md sections:**
- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager)
- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult)
- Immutability: @Immutable for Compose performance (173+ event classes)
- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder)
- Inline functions: reified generics, performance optimization (OptimizedJsonMapper)
- Value classes: Zero-cost wrappers (optimization opportunity)
**Bundled resources:**
- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns
- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns
- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns
- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set
**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders).
**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline)
2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder
3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025
4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf)
5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability)
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real usage scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 4. compose-expert ✅ COMPLETED
**Focus:** Shared composables, state management, animations, Material3
**SKILL.md sections:**
- Shared composables philosophy (100+ already shared in commons/commonMain)
- State management: remember, derivedStateOf, produceState (visual patterns)
- Recomposition optimization: @Stable/@Immutable (visual usage)
- Material3 conventions: theming
- Custom icons: ImageVector builders (robohash pattern)
- Platform differences: Desktop vs Android UI
- Performance: lazy lists, image loading
- Decision framework: share by default in commonMain
**Bundled resources:**
- `references/shared-composables-catalog.md` - Complete catalog with patterns
- `references/state-patterns.md` - State hoisting, derivedStateOf examples
- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL
- `scripts/find-composables.sh` - Grep @Composable utility
**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert.
**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/`
---
### 5. ios-expert
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
**SKILL.md sections:**
- iOS source sets: iosMain, iosArm64Main
- Swift interop: type mapping, nullability
- expect/actual iOS: 10+ examples from quartz/iosMain
- XCFramework setup: baseName = "quartz-kmpKit"
- Platform APIs: platform.posix, CFNetwork, Security
- CocoaPods integration
- XCode project setup
**Bundled resources:**
- `references/ios-actual-implementations.md` - 10 iosMain actuals
- `references/swift-interop-guide.md` - Type mapping
- `references/xcode-integration.md` - XCode setup
- `scripts/generate-xcframework.sh` - Build all iOS targets
**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup.
---
### 6. desktop-expert ✅ DRAFT COMPLETE
**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions
**SKILL.md sections:**
- Desktop entry point: application {} DSL
- Window management: WindowState, positioning, multi-window
- Menu system: MenuBar, keyboard shortcuts (OS-aware)
- System tray: minimize to tray
- Desktop navigation: NavigationRail pattern (vs Android bottom nav)
- File system: Desktop.getDesktop(), file pickers, drag-drop
- Desktop UX principles: keyboard-first, native feel, tooltips
- OS-specific behavior: macOS vs Windows vs Linux
- Platform detection: PlatformDetector utility
- Packaging: DMG, MSI, DEB distribution
**Bundled resources:**
- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.)
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper
- `references/os-detection.md` - Platform detection, file paths, system integration
**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert.
**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined desktop usage scenarios
2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt)
3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG)
4. ✅ SYNTHESIZE - Extracted desktop principles from codebase
5. ✅ DRAFT - Created SKILL.md + 4 reference files
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real desktop scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 7. android-expert ✅ DRAFT COMPLETE
**Focus:** Android platform APIs, navigation, permissions, Material Design
**SKILL.md sections:**
- Android module structure: amethyst/ layout
- Navigation: Navigation Compose, bottom nav
- Permissions: runtime (camera, biometric)
- Platform APIs: Intent, Context, ContentResolver
- Lifecycle: Lifecycle-aware, ViewModel
- Material Design: Android Material 3
- Build config: Proguard, R8
- Android UX: mobile-first patterns
**Bundled resources:**
- `references/android-navigation.md` - Navigation Compose
- `references/android-permissions.md` - Permission handling
- `references/proguard-rules.md` - Proguard explanation
- `scripts/analyze-apk-size.sh` - APK optimization
**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions.
**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined Android usage scenarios
2. ✅ EXPLORE - Analyzed amethyst/ module patterns
3. ✅ RESEARCH - Android best practices + KMP Android patterns
4. ✅ SYNTHESIZE - Extracted Android principles from codebase
5. ✅ DRAFT - Initialized skill, created resources
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 8. nostr-expert ✅ COMPLETED
**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns
**SKILL.md sections:**
- Quartz architecture: package structure by NIP (57 NIPs implemented)
- Event anatomy: IEvent, Event, kinds, tags
- EventTemplate & TagArrayBuilder DSL patterns
- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events
- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders
- Threading (NIP-10): reply/root markers
- Cryptography: secp256k1 signing, NIP-44 encryption
- Bech32 encoding: npub, nsec, note, nevent
- Event validation & verification
- Common workflows: publishing, querying, zaps, gift-wrapped DMs
**Bundled resources:**
- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines)
- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines)
- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines)
- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term
**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase.
**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/`
---
## Implementation Workflow
Using skill-creator 10-step methodology per skill:
**Overall Plan:**
1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained
2. **EXPLORE** ✅ - Codebase analyzed via Explore agent
3. **RESEARCH** ✅ - Domain patterns identified via Plan agent
4. **SYNTHESIZE** ✅ - Skills designed above
**Per-Skill Implementation:**
- kotlin-multiplatform: ✅ COMPLETED
- gradle-expert: ✅ COMPLETED
- kotlin-expert: ✅ COMPLETED
- compose-expert: ✅ COMPLETED
- desktop-expert: ✅ COMPLETED
- android-expert: ✅ COMPLETED
- nostr-expert: ✅ COMPLETED
- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform)
## Critical Files Referenced
**Build patterns:**
- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set
- `/commons/build.gradle.kts` - Shared UI setup
**Code patterns:**
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure
- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual
**Documentation:**
- `/docs/shared-ui-analysis.md` - UI migration strategy
## Output Location
`.claude/skills/<skill-name>/` for each skill
## Next Steps
1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference
2. ✅ Completed kotlin-multiplatform skill
3. ✅ Completed gradle-expert skill
4. ✅ Completed kotlin-expert skill
5. ✅ Completed compose-expert skill
6. ✅ Completed desktop-expert skill
7. ✅ Completed android-expert skill
8. ✅ Completed nostr-expert skill
9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase)
## Current Status: 7/8 Skills Completed
**Completed Skills (Auto-loaded from `.claude/skills/`):**
1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual)
2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting)
3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders)
4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector)
5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs)
6. ✅ android-expert (Android platform APIs, navigation, permissions)
7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags)
**Deferred:**
- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform)
## Skill Loading
**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory.
To manually verify skills are loaded:
```bash
ls -1 .claude/skills/
```
Should show:
- android-expert/
- compose-expert/
- desktop-expert/
- gradle-expert/
- kotlin-expert/
- kotlin-multiplatform/
- nostr-expert/
---
# Amethyst Skill Library — History & Changelog
> Historical record of how the `.claude/skills/` library was built and audited.
> The 8 original skills were created in 2025 using the skill-creator 10-step
> methodology (detailed per-skill progress logs pruned in 2026-06 — see git
> history of this file if you need them). For the current skill list and how
> the two skill layers (codebase-oriented vs technique-oriented) fit together,
> see the Skills section of `.claude/CLAUDE.md`.
## Phase 1 (2025): Core skills created
Eight hybrid domain skills (general expertise + Amethyst-specific patterns),
each with a SKILL.md plus bundled `references/` and `scripts/`:
1. **kotlin-multiplatform** — KMP architecture, the jvmAndroid source-set pattern, expect/actual catalog
2. **gradle-expert** — build system, version catalog, dependency troubleshooting
3. **kotlin-expert** — Flow state, sealed hierarchies, @Immutable, DSL builders
4. **compose-expert** — shared composables, state management, Material3, ImageVector
5. **desktop-expert** — Desktop UX, window management, Compose Desktop APIs
6. **android-expert** — Android navigation, permissions, platform APIs
7. **nostr-expert** — Nostr protocol, Quartz implementation, NIPs, events, tags
8. **ios-expert** — ⏸️ deferred (iOS targets are mature, but no iOS-specific UI work has surfaced in this repo yet)
## Phase 2 (2026-04): Audit & Expansion
After a full audit of the skill library, the following changes were made:
### Stale references fixed
- `CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20.
- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes.
- `desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
- `CLAUDE.md` tech-stack versions replaced with a pointer to `gradle/libs.versions.toml` as the source of truth.
- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp version notes.
- `desktop-expert` Main.kt line references rewritten to match current layout (NavigationRail moved to `ui/deck/SinglePaneLayout.kt`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
### Redundant files removed
- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). `quartz-kmp.md` kept as a small breadcrumb pointer.
- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`).
### New references added to existing skills
- `nostr-expert/references/nip19-bech32.md``Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities.
@@ -346,33 +44,44 @@ After a full audit of the skill library, the following changes were made:
### New skills created
- **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store.
- `references/account-state-flow.md`, `references/local-cache.md`
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`).
- `references/filter-assemblers.md`, `references/preloaders.md`
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders.
- **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`.
- `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md`
- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55).
- `references/nip46-remote-signer.md`, `references/nip55-android-signer.md`
- **`auth-signers/`** — `NostrSigner` abstraction across internal, NIP-46 remote, and NIP-55 external signers.
### Updated skills directory (Phase 2)
```
- android-expert/
- auth-signers/ (new)
- account-state/ (new)
- compose-expert/
- desktop-expert/
- feed-patterns/ (new)
- find-missing-translations/
- find-non-lambda-logs/
- gradle-expert/
- kotlin-coroutines/
- kotlin-expert/
- kotlin-multiplatform/
- nostr-expert/
- quartz-integration/
- relay-client/ (new)
- quartz-kmp.md (breadcrumb pointer)
```
## Phase 3 (2026-06): Fable 5 config review
### Still deferred
- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo.
Instructions written to coach older models were removed now that the model
handles them natively; stale references fixed:
- `CLAUDE.md`: deleted the 5-step skill-approval "Workflow" section
(skills auto-trigger; the approval loop blocked autonomous sessions);
condensed "Verify, Don't Guess" to the repo-specific tooling pointers and
dropped references to `/bugfix` / `/investigate` (never committed to this
repo); replaced the mandated emoji survey matrix with one-line guidance.
- `android-expert` and `desktop-expert` SKILL.md gained YAML frontmatter —
without it they were listed without trigger descriptions and never
auto-invoked.
- `commands/extract.md`: fixed stale `shared-ui/` module name → `commons/`.
- `skills/quartz-kmp.md` breadcrumb deleted (KMP migration long complete;
`quartz-integration` and `nostr-expert` cover its pointers).
- Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified
Kotlin files, so Q&A-only turns no longer pay a Gradle invocation.
Second audit pass (every concrete claim checked against the code; `amy-expert`,
`find-missing-translations`, `find-non-lambda-logs`, and the vendored technique
skills verified clean):
- `auth-signers`: bunker login entry point corrected — `NostrSignerRemote.fromBunkerUri(...)`
+ `connect()`, not the nonexistent `RemoteSignerManager.connect(url)`.
- `nostr-expert`: NIP count 57 → 80+ packages; `Nip44v2.encrypt/decrypt`
static-object snippet replaced with the real `Nip44` facade
(returns `EncryptedInfo`, `encodePayload()` for event content); invented
`Nip19.npubEncode`/`Nip19Result` API replaced with the real `ByteArray`
extensions (`toNpub()`, …), entity `create()` helpers, and
`Nip19Parser.uriToRoute()?.entity`.
- `nostr-expert/references/nip-catalog.md`: heading count (60+8) replaced with
actual package counts (87 + 23 experimental) and a ground-truth pointer.
- `quartz-integration`: NIP-19 decode example rewritten for
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
Event Store section corrected from "Android only" to commonMain/all platforms
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.
+1 -1
View File
@@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
# Create local.properties if missing
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")"
LOCAL_PROPS="$REPO_ROOT/local.properties"
if [ ! -f "$LOCAL_PROPS" ]; then
echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Stop hook: format Kotlin sources, but only when the working tree actually
# has modified Kotlin files — skips the Gradle invocation on Q&A-only turns.
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
if git status --porcelain 2>/dev/null | grep -qE '[.]kts?$'; then
./gradlew spotlessApply 2>/dev/null
fi
exit 0
+1 -1
View File
@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply 2>/dev/null",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh",
"timeout": 120
}
]
+23 -25
View File
@@ -1,6 +1,6 @@
---
name: account-state
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
---
# Account & Local Cache State
@@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus
Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change
Account observes relevant kinds (3, 10002, 10000, …)
Account state objects pin the relevant addressable notes
Account StateFlow updates (followList, relays, mutes, …)
State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …)
ViewModels collect
@@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
Composables render
```
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering.
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering.
## Key Files
### `Account.kt` (singleton-per-session)
- `class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind:
- `followListFlow` ← NIP-02 ContactList (kind 3)
- `relayListFlow` ← NIP-65 RelayList (kind 10002)
- `muteListFlow` ← NIP-51 Lists (kind 10000)
- `bookmarkListFlow` ← NIP-51 Lists (kind 10003)
- `topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc.
- Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, …
- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event.
- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
- `class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind:
- `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3)
- `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, …
- `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000)
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
- Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders.
- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`.
- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event.
- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
### `LocalCache.kt`
@@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
Typical recipe:
1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list).
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed.
3. In `Account.kt`:
- Add a private `MutableStateFlow<T>`.
- Expose a `StateFlow<T>` read view.
- Subscribe to the relay (via the relayClient subscription pattern see `relay-client` skill).
- On event arrival, parse with the quartz event class and update the flow.
- Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes.
4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one):
- Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`.
- Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`.
- Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed.
3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill).
4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
## `LocalCache` vs `Account` Flow — Which to Read?
- **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`.
- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.<featureFlow>`.
- **Are you rendering "my follows", "my mutes", "my relays"?** → `account.<feature>.flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`).
- **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable.
## Gotchas
- **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache.
- **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object.
- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it.
- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it.
- **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state.
- **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident.
## References
- `references/account-state-flow.md` — catalog of major `Account` StateFlow properties and their source kinds.
- `references/account-state-flow.md` — catalog of major `Account` state objects and their source kinds.
- `references/local-cache.md``LocalCache` internals, insertion path, indexes.
- Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events).
@@ -1,93 +1,94 @@
# Account StateFlow Catalog
# Account State-Object Catalog
`Account.kt` exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package.
`Account.kt` composes ~50 **feature state objects** (not raw StateFlow
properties). Each object pins its backing addressable note in `LocalCache`,
exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and
offers suspend mutation helpers that return signed events. Consumers read
`account.<property>.flow`.
(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.)
(Property and class names are exact as of the current `Account.kt`; if one has
been renamed, grep `Account.kt` for the class name.)
## Identity & Contacts
| Flow | Kind(s) | Source | Model package |
|------|---------|--------|---------------|
| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` |
| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` |
| `followersFlow` | derived | LocalCache scan | — |
| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` |
| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` |
| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` |
| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` |
| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` |
| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` |
| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` |
| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` |
## Relays & Connectivity
## Relay Lists
| Flow | Kind | Package |
|------|------|---------|
| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` |
| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` |
| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` |
| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` |
| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` |
| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` |
| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` |
| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` |
| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` |
| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` |
| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` |
Derived relay views (merge several of the above): `homeRelays`
(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`,
`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`,
`defaultGlobalRelays`.
## Content Lists
| Flow | Kind | Package |
|------|------|---------|
| `bookmarkListFlow` | 10003 | `model/nip51Lists/` |
| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` |
| `topNavFeedsFlow` | custom | `model/topNavFeeds/` |
| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` |
| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` |
| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` |
| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` |
| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` |
| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` |
| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` |
| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` |
| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` |
| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` |
| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` |
| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` |
| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` |
| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` |
## Messaging
## Other Feature State
| Flow | Kind | Package |
|------|------|---------|
| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` |
| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` |
| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` |
| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` |
| Account property | State class | Purpose | Package |
|------------------|-------------|---------|---------|
| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` |
| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` |
| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` |
| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` |
| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — |
## Settings & UI
| Flow | Source | Package |
|------|--------|---------|
| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` |
| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` |
| `privacyOptionsFlow` | local | `model/privacyOptions/` |
| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` |
| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` |
## Advanced / Derived
| Flow | Purpose | Package |
|------|---------|---------|
| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` |
| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` |
| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` |
| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` |
| `serverListFlow` | media/upload servers | `model/serverList/` |
Note the migration direction: newer/extracted state classes live in
`commons/src/commonMain/.../commons/model/`, the rest still in
`amethyst/src/main/java/.../model/`. Check both when looking for one.
## Publishing Mutations
Every flow has a corresponding mutation method on `Account` that:
State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`,
`BookmarkListState` add/remove) **build and sign** the updated replaceable
event via the quartz event class (`XEvent.add / remove / create`) and return
it. The caller (usually a method on `Account`) is responsible for sending it
through the client. Decryption results are cached in the paired
`…DecryptionCache` so re-renders don't re-decrypt.
1. Constructs the updated event using a `TagArrayBuilder`.
2. Signs through the injected `NostrSigner` (see `auth-signers` skill).
3. Publishes to the appropriate relay set.
4. Updates the local StateFlow *before* relay round-trip (optimistic).
5. Rolls back / reconciles on failure.
## When a State Object Doesn't Exist Yet
Examples of mutation methods (names may vary slightly in current code):
- `follow(pubKey)` / `unfollow(pubKey)`
- `addBookmark(noteId)` / `removeBookmark(noteId)`
- `mute(pubKey)` / `unmute(pubKey)`
- `updateRelayList(...)`, `updateDmRelayList(...)`
- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)`
If you're adding a new NIP that's user-scoped, follow the pattern (full recipe
in `SKILL.md`):
## When a Flow Doesn't Exist Yet
If you're adding a new NIP that's user-scoped, follow the pattern:
1. Create `model/nipXX…/` with an optional `ExtState`/builder class.
2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow<T> = _xFlow.asStateFlow()` to `Account`.
3. Wire the relay subscription (see `relay-client` skill).
4. Add the mutation method that builds, signs, and publishes.
5. Update persistence if the setting is local-only (`AccountSettings.kt`).
1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or
`BookmarkListState` (plain).
2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose
`val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`.
3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if
private), and wire the relay subscription (see `relay-client` skill).
4. Add mutation helpers that build, sign, and return the event; publish from
the calling site.
5. Use `AccountSettings` for the local backup copy if the list must survive
relay loss.
+8 -3
View File
@@ -1,3 +1,8 @@
---
name: android-expert
description: Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform.
---
# android-expert
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
@@ -739,14 +744,14 @@ fun SignerIntegration(accountViewModel: AccountViewModel) {
```gradle
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = 36
compileSdk = 37 // from libs.versions.toml android-compileSdk — check there, it drifts
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
targetSdk = 37 // android-targetSdk in libs.versions.toml
versionCode = 447
versionName = "1.11.0"
versionName = generateVersionName(libs.versions.app.get(), rootDir)
vectorDrawables {
useSupportLibrary = true
+1 -1
View File
@@ -75,7 +75,7 @@ Most feature code should go through `Account`'s mutation methods (`account.sendR
Entry points:
- **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`.
- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`.
- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake.
- **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`.
The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen.
+9 -4
View File
@@ -1,3 +1,8 @@
---
name: desktop-expert
description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform.
---
# Desktop Expert
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
@@ -69,7 +74,7 @@ fun main() = application {
- `rememberWindowState()` manages size/position
- `onCloseRequest` handles window close
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt``fun main()` at L172, `application {` at L186, top-level `Window` at L229, `MenuBar` at L234.
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol).
---
@@ -275,9 +280,9 @@ Row(Modifier.fillMaxSize()) {
}
```
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces.
**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases.
**Why NavigationRail?**
**Why a left sidebar?**
- Desktop has horizontal space (1200+ dp width)
- Vertical sidebar is standard desktop pattern
- Always visible (no tabs hidden)
@@ -285,7 +290,7 @@ Row(Modifier.fillMaxSize()) {
**Android comparison:**
- Android: `BottomNavigationBar` (horizontal, bottom)
- Desktop: `NavigationRail` (vertical, left)
- Desktop: left vertical sidebar (`MainSidebar`)
### Multi-Pane Layouts
@@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
---
## Desktop: NavigationRail
## Desktop: Left Sidebar
### Current Implementation
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+).
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces.
```kotlin
@Composable
+36 -22
View File
@@ -1,6 +1,6 @@
---
name: feed-patterns
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
---
# Feed Patterns
@@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
│ ◄── ChatroomFeedViewModel │
│ ◄── MarmotGroupFeedViewModel │
│ │
FeedContentState — the flow the UI collects
│ commons/.../ui/feeds/ (shared, KMP) │
│ IFeedFilter / FeedFilter<T> (abstract base) │
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
│ ChangesFlowFilter │
│ FeedContentState, FeedState — the flow the UI collects │
└─────────────────────────────────────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────────┐
│ amethyst/.../ui/dal/ (Android; feeds defined per screen)
│ FeedFilter<T> (abstract) │
│ amethyst/.../ui/dal/ (Android-only additions)
│ AdditiveComplexFeedFilter<T, U> │
│ ChangesFlowFilter │
│ FilterByListParams │
│ DefaultFeedOrder
│ DefaultFeedOrder (Note/Event/Card comparators)
│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │
│ back-compat typealiases re-exporting commons) │
│ │
Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter,
BookmarkListFeedFilter, NotificationFeedFilter, …
Concrete feeds: HomeNewThreadFeedFilter,
HashtagFeedFilter, NotificationFeedFilter, … live in
│ feature folders under ui/screen/loggedIn/*/dal/ │
└─────────────────────────────────────────────────────────────┘
│ reads
┌─────────────────────────────────────────────────────────────┐
│ model/LocalCache.kt + Account.<featureFlow>
│ model/LocalCache.kt + account.<feature>.flow │
└─────────────────────────────────────────────────────────────┘
```
@@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
- **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed.
- **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing.
### Android DAL (the filters)
### Shared filter bases (commons)
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query.
- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects.
### Android DAL (additions on top)
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`:
- **`FeedFilters.kt`** — `abstract class FeedFilter<T>`. Has `feed(): List<T>` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache).
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything.
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query.
- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters.
- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging).
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`: like `AdditiveFeedFilter` but the incoming items (`Set<U>`) are a different type than the list rows (`T`).
- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters.
- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`.
- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here.
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`.
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature `dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`.
## Adding a New Feed
1. **Define the filter.** Extend `AdditiveComplexFeedFilter<Note, Set<HexKey>>` (or plain `FeedFilter<Note>` if additivity doesn't matter). Implement:
1. **Define the filter.** Extend `AdditiveFeedFilter<Note>` (or plain `FeedFilter<Note>` if additivity doesn't matter; `AdditiveComplexFeedFilter<T, U>` if incoming items differ in type from list rows). Implement:
- `feedKey()` — stable identity (e.g. hashtag name, account pubkey).
- `feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list.
- `limit()` — pagination hint.
- If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
- If additive: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
2. **Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`.
3. **Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes.
4. **Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`.
@@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
## Filter Sharing (Android vs Desktop)
- `FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/`**Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`.
- ViewModels are in `commons/commonMain/`**shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared.
- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it.
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/`**shared**. ViewModels are in `commons/.../viewmodels/`**shared**.
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
## Gotchas
@@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
- **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates.
- **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts.
- **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan.
- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc.
- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams` exclusion paths rather than filtering post-hoc.
## References
@@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt
| If… | Use |
|-----|-----|
| Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter<Note>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter<Note, Set<Note>>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter<Note>` |
| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter<T, U>` (Android-only) |
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `ListChangeFeedViewModel` |
| The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) |
All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`.
## 2. Write the Filter
@@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
class HashtagFeedFilter(
private val accountViewModel: AccountViewModel,
private val hashtag: String,
) : AdditiveComplexFeedFilter<Note, Set<Note>>() {
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "Hashtag-$hashtag"
@@ -28,7 +29,7 @@ class HashtagFeedFilter(
override fun feed(): List<Note> {
val params = FilterByListParams.create(
excludeMuted = true,
hiddenUsers = accountViewModel.hiddenUsersFlow.value,
hiddenUsers = account.hiddenUsers.flow.value,
)
return LocalCache.hashtagIndex[hashtag]
.orEmpty()
@@ -69,7 +70,7 @@ class HashtagFeedViewModel(
)
```
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`.
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`.
## 4. Wire Invalidation
@@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan
```kotlin
init {
viewModelScope.launch {
accountViewModel.muteListFlow.collect { invalidateAll() }
account.muteList.flow.collect { invalidateAll() }
}
}
```
+6 -6
View File
@@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro
# Gradle Expert
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
## Build Architecture Mental Model
Think of this project as **4 layers**:
The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses):
```
┌─────────────┬─────────────┐
@@ -165,11 +165,11 @@ implementation(libs.jna)
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
**Current project config:**
**Current project config** (always re-check `gradle/libs.versions.toml` — these drift):
```toml
composeMultiplatform = "1.9.3" # Plugin + runtime
composeBom = "2025.12.01" # AndroidX Compose BOM
kotlin = "2.3.0"
composeMultiplatform = "1.11.0" # Plugin + runtime
composeBom = "2026.05.01" # AndroidX Compose BOM
kotlin = "2.3.21"
```
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
@@ -3,46 +3,50 @@
## Visual Hierarchy
```
┌─────────────────────────────────────────────────────────┐
│ Root Project │
(Amethyst)
└─────────────────────────────────────────────────────────┘
┌────────────────┼────────────────┐
│ │
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ :amethyst :desktopApp │ │ :benchmark
(Android) │ (JVM) │ │ (Android)
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ │
────────────────┼────────────────┘
─────────────┐
:commons
(KMP UI)
│ │
jvmAndroid
/ \
│ jvm android
└─────────────┘
┌─────────────┐
│ :quartz
│(KMP Library)│
│ │
│ commonMain │
│ │ │
│ jvmAndroid │
│ / | \ │
│jvm and ios │
└─────────────┘
Apps / harnesses Libraries
┌─────────────┐ ┌─────────────┐ ┌────────────┐
:amethyst │ │ :desktopApp │ │ :benchmark
│ (Android) │ │ (JVM) │ │ (Android) │
└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘
───────┼────┐ │ │ │
│ │
│ ┌────────────────┐ │ │ (androidTest │
│ │ :commons ◄┼──┼──only)
│ │ (KMP UI) │
└───────┬────────┘ │ │ │
──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │
│ :nestsClient │ │ :cli │ │:geode │
│ (KMP, MoQ) │ │ │(JVM) │ │(JVM │ │
────────────┘ │ └──┬───┘ │relay) │ │
│ │ └───┬───┘
│ │ │
┌────────┐
│ :quic
│ (KMP)
└───┬────┘ │ │ │ │
│ ▲ │ │ │ │ │
│ └── :quic-interop │
▼ ▼ ▼ ▼ ▼ ▼
┌──────────────────────────────┐
│ :quartz │
(KMP Library)
└──────────────────────────────┘
```
Verified edges (from each module's `build.gradle.kts`):
- `:amethyst``:quartz`, `:commons`, `:nestsClient`
- `:desktopApp``:quartz`, `:commons`
- `:benchmark``:quartz`, `:commons` (androidTest only)
- `:cli``:quartz`, `:commons`
- `:geode``:quartz` (api + testFixtures)
- `:nestsClient``:quartz` (api), `:quic`
- `:quic``:quartz` (api)
- `:quic-interop``:quic` (project dir: `quic/interop`)
## Module Details
### :quartz (KMP Nostr Library)
@@ -86,11 +90,41 @@
**Type:** Android Library
**Targets:** Android
**Dependencies:**
- Modules: `:commons`, `:quartz`
- Modules: `:commons`, `:quartz` (androidTest only)
- External: AndroidX Benchmark
**Role:** Performance benchmarking for Android builds
### :cli (Amy CLI)
**Type:** JVM Application (no Compose)
**Dependencies:** `:quartz`, `:commons`
**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill)
### :geode (Relay Server)
**Type:** JVM Application (Ktor)
**Dependencies:** `:quartz` (api + testFixtures)
**Role:** Standalone Nostr relay built on quartz's relay-server code
### :quic (QUIC Transport)
**Type:** Kotlin Multiplatform Library
**Dependencies:** `:quartz` (api)
**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ
### :nestsClient (Audio Rooms)
**Type:** Kotlin Multiplatform Library
**Dependencies:** `:quartz` (api), `:quic`
**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature
### :quic-interop (Interop Harness)
**Type:** JVM Application (project dir `quic/interop`)
**Dependencies:** `:quic`
**Role:** QUIC interop-runner test client
## Dependency Flow Patterns
### Desktop Build Chain
@@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant
implementation(compose.ui) // Compose Multiplatform BOM
implementation(compose.material3)
// Version catalog alignment
composeMultiplatform = "1.9.3"
composeBom = "2025.12.01" // AndroidX Compose
// Version catalog alignment (re-check libs.versions.toml — these drift)
composeMultiplatform = "1.11.0"
composeBom = "2026.05.01" // AndroidX Compose
```
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
+2 -3
View File
@@ -807,6 +807,5 @@ Passing lambda to function?
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
**Version:** 1.0.1
**Last Updated:** 2026-06-10
+32 -44
View File
@@ -1,6 +1,6 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
@@ -313,26 +313,24 @@ class LocalSigner(private val privateKey: ByteArray) : ISigner {
### Encryption (NIP-44)
```kotlin
// Modern encryption (ChaCha20-Poly1305)
object Nip44v2 {
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade
// (nip44Encryption/Nip44.kt — picks the current version, decrypts any)
object Nip44 {
fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo
fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String
}
// Usage
val encrypted = Nip44v2.encrypt(
plaintext = "Secret message",
privateKey = myPrivateKey,
pubKey = recipientPubKey
)
val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey)
val payload = encrypted.encodePayload() // base64 string for event content
val decrypted = Nip44v2.decrypt(
ciphertext = encrypted,
privateKey = myPrivateKey,
pubKey = senderPubKey
)
val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey)
```
Most code should not call `Nip44` directly — go through
`signer.nip44Encrypt(plaintext, toPublicKey)` / `signer.nip44Decrypt(ciphertext, fromPublicKey)`
so remote/external signers keep working.
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
### NIP-04 (Deprecated)
@@ -345,44 +343,34 @@ object Nip04 {
}
```
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
## Bech32 Encoding (NIP-19)
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
TLV entities carry relay hints via `create()` helpers on the entity classes in
`nip19Bech32/entities/`. Decoding goes through `Nip19Parser`, whose
`uriToRoute()` returns a `ParseReturn?` wrapping the parsed `Entity`.
```kotlin
object Nip19 {
// Encode
fun npubEncode(pubkey: HexKey): String // npub1...
fun nsecEncode(privateKey: ByteArray): String // nsec1...
fun noteEncode(eventId: HexKey): String // note1...
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
// Encode simple entities: ByteArray extensions
val npub = pubkeyBytes.toNpub() // "npub1..."
val nsec = privKeyBytes.toNsec() // "nsec1..."
val note = eventIdBytes.toNote() // "note1..."
// Decode
fun decode(bech32: String): Nip19Result
}
sealed class Nip19Result {
data class NPub(val hex: HexKey) : Nip19Result()
data class NSec(val hex: HexKey) : Nip19Result()
data class Note(val hex: HexKey) : Nip19Result()
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
}
// Encode TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
val nprofile = NProfile.create(pubkeyHex, relays)
```
**Usage**:
```kotlin
// Encode
val npub = Nip19.npubEncode(pubkeyHex)
// Output: "npub1..."
// Decode
when (val result = Nip19.decode(npub)) {
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
// Decode (also accepts nostr: URIs); entity types live in nip19Bech32.entities
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
is NPub -> println("Pubkey: ${entity.hex}")
is NEvent -> println("Event: ${entity.hex}, relays: ${entity.relay}")
is NAddress -> println("Address: ${entity.aTag()}")
null -> println("not a valid bech32 entity")
else -> println("Other type")
}
```
@@ -1,4 +1,8 @@
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
# NIP Catalog: Quartz NIP Packages
As of 2026-06 Quartz has **87 standard `nip*` packages** plus **23 packages
under `experimental/`**. The categorized list below may lag behind —
`ls quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/` is ground truth.
## Standard NIPs by Category
+27 -21
View File
@@ -447,23 +447,28 @@ val textNote = Event.fromJson(json) as? TextNoteEvent
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
// Decode any bech32 entity
val result = Nip19Parser.uriToRoute("npub1abc...")
// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null
when (val r = Nip19Parser.uriToRoute(input)) {
is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}")
is Nip19Parser.Return.Note -> println("event id: ${r.hex}")
is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}")
is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}")
is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}")
null -> println("not a valid bech32 entity")
else -> {}
// Decode any bech32 entity (plain or nostr:-prefixed).
// uriToRoute() returns Nip19Parser.ParseReturn? — the parsed Entity is in .entity
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
is NPub -> println("pubkey: ${entity.hex}")
is NNote -> println("event id: ${entity.hex}")
is NEvent -> println("event: ${entity.hex}, relays: ${entity.relay}")
is NProfile -> println("profile: ${entity.hex}")
is NAddress -> println("address: ${entity.aTag()}")
null -> println("not a valid bech32 entity")
else -> {}
}
// The parser also handles nostr: URI scheme
val result = Nip19Parser.uriToRoute("nostr:npub1abc...")
// Encode: ByteArray extensions from nip19Bech32/ByteArrayExt.kt
val npub = pubkeyBytes.toNpub() // also toNsec(), toNote(), ...
// TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
```
---
@@ -601,21 +606,22 @@ In Xcode: drag & drop the `.xcframework` into your project, then use from Swift
---
## 14. Event Store (Android only)
## 14. Event Store (SQLite, all platforms)
SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62):
SQLite-backed storage in `commonMain` (JVM, Android, iOS — uses the bundled
androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50,
NIP-62). All operations are `suspend`:
```kotlin
import com.vitorpamplona.quartz.nip01Core.store.EventStore
import android.content.Context
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
val store = EventStore()
val store = EventStore() // default DB file "events.db"
// Insert
store.insert(event)
// Query
val events = store.query(
val events = store.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
)
@@ -623,7 +629,7 @@ val events = store.query(
val count = store.count(Filter(kinds = listOf(1)))
// Full-text search (NIP-50)
val results = store.query(Filter(search = "bitcoin"))
val results = store.query<Event>(Filter(search = "bitcoin"))
```
---
-23
View File
@@ -1,23 +0,0 @@
# Quartz KMP (Legacy Skill — Migration Complete)
> The KMP migration of Quartz is **complete**. This file is kept for historical reference.
>
> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead.
> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill.
## What was migrated
The Quartz library was successfully converted from Android-only to full KMP supporting:
- **commonMain** — All Nostr protocol logic, events, filters, tags
- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers
- **androidMain** — SQLite event store, NIP-55 Android signer
- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm)
- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`)
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.11.0
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+8 -2
View File
@@ -24,17 +24,23 @@ relayClient/
├── assemblers/ # "Given these inputs, build this relay Filter"
│ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys
│ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids
── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt
├── composeSubscriptionManagers/
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
│ ├── MutableComposeSubscriptionManager.kt # reference impl
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
├── eoseManagers/ # EOSE tracking per subscription
│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager
├── nip17Dm/ # gift-wrap DM plumbing
│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt
├── preload/
│ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting
│ └── MetadataRateLimiter.kt # token-bucket-ish limiter
└── subscriptions/
── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
├── LifecycleAwareKeyDataSourceSubscription.kt
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt
```
## Core Concept: `Subscribable<T>`
-76
View File
@@ -79,82 +79,6 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Cache vlc-setup plugin downloads (VLC + UPX archives) keyed on the
# versions pinned in desktopApp/build.gradle.kts. Each OS gets its own
# cache namespace because the plugin downloads platform-specific archives.
# On a hit the vlcDownload / upxDownload tasks are up-to-date and we
# never touch get.videolan.org; on a miss (version bump or new runner)
# we fall back to fetching, which is what the in-build retry budget
# exists for.
- name: Cache vlc-setup downloads
uses: actions/cache@v5
with:
path: ~/.gradle/vlcSetup
key: vlcsetup-${{ runner.os }}-${{ hashFiles('desktopApp/build.gradle.kts') }}
restore-keys: |
vlcsetup-${{ runner.os }}-
# Pre-fetch VLC + UPX archives into ~/.gradle/vlcSetup before invoking
# Gradle. The vlc-setup plugin (ir.mahozad.vlc-setup 0.1.0) writes its
# downloads to ${gradleUserHomeDir}/vlcSetup/ and sets overwrite(false),
# so an existing file there makes vlcDownload / upxDownload up-to-date
# and Gradle never opens a socket to videolan.org.
#
# Why curl instead of relying on de.undercouch.gradle.tasks.download:
# curl --retry-all-errors with a long --retry-max-time tolerates a
# sustained get.videolan.org outage far better than the plugin's inner
# retry budget (retries(4) + 5min readTimeout in build.gradle.kts),
# which has been hitting SocketTimeoutException on Windows runners.
#
# Cache hit: the file is already on disk, fetch() short-circuits, this
# step takes <1s. Cache miss: curl downloads with aggressive retries,
# populating the cache for the next run.
#
# Versions are pinned to match desktopApp/build.gradle.kts (vlcVersion
# = 3.0.20) and the vlc-setup extension default (upxVersion = 4.2.4).
# NOTE: vlcVersion lags behind upstream VLC because the Linux plugins on
# Maven Central (ir.mahozad:vlc-plugins-linux) are only published for
# 3.0.20 / 3.0.20-2. Bump only after the Maven artifact is republished.
# macOS does not download UPX — UPX cannot compress .dylib files.
- name: Pre-fetch VLC + UPX archives
env:
VLC_VERSION: "3.0.20"
UPX_VERSION: "4.2.4"
run: |
set -euo pipefail
DEST="$HOME/.gradle/vlcSetup"
mkdir -p "$DEST"
fetch() {
local url="$1" out="$2"
if [[ -s "$out" ]]; then
echo "cached: $out"
return 0
fi
echo "fetching: $url"
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
--retry-max-time 900 --connect-timeout 30 \
-o "$out.part" "$url"
mv "$out.part" "$out"
}
case "${{ runner.os }}" in
Windows)
fetch "https://get.videolan.org/vlc/${VLC_VERSION}/win64/vlc-${VLC_VERSION}-win64.zip" \
"$DEST/vlc-${VLC_VERSION}.zip"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" \
"$DEST/upx-${UPX_VERSION}.zip"
;;
Linux)
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
"$DEST/vlc-${VLC_VERSION}.jar"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
"$DEST/upx-${UPX_VERSION}.tar.xz"
;;
macOS)
fetch "https://get.videolan.org/vlc/${VLC_VERSION}/macosx/vlc-${VLC_VERSION}-universal.dmg" \
"$DEST/vlc-${VLC_VERSION}.dmg"
;;
esac
# Compose UI smoke test (DesktopLaunchSmokeTest) uses Skiko which needs
# a display server on Linux. xvfb provides a virtual framebuffer.
- name: Install xvfb (Linux)
+3 -3
View File
@@ -23,9 +23,9 @@ env:
# Single source of truth in scripts/asset-name.sh.
# appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below.
# We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so RPATH
# mismatch) and the UPX-compressed VLC plugins. appimagetool only embeds the
# AppDir as-is, which is what we actually want.
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
# embeds the AppDir as-is, which is what we actually want.
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
-30
View File
@@ -68,36 +68,6 @@ jobs:
with:
cache-read-only: true
- name: Cache vlc-setup downloads
uses: actions/cache@v5
with:
path: ~/.gradle/vlcSetup
key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }}
restore-keys: |
vlcsetup-Linux-
- name: Pre-fetch VLC + UPX archives
env:
VLC_VERSION: "3.0.20"
UPX_VERSION: "4.2.4"
run: |
set -euo pipefail
DEST="$HOME/.gradle/vlcSetup"
mkdir -p "$DEST"
fetch() {
local url="$1" out="$2"
if [[ -s "$out" ]]; then echo "cached: $out"; return 0; fi
echo "fetching: $url"
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
--retry-max-time 900 --connect-timeout 30 \
-o "$out.part" "$url"
mv "$out.part" "$out"
}
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
"$DEST/vlc-${VLC_VERSION}.jar"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
"$DEST/upx-${UPX_VERSION}.tar.xz"
- name: Install xvfb + packaging deps
run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot
+14 -4
View File
@@ -161,10 +161,20 @@ TASKS.md
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
# Per-OS appResources slots — historically the ir.mahozad.vlc-setup plugin
# populated these with VLC binaries (no longer used; superseded by
# kdroidFilter ComposeMediaPlayer). We still ignore the directory contents
# by default to keep stale workspaces from accidentally bundling old VLC
# trees into local packages, but explicitly track the ffmpeg/README.md
# drop-in slot for the LGPL FFmpeg binaries used by VideoThumbnailCache.
desktopApp/src/jvmMain/appResources/linux/*
desktopApp/src/jvmMain/appResources/macos/*
desktopApp/src/jvmMain/appResources/windows/*
!desktopApp/src/jvmMain/appResources/linux/ffmpeg/
!desktopApp/src/jvmMain/appResources/macos/ffmpeg/
!desktopApp/src/jvmMain/appResources/windows/ffmpeg/
desktopApp/src/jvmMain/appResources/*/ffmpeg/*
!desktopApp/src/jvmMain/appResources/*/ffmpeg/README.md
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
@@ -428,7 +428,7 @@ class AppModules(
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torEvaluatorFlow.flow.value.useTor(url)
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
okHttpClientForRelays.getHttpClient(useTor)
}
@@ -25,6 +25,7 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -123,7 +124,9 @@ private object PrefKeys {
const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration
const val NWC_WALLETS = "nwcWallets"
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId"
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
const val LATEST_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList"
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
@@ -401,9 +404,19 @@ object LocalPreferences {
} else {
remove(PrefKeys.NWC_WALLETS)
}
settings.defaultNwcWalletId.value?.let {
putString(PrefKeys.DEFAULT_NWC_WALLET_ID, it)
} ?: remove(PrefKeys.DEFAULT_NWC_WALLET_ID)
val debitEntries = settings.clinkDebitWallets.value.map { it.denormalize() }
if (debitEntries.isNotEmpty()) {
putString(PrefKeys.CLINK_DEBIT_WALLETS, JsonMapper.toJson(debitEntries))
} else {
remove(PrefKeys.CLINK_DEBIT_WALLETS)
}
settings.defaultPaymentSourceId.value?.let {
putString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, it)
} ?: remove(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID)
// Legacy NWC-only default key is superseded by DEFAULT_PAYMENT_SOURCE_ID.
remove(PrefKeys.DEFAULT_NWC_WALLET_ID)
// Remove legacy key after migration
remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
@@ -565,6 +578,8 @@ object LocalPreferences {
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)
val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null)
val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null)
val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null)
val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null)
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
@@ -619,6 +634,10 @@ object LocalPreferences {
}
}
}
val clinkDebitsLoaded =
async {
parseOrNull<List<ClinkDebitWalletEntry>>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList()
}
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(viewedPollResultNoteIdsStr) ?: mapOf() }
@@ -694,7 +713,15 @@ object LocalPreferences {
defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities),
defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks),
nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first),
defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second),
clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()),
// Prefer the new unified default; migrate from the legacy NWC default;
// else fall back to the first configured source (NWC before debits).
defaultPaymentSourceId =
MutableStateFlow(
defaultPaymentSourceIdStr
?: nwcWalletsLoaded.await().second
?: clinkDebitsLoaded.await().firstOrNull()?.id,
),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
@@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -193,7 +196,10 @@ class AccountSettings(
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
// new key, migrated from the legacy NWC-only `defaultNwcWalletId`.
val defaultPaymentSourceId: MutableStateFlow<String?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -335,14 +341,17 @@ class AccountSettings(
return false
}
/** The selected default spend rail across both NWC wallets and CLINK debits. */
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
/**
* The NWC wallet to use for NWC-only flows (balance display, mint top-up). Resolves
* the unified default when it points at an NWC wallet, otherwise falls back to the
* first NWC wallet so those flows keep working even when a debit is the zap default.
*/
fun defaultNwcWallet(): NwcWalletEntryNorm? {
val id = defaultNwcWalletId.value
val wallets = nwcWallets.value
return if (id != null) {
wallets.firstOrNull { it.id == id }
} else {
wallets.firstOrNull()
}
return wallets.firstOrNull { it.id == defaultPaymentSourceId.value } ?: wallets.firstOrNull()
}
fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri
@@ -353,8 +362,10 @@ class AccountSettings(
nwcWallets.tryEmit(nwcWallets.value.toMutableList().apply { set(existing, wallet) })
} else {
nwcWallets.tryEmit(nwcWallets.value + wallet)
if (nwcWallets.value.size == 1) {
defaultNwcWalletId.tryEmit(wallet.id)
// First configured source of any kind becomes the default; adding more never
// silently changes an existing default.
if (defaultPaymentSourceId.value == null) {
defaultPaymentSourceId.tryEmit(wallet.id)
}
}
saveAccountSettings()
@@ -364,16 +375,68 @@ class AccountSettings(
fun removeNwcWallet(walletId: String): Boolean {
val wallets = nwcWallets.value.filter { it.id != walletId }
nwcWallets.tryEmit(wallets)
if (defaultNwcWalletId.value == walletId) {
defaultNwcWalletId.tryEmit(wallets.firstOrNull()?.id)
reassignDefaultIfRemoved(walletId)
saveAccountSettings()
return true
}
fun addClinkDebitWallet(wallet: ClinkDebitWalletEntryNorm): Boolean {
val existing = clinkDebitWallets.value.indexOfFirst { it.id == wallet.id }
if (existing >= 0) {
clinkDebitWallets.tryEmit(clinkDebitWallets.value.toMutableList().apply { set(existing, wallet) })
} else {
clinkDebitWallets.tryEmit(clinkDebitWallets.value + wallet)
if (defaultPaymentSourceId.value == null) {
defaultPaymentSourceId.tryEmit(wallet.id)
}
}
saveAccountSettings()
return true
}
fun setDefaultNwcWallet(walletId: String): Boolean {
if (defaultNwcWalletId.value != walletId && nwcWallets.value.any { it.id == walletId }) {
defaultNwcWalletId.tryEmit(walletId)
fun removeClinkDebitWallet(walletId: String): Boolean {
clinkDebitWallets.tryEmit(clinkDebitWallets.value.filter { it.id != walletId })
reassignDefaultIfRemoved(walletId)
saveAccountSettings()
return true
}
fun renameClinkDebitWallet(
walletId: String,
newName: String,
): Boolean {
val wallets = clinkDebitWallets.value.toMutableList()
val index = wallets.indexOfFirst { it.id == walletId }
if (index >= 0) {
wallets[index] = wallets[index].copy(name = newName)
clinkDebitWallets.tryEmit(wallets)
saveAccountSettings()
return true
}
return false
}
/** When the removed source was the default, fall back to the first remaining source. */
private fun reassignDefaultIfRemoved(walletId: String) {
if (defaultPaymentSourceId.value == walletId) {
defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id)
}
}
/** Resets the default to the first remaining source if it no longer points at anything. */
private fun reassignDefaultIfMissing() {
val id = defaultPaymentSourceId.value ?: return
val exists = nwcWallets.value.any { it.id == id } || clinkDebitWallets.value.any { it.id == id }
if (!exists) {
defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id)
}
}
/** Selects the unified default across both NWC wallets and CLINK debits. */
fun setDefaultPaymentSource(sourceId: String): Boolean {
val exists = nwcWallets.value.any { it.id == sourceId } || clinkDebitWallets.value.any { it.id == sourceId }
if (defaultPaymentSourceId.value != sourceId && exists) {
defaultPaymentSourceId.tryEmit(sourceId)
saveAccountSettings()
return true
}
@@ -399,7 +462,7 @@ class AccountSettings(
if (newServer == null) {
if (nwcWallets.value.isNotEmpty()) {
nwcWallets.tryEmit(emptyList())
defaultNwcWalletId.tryEmit(null)
reassignDefaultIfMissing()
saveAccountSettings()
return true
}
@@ -68,6 +68,7 @@ class UserMetadataState(
nip05: String? = null,
lnAddress: String? = null,
lnURL: String? = null,
clinkOffer: String? = null,
): MetadataEvent {
val latest = getUserMetadataEvent()
@@ -85,6 +86,7 @@ class UserMetadataState(
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
clinkOffer = clinkOffer,
)
} else {
MetadataEvent.createNew(
@@ -98,6 +100,7 @@ class UserMetadataState(
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
clinkOffer = clinkOffer,
)
}
@@ -65,12 +65,10 @@ class NwcSignerState(
* Flow of the default wallet's NWC URI, derived from multi-wallet settings.
*/
val defaultWalletUri: StateFlow<Nip47WalletConnect.Nip47URINorm?> =
combine(settings.nwcWallets, settings.defaultNwcWalletId) { wallets, defaultId ->
if (defaultId != null) {
wallets.firstOrNull { it.id == defaultId }?.uri
} else {
wallets.firstOrNull()?.uri
}
combine(settings.nwcWallets, settings.defaultPaymentSourceId) { wallets, defaultId ->
// Use the NWC wallet the unified default points at; otherwise fall back to the
// first NWC wallet so NWC zap routing is unchanged for NWC-only users.
(wallets.firstOrNull { it.id == defaultId } ?: wallets.firstOrNull())?.uri
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, settings.defaultZapPaymentRequest())
@@ -105,4 +105,42 @@ class AccountsTorStateConnector(
SharingStarted.Eagerly,
emptySet(),
)
// Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK
// Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the
// money-operations Tor preference instead of being classified as generic "new" relays.
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val allMoneyOpRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val perAccountFlows =
snapshot.map { (_, account) ->
combine(
account.settings.nwcWallets,
account.settings.clinkDebitWallets,
) { nwcWallets, clinkDebitWallets ->
val relays = mutableSetOf<NormalizedRelayUrl>()
nwcWallets.forEach { relays.add(it.uri.relayUri) }
clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) }
relays.toSet()
}
}
val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) }
emitAll(
combine(ready) { perAccount ->
val moneyOpRelays = mutableSetOf<NormalizedRelayUrl>()
perAccount.forEach { moneyOpRelays.addAll(it) }
moneyOpRelays.toSet()
},
)
}.onEach {
torEvaluatorFlow.moneyOpRelays.tryEmit(it)
}.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
@Stable
@@ -45,6 +46,58 @@ class TorRelayState(
val dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/**
* Relays known to be used for money operations from persistent configuration: NIP-47 wallet
* relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all
* logged-in accounts. These follow the money-operations Tor preference (see [TorRelayEvaluation]).
*/
val moneyOpRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/**
* Money-operation relays registered for the lifetime of a single ad-hoc round-trip whose relay
* isn't a saved wallet — e.g. paying someone's CLINK offer (`noffer`) pointer. Reference-counted
* so overlapping payments that share a relay don't unregister it while another is still in flight.
*/
private val adHocMoneyOpCounts = MutableStateFlow<Map<NormalizedRelayUrl, Int>>(emptyMap())
private fun currentMoneyOpRelays(): Set<NormalizedRelayUrl> = moneyOpRelays.value + adHocMoneyOpCounts.value.keys
/**
* Marks [relays] as money-operation relays until a matching [unregisterMoneyOpRelays] call.
* Used by the CLINK offer/debit payers so a one-off payment relay honors the money-operations
* Tor preference instead of being treated as a generic "new" relay.
*/
fun registerMoneyOpRelays(relays: Set<NormalizedRelayUrl>) {
if (relays.isEmpty()) return
adHocMoneyOpCounts.update { current ->
current.toMutableMap().apply {
relays.forEach { this[it] = (this[it] ?: 0) + 1 }
}
}
}
fun unregisterMoneyOpRelays(relays: Set<NormalizedRelayUrl>) {
if (relays.isEmpty()) return
adHocMoneyOpCounts.update { current ->
current.toMutableMap().apply {
relays.forEach {
val next = (this[it] ?: 0) - 1
if (next <= 0) remove(it) else this[it] = next
}
}
}
}
private fun currentSettings() =
TorRelaySettings(
torType = torSettingsFlow.torType.value,
onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value,
dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value,
newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value,
trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value,
moneyOperationsViaTor = torSettingsFlow.moneyOperationsViaTor.value,
)
val torSettings =
combine(
torSettingsFlow.torType,
@@ -66,27 +119,15 @@ class TorRelayState(
newRelaysViaTor = newRelaysViaTor,
trustedRelaysViaTor = trustedRelaysViaTor,
)
}.combine(torSettingsFlow.moneyOperationsViaTor) { settings, moneyOperationsViaTor ->
settings.copy(moneyOperationsViaTor = moneyOperationsViaTor)
}.onStart {
emit(
TorRelaySettings(
torType = torSettingsFlow.torType.value,
onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value,
dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value,
newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value,
trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value,
),
)
emit(currentSettings())
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
TorRelaySettings(
torType = torSettingsFlow.torType.value,
onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value,
dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value,
newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value,
trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value,
),
currentSettings(),
)
val flow =
@@ -94,12 +135,21 @@ class TorRelayState(
torSettings,
trustedRelays,
dmRelays,
) { torSettings: TorRelaySettings, trustedRelayList: Set<NormalizedRelayUrl>, dmRelayList: Set<NormalizedRelayUrl> ->
moneyOpRelays,
adHocMoneyOpCounts,
) {
torSettings: TorRelaySettings,
trustedRelayList: Set<NormalizedRelayUrl>,
dmRelayList: Set<NormalizedRelayUrl>,
moneyOpRelayList: Set<NormalizedRelayUrl>,
adHocMoneyOps: Map<NormalizedRelayUrl, Int>,
->
emit(
TorRelayEvaluation(
torSettings = torSettings,
trustedRelayList = trustedRelayList,
dmRelayList = dmRelayList,
moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys,
),
)
}.onStart {
@@ -108,6 +158,7 @@ class TorRelayState(
torSettings = torSettings.value,
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
moneyOpRelayList = currentMoneyOpRelays(),
),
)
}.flowOn(Dispatchers.IO)
@@ -118,10 +169,22 @@ class TorRelayState(
torSettings = torSettings.value,
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
moneyOpRelayList = currentMoneyOpRelays(),
),
)
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = flow.value.useTor(relay)
/**
* Resolves the Tor preference for [relay] from live source values rather than the cached [flow]
* snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the
* very next connection attempt, with no dependency on the combine pipeline having propagated yet.
*/
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) =
TorRelayEvaluation(
torSettings = currentSettings(),
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
moneyOpRelayList = currentMoneyOpRelays(),
).useTor(relay)
fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url))
}
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.experimental.clink.client.DebitClient
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/**
* Drives the CLINK Debits payer round-trips: publishes a kind-21002 request (pay an
* invoice, or authorize a spending budget) and waits for the encrypted reply. The wallet
* authorizes against the account's own identity (no shared secret).
*
* This is the CLINK-debit spend rail that the zap button / offer card route through
* when a debit pointer is the selected default payment source. It MUST only be invoked
* after an explicit user confirmation — a debit moves real sats.
*
* Consume-only: Amethyst sends debit requests, it never answers them.
*/
object ClinkDebitPayer {
const val DEFAULT_TIMEOUT_MS = 30_000L
/**
* Asks the wallet to pay [bolt11].
*
* @return the decrypted response (`res:"ok"` with optional preimage, or a `GFY`
* failure), or null if no reply arrived in time or the pointer carried no relay.
*/
suspend fun payInvoice(
account: Account,
pointer: NDebit,
bolt11: String,
amountSats: Long? = null,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
): DebitResponse? =
// Off the Main thread: building the request signs + NIP-44 encrypts, and callers reach
// this from Compose (Main) scopes (offer card, lightning-address row). See ClinkOfferPayer.
withContext(Dispatchers.IO) {
val client = clientFor(pointer, account) ?: return@withContext null
sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs)
}
/**
* Asks the wallet to authorize a spending budget. Omit [frequency] for a one-time
* budget; otherwise it recurs every `frequency` (day/week/month).
*/
suspend fun requestBudget(
account: Account,
pointer: NDebit,
amountSats: Long,
frequency: DebitFrequency? = null,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
): DebitResponse? =
withContext(Dispatchers.IO) {
val client = clientFor(pointer, account) ?: return@withContext null
sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs)
}
// Debits sign with the persistent account identity (unlike offer requests, which use a
// throwaway key — see ClinkOfferPayer): the service must see one stable app identity so a
// budget authorization can cover repeat debits instead of prompting on every payment.
private fun clientFor(
pointer: NDebit,
account: Account,
): DebitClient? = if (pointer.relays.isEmpty()) null else DebitClient(pointer, account.signer)
/** Publishes [request] to the pointer's relays and awaits the matching kind-21002 reply. */
private suspend fun sendAndAwait(
account: Account,
client: DebitClient,
request: DebitEvent,
timeoutMs: Long,
): DebitResponse? {
val relays = client.pointer.relays.toSet()
val reply = CompletableDeferred<DebitEvent>()
// A random short id: relays cap subscription ids at 64 chars (NIP-01); the reply is matched
// by request id in the listener, not by subId.
val subId = newSubId()
val filters: Map<NormalizedRelayUrl, List<Filter>> = relays.associateWith { listOf(client.responseFilter(request.id)) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is DebitEvent && event.requestId() == request.id && !reply.isCompleted) {
reply.complete(event)
}
}
}
// Saved debit wallets are already fed into the money-op relay set by AccountsTorStateConnector,
// but register here too so a freshly-added wallet paid before that flow propagates — and any
// non-saved debit pointer — still routes under the money-operations Tor preference rather than
// the generic `newRelaysViaTor` policy.
val torState = Amethyst.instance.torEvaluatorFlow
torState.registerMoneyOpRelays(relays)
account.client.subscribe(subId, filters, listener)
return try {
account.client.publish(request, relays)
val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null
// Treat an undecryptable/malformed reply as no usable response rather than
// throwing — callers only handle null, and an uncaught decode error would
// leave the calling UI hung (spinner stuck, no toast, sibling zaps cancelled).
try {
client.parseResponse(response)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
} finally {
account.client.unsubscribe(subId)
torState.unregisterMoneyOpRelays(relays)
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.experimental.clink.client.OfferClient
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/**
* Drives the CLINK Offers payer round-trip: publishes a kind-21001 request to the
* offer's relays and waits for the service's encrypted reply, returning the decrypted
* [OfferResponse] (an invoice or an error). UI then hands a successful `bolt11` to the
* existing pay path (e.g. `payViaIntent`).
*
* Consume-only: Amethyst never answers offer requests, it only asks.
*/
object ClinkOfferPayer {
const val DEFAULT_TIMEOUT_MS = 30_000L
/**
* @param amountSats overrides the pointer's embedded price (required for spontaneous offers).
* @return the decrypted response, or null if no reply arrived before [timeoutMs] (or the
* pointer carried no relay to reach).
*/
suspend fun requestInvoice(
account: Account,
offer: NOffer,
amountSats: Long? = null,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
): OfferResponse? {
val relays = offer.relays.toSet()
if (relays.isEmpty()) return null
// Keep the round-trip off the Main thread: the ephemeral keygen, JSON serialization,
// NIP-44 encryption and signing are CPU/crypto-heavy, and callers reach this from a
// Compose (Main) scope. StrictMode flags any of it running on the UI thread.
return withContext(Dispatchers.IO) {
// Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker
// News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this
// key and decrypted with it), so there is no reason to expose the user's real identity
// to every offer service they pay. Payer identity, when needed, travels in the request
// body (payer_data / a signed zap request), not the transport key.
val ephemeralSigner = NostrSignerInternal(KeyPair())
val client = OfferClient(offer, ephemeralSigner)
val request = client.requestInvoice(amountSats = amountSats)
val reply = CompletableDeferred<OfferEvent>()
// A random short id: relays cap subscription ids at 64 chars (NIP-01) and reject an
// over-long REQ outright. The reply is matched by request id in the listener, not by subId.
val subId = newSubId()
val filters: Map<NormalizedRelayUrl, List<Filter>> = relays.associateWith { listOf(client.responseFilter(request.id)) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) {
reply.complete(event)
}
}
}
// The offer's relays are an ad-hoc payment endpoint, not a saved wallet, so register them
// as money-operation relays for the duration of the round-trip. Otherwise account.client
// would treat them as generic "new" relays and route them per `newRelaysViaTor`, silently
// pushing the payment through Tor (and failing on services that block Tor exits) even when
// the user disabled Tor for money operations. The subscribe() below triggers a reconnect, and
// BasicRelayClient rebuilds any socket left on the now-wrong (Tor) transport onto clearnet.
val torState = Amethyst.instance.torEvaluatorFlow
torState.registerMoneyOpRelays(relays)
account.client.subscribe(subId, filters, listener)
try {
account.client.publish(request, relays)
val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return@withContext null
// A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON
// from a buggy or hostile relay) is treated as no usable response rather than
// thrown — callers only handle null, and an uncaught decode error would hang
// the UI (the Pay button stuck on "Requesting…").
try {
client.parseResponse(response)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
} finally {
account.client.unsubscribe(subId)
torState.unregisterMoneyOpRelays(relays)
}
}
}
}
@@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -44,8 +46,10 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.round
class ZapPaymentHandler(
@@ -206,14 +210,27 @@ class ZapPaymentHandler(
onProgress(0.75f)
}
if (account.nip47SignerState.hasWalletConnectSetup()) {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context)
// onProgress(1f)
} else {
onPayViaIntent(payables.toImmutableList())
onProgress(0f)
// Route through the user's selected default payment source. A CLINK debit takes
// precedence over NWC when it is the chosen default; NWC-only users are unaffected
// (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app.
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f)
}, context)
}
is PaymentSource.Nwc -> {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context)
// onProgress(1f)
}
null -> {
onPayViaIntent(payables.toImmutableList())
onProgress(0f)
}
}
}
@@ -337,7 +354,7 @@ class ZapPaymentHandler(
onProgress: (percent: Float) -> Unit,
context: Context,
): List<Paid> {
var progressAllPayments = 0.00f
val progress = PaymentProgress(payables.size, onProgress)
return mapNotNullAsync(
items = payables,
@@ -346,9 +363,8 @@ class ZapPaymentHandler(
bolt11 = payable.invoice,
zappedNote = note,
onResponse = { response ->
progress.step()
if (response is PayInvoiceErrorResponse) {
progressAllPayments += 0.5f / payables.size
onProgress(progressAllPayments)
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
stringRes(
@@ -359,15 +375,68 @@ class ZapPaymentHandler(
),
payable.info.user,
)
} else {
progressAllPayments += 0.5f / payables.size
onProgress(progressAllPayments)
}
},
)
progressAllPayments += 0.5f / payables.size
onProgress(progressAllPayments)
progress.step()
Paid(payable, true)
},
)
}
/**
* Thread-safe progress accumulator for the parallel pay rails. Each payable advances in two
* half-steps (request dispatched, then response/settlement), reported as a 0..1 fraction.
* The counter is atomic because `mapNotNullAsync` runs the payables concurrently and the
* response half-step fires from an async callback, so plain `+=` would lose updates.
*/
private class PaymentProgress(
payableCount: Int,
private val onProgress: (percent: Float) -> Unit,
) {
private val totalSteps = (payableCount * 2).coerceAtLeast(1)
private val done = AtomicInteger(0)
fun step() = onProgress(done.incrementAndGet().toFloat() / totalSteps)
}
/**
* Pays each zap invoice by asking the user's CLINK debit service (kind 21002) to
* settle the BOLT-11. The service authorizes against the account identity.
*
* Fire-and-forget, like the NWC rail ([payViaNWC]): the request is dispatched on the
* account scope and each payable is reported paid optimistically so the zap UI completes
* promptly. A `GFY`/failure (or no reply within the debit timeout) surfaces later through
* [onError] rather than blocking the zap on the service's response.
*/
suspend fun payViaClinkDebit(
payables: List<Payable>,
pointer: NDebit,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
): List<Paid> {
val progress = PaymentProgress(payables.size, onProgress)
return mapNotNullAsync(
items = payables,
runRequestFor = { payable: Payable ->
account.scope.launch {
val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice)
progress.step()
if (response?.isOk() != true) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
payable.info.user,
)
}
}
progress.step()
Paid(payable, true)
},
@@ -303,6 +303,22 @@ fun NewUserMetadataScreen(
singleLine = true,
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.clink_offer_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.clinkOffer.value,
onValueChange = { postViewModel.clinkOffer.value = it },
placeholder = {
Text(
text = "noffer1…",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
)
// -- Social Proofs --
ExpandableSection(
title = stringRes(R.string.social_proof),
@@ -60,6 +60,7 @@ class NewUserMetadataViewModel : ViewModel() {
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
val clinkOffer = mutableStateOf("")
val twitter = mutableStateOf("")
val github = mutableStateOf("")
@@ -85,6 +86,7 @@ class NewUserMetadataViewModel : ViewModel() {
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
clinkOffer.value = it.info.clinkOffer ?: ""
}
twitter.value = ""
@@ -124,6 +126,7 @@ class NewUserMetadataViewModel : ViewModel() {
nip05 = nip05.value,
lnAddress = lnAddress.value,
lnURL = lnURL.value,
clinkOffer = clinkOffer.value,
)
val identities =
@@ -149,6 +152,7 @@ class NewUserMetadataViewModel : ViewModel() {
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
clinkOffer.value = ""
twitter.value = ""
github.value = ""
mastodon.value = ""
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
@@ -108,6 +109,7 @@ 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.creators.invoice.ClinkOfferPreview
import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -503,6 +505,10 @@ private fun RenderWordWithoutPreview(
// as a wall of base64.
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
// Decoding is local and the network round-trip only fires on the Pay tap,
// so the offer card is safe to render even in the no-preview path.
is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is SecretEmoji -> Text(word.segmentText)
@@ -549,6 +555,7 @@ private fun RenderWordWithPreview(
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel)
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is ClinkOfferSegment -> ClinkOfferPreview(word.offer, 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)
@@ -197,6 +197,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddClinkDebitWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddNwcWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
@@ -317,6 +318,7 @@ fun BuildNavigation(
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav, it.nip47) }
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddClinkDebit> { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) }
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWalletSettings> { CashuWalletSettingsScreen(accountViewModel, nav) }
@@ -210,6 +210,10 @@ sealed class Route {
@Serializable object WalletAddCashu : Route()
@Serializable data class WalletAddClinkDebit(
val ndebit: String? = null,
) : Route()
@Serializable object CashuWallet : Route()
@Serializable object CashuWalletSettings : Route()
@@ -0,0 +1,287 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.invoice
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.ClinkOfferPayer
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.clink.common.SatRange
import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType
import kotlinx.coroutines.launch
/**
* Inline card for a CLINK Offers pointer (`noffer1…`) found in a note. Tapping "Pay"
* runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr,
* then pays it through the user's default payment source (confirmed for in-app wallets,
* see [InvoicePaymentDispatcher]).
*/
@Composable
fun ClinkOfferPreview(
offer: NOffer,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val clipboard = LocalClipboard.current
var requesting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var payingInvoice by remember { mutableStateOf<String?>(null) }
var amountInput by remember { mutableStateOf("") }
var needsAmount by remember { mutableStateOf(offer.priceType == OfferPriceType.SPONTANEOUS) }
var amountRange by remember { mutableStateOf<SatRange?>(null) }
// The pointer actually paid: starts as the rendered offer, swapped if the service
// replies "Expired or Moved" (code 3) with a replacement noffer.
var activeOffer by remember(offer) { mutableStateOf(offer) }
errorMessage?.let {
ErrorMessageDialog(
title = stringRes(context, R.string.error_dialog_pay_invoice_error),
textContent = it,
onDismiss = { errorMessage = null },
)
}
InvoicePaymentDispatcher(
bolt11 = payingInvoice,
accountViewModel = accountViewModel,
onClear = { payingInvoice = null },
onError = { errorMessage = it },
)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Icon(
imageVector = CustomHashTagIcons.Lightning,
contentDescription = null,
modifier = Size20Modifier,
tint = Color.Unspecified,
)
Text(
text = stringRes(R.string.clink_lightning_offer),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
)
Spacer(modifier = Modifier.weight(1f))
val copiedMessage = stringRes(R.string.copied_to_clipboard)
IconButton(
onClick = {
scope.launch {
clipboard.setText(activeOffer.encode())
Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
}
},
) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = stringRes(R.string.copy_to_clipboard),
tint = MaterialTheme.colorScheme.primary,
modifier = Size20Modifier,
)
}
}
HorizontalDivider(thickness = DividerThickness)
// FIXED offers display their preset price; SPONTANEOUS offers (and the default
// when the pointer omits a price type) require the payer to enter an amount.
// Reflect the pointer actually being charged (which may have changed if the
// service redirected us to a replacement noffer via "Expired or Moved").
val effectiveType = activeOffer.priceType
if (effectiveType == OfferPriceType.FIXED) {
activeOffer.price?.let {
Text(
text = "$it ${stringRes(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
}
if (needsAmount) {
OutlinedTextField(
value = amountInput,
onValueChange = { new -> amountInput = new.filter(Char::isDigit) },
label = { Text(stringRes(R.string.clink_offer_amount_sats)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText =
amountRange?.let { range ->
val min = range.min
val max = range.max
if (min != null && max != null) {
{ Text(stringRes(R.string.clink_offer_amount_range, min.toString(), max.toString())) }
} else {
null
}
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
val amountRequired = needsAmount
suspend fun runOfferRequest(
useOffer: NOffer,
followMoved: Boolean,
) {
val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount)
val bolt11 = response?.bolt11
val movedTo =
if (response?.code == OfferErrorCode.EXPIRED_OR_MOVED && followMoved) {
response.latest?.let { ClinkPointerParser.parse(it) as? NOffer }
} else {
null
}
when {
bolt11 != null -> {
requesting = false
payingInvoice = bolt11
}
// Follow a relocated offer once, paying the replacement pointer.
movedTo != null -> {
activeOffer = movedTo
runOfferRequest(movedTo, followMoved = false)
}
response?.code == OfferErrorCode.INVALID_AMOUNT -> {
// Reveal the amount field (or refine it) with the service's range.
requesting = false
needsAmount = true
amountRange = response.range
errorMessage =
response.error?.takeIf { it.isNotBlank() }
?: stringRes(context, R.string.clink_offer_invalid_amount)
}
else -> {
requesting = false
errorMessage =
response?.error?.takeIf { it.isNotBlank() }
?: stringRes(context, R.string.error_dialog_pay_invoice_error)
}
}
}
Button(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
enabled = !requesting && (!amountRequired || (amountInput.toLongOrNull() ?: 0L) > 0L),
onClick = {
requesting = true
scope.launch { runOfferRequest(activeOffer, followMoved = true) }
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(
text = stringRes(if (requesting) R.string.clink_requesting_invoice else R.string.pay),
color = Color.White,
fontSize = 20.sp,
)
}
}
}
}
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.invoice
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
/**
* Pays a single BOLT-11 from an in-post card (offer card, invoice card) through the
* user's selected default payment source.
*
* Unlike the zap button — a deliberate small-amount tap that fires immediately — a
* card "Pay" can be a larger or variable amount, so an **in-app** payment (NWC or CLINK
* debit) is gated behind a confirmation dialog. The external-wallet path needs no extra
* confirmation: the wallet app presents its own.
*
* Drive it from a nullable `bolt11` state: set it to trigger, [onClear] resets it.
*/
@Composable
fun InvoicePaymentDispatcher(
bolt11: String?,
accountViewModel: AccountViewModel,
onClear: () -> Unit,
onError: (String) -> Unit,
onSuccess: () -> Unit = {},
) {
if (bolt11 == null) return
val context = LocalContext.current
val source = remember(bolt11) { accountViewModel.account.settings.defaultPaymentSource() }
if (source == null) {
// No in-app wallet configured -> hand off to an external wallet app (it confirms).
LaunchedEffect(bolt11) {
payViaIntent(bolt11, context, onSuccess, onError)
onClear()
}
return
}
val amountSats =
remember(bolt11) {
try {
LnInvoiceUtil.getAmountInSats(bolt11).toLong().takeIf { it > 0 }
} catch (_: Exception) {
null
}
}
ConfirmPaymentDialog(
amountSats = amountSats,
sourceName = source.name,
onConfirm = {
when (source) {
is PaymentSource.Nwc ->
accountViewModel.sendZapPaymentRequestFor(bolt11, null) { response ->
when (response) {
is PayInvoiceSuccessResponse -> onSuccess()
is PayInvoiceErrorResponse ->
onError(
response.error?.message
?: response.error?.code?.toString()
?: stringRes(context, R.string.error_parsing_error_message),
)
else -> {}
}
}
is PaymentSource.ClinkDebit ->
accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, bolt11) { response ->
if (response?.isOk() == true) {
onSuccess()
} else {
onError(
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
)
}
}
}
onClear()
},
onDismiss = onClear,
)
}
@Composable
private fun ConfirmPaymentDialog(
amountSats: Long?,
sourceName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val message =
if (amountSats != null) {
val amountText = "$amountSats ${stringRes(context, R.string.sats)}"
stringRes(context, R.string.clink_confirm_pay_amount_via_source, amountText, sourceName)
} else {
stringRes(context, R.string.clink_confirm_pay_via_source, sourceName)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.clink_confirm_payment_title)) },
text = { Text(message) },
confirmButton = {
Button(onClick = onConfirm) { Text(stringRes(R.string.pay)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser
import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -89,7 +88,7 @@ fun MayBeInvoicePreview(
LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount ->
CrossfadeIfEnabled(targetState = invoiceAmount, label = "MayBeInvoicePreview", accountViewModel = accountViewModel) {
if (it != null) {
InvoicePreview(it.invoice, it.amount)
InvoicePreview(it.invoice, it.amount, accountViewModel)
} else {
Text(
text = lnbcWord,
@@ -104,10 +103,12 @@ fun MayBeInvoicePreview(
fun InvoicePreview(
lnInvoice: String,
amount: String?,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
var payingInvoice by remember { mutableStateOf<String?>(null) }
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
@@ -117,6 +118,13 @@ fun InvoicePreview(
)
}
InvoicePaymentDispatcher(
bolt11 = payingInvoice,
accountViewModel = accountViewModel,
onClear = { payingInvoice = null },
onError = { showErrorMessageDialog = it },
)
Column(
modifier =
Modifier
@@ -172,7 +180,7 @@ fun InvoicePreview(
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = { payViaIntent(lnInvoice, context, { }) { showErrorMessageDialog = it } },
onClick = { payingInvoice = lnInvoice },
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
@@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -43,6 +46,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
@@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
@@ -116,13 +121,23 @@ fun WatchResponses(
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
// Snapshot once per result list, not per row.
val priority = remember(suggestions) { userSuggestions.priorityPubkeys() }
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier = modifier,
state = listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item ->
UserLine(item, accountViewModel, trailingContent) { onSelect(item) }
val trailing =
trailingContent
?: if (item.pubkeyHex in priority) {
{ InThisChatChip() }
} else {
null
}
UserLine(item, accountViewModel, trailing) { onSelect(item) }
HorizontalDivider(
thickness = DividerThickness,
)
@@ -133,6 +148,22 @@ fun WatchResponses(
}
}
@Composable
private fun InThisChatChip() {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
text = stringRes(R.string.user_suggestion_in_this_chat),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@Composable
fun UserLine(
baseUser: User,
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
@@ -58,10 +59,36 @@ val userUriPrefixes =
DualCase("nostr:nprofile"),
)
/**
* Moves users whose pubkey is in [priority] to the top of [found],
* preserving the relative order everywhere else (stable sort). Reorders
* only — it never adds or removes entries, so priority keys whose users
* didn't match the search have no effect.
*/
fun rankPriorityFirst(
found: List<User>,
priority: Set<HexKey>,
): List<User> =
if (priority.isEmpty()) {
found
} else {
found.sortedByDescending { it.pubkeyHex in priority }
}
/**
* Drives the @-mention autocomplete dropdown: searches the local cache,
* relays, and NIP-05 identifiers for the word currently being typed.
*
* [priorityPubkeys] is a live supplier of pubkeys to rank first in the
* results — pass the current conversation's participants (NIP-17 room
* users, public-chat authors, MLS group members, …) so they beat
* network-wide matches. Ranking only; it never filters anyone out.
*/
@Stable
class UserSuggestionState(
val account: Account,
val nip05Client: INip05Client,
val priorityPubkeys: () -> Set<HexKey> = { emptySet() },
) {
val invalidations = MutableStateFlow(0)
val currentWord = MutableStateFlow("")
@@ -158,7 +185,10 @@ class UserSuggestionState(
}
if (prefix != null) {
logTime("UserSuggestionState Search $prefix version $version") {
account.cache.findUsersStartingWith(prefix, account)
rankPriorityFirst(
account.cache.findUsersStartingWith(prefix, account),
priorityPubkeys(),
)
}
} else {
emptyList()
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
@@ -73,6 +74,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
@@ -87,6 +89,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
@@ -1669,6 +1673,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.
@@ -1676,10 +1685,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)
@@ -2069,6 +2079,22 @@ class AccountViewModel(
onSent()
}
/**
* Pays a single BOLT-11 through a CLINK debit pointer (kind 21002) — the debit-rail
* counterpart of [sendZapPaymentRequestFor]. [onResult] receives the decrypted
* response (`isOk()` with optional preimage, or a GFY failure), or null on timeout,
* delivered on the main dispatcher so UI callbacks (toasts, dialogs) are safe.
* Untested end-to-end.
*/
fun payInvoiceViaClinkDebit(
pointer: NDebit,
bolt11: String,
onResult: (DebitResponse?) -> Unit,
) = launchSigner {
val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11)
withContext(Dispatchers.Main) { onResult(response) }
}
fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag))
fun updateInteractiveStoryReadingState(
@@ -27,16 +27,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -50,16 +46,19 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
@@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
@@ -96,19 +96,15 @@ fun MarmotGroupChatView(
WatchLifecycleAndUpdateModel(feedViewModel)
val chatroom =
remember(nostrGroupId) {
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
}
val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel")
newMessageModel.init(accountViewModel)
newMessageModel.load(nostrGroupId)
DisposableEffect(nostrGroupId) {
chatroom.markAsRead()
newMessageModel.chatroom?.markAsRead()
onDispose { }
}
val messageState = remember(nostrGroupId) { TextFieldState() }
val replyTo = remember(nostrGroupId) { mutableStateOf<Note?>(null) }
// Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS
// message in the Notifications screen) into the actual Note once it has
// landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids.
@@ -116,14 +112,14 @@ fun MarmotGroupChatView(
LaunchedEffect(replyToInnerNote) {
val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote)
if (parent != null) {
replyTo.value = parent
newMessageModel.reply(parent)
}
}
}
if (draftMessage != null) {
LaunchedEffect(draftMessage) {
messageState.setTextAndPlaceCursorAtEnd(draftMessage)
newMessageModel.editFromDraft(draftMessage)
}
}
@@ -139,7 +135,7 @@ fun MarmotGroupChatView(
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "MarmotGroup/$nostrGroupId",
onWantsToReply = { note -> replyTo.value = note },
onWantsToReply = { note -> newMessageModel.reply(note) },
onWantsToEditDraft = { },
)
}
@@ -148,8 +144,7 @@ fun MarmotGroupChatView(
MarmotGroupMessageComposer(
nostrGroupId = nostrGroupId,
messageState = messageState,
replyTo = replyTo,
newMessageModel = newMessageModel,
accountViewModel = accountViewModel,
nav = nav,
onMessageSent = {
@@ -162,49 +157,59 @@ fun MarmotGroupChatView(
@Composable
fun MarmotGroupMessageComposer(
nostrGroupId: HexKey,
messageState: TextFieldState,
replyTo: MutableState<Note?>,
newMessageModel: MarmotNewMessageViewModel,
accountViewModel: AccountViewModel,
nav: INav,
onMessageSent: suspend () -> Unit,
) {
val scope = rememberCoroutineScope()
val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } }
val canPost by remember { derivedStateOf { newMessageModel.canPost() } }
val context = LocalContext.current
var isUploading by remember { mutableStateOf(false) }
val uploadState =
remember {
ChatFileUploadState(
defaultServer = accountViewModel.account.settings.defaultFileServer,
defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload,
)
}
// Upload dialog
uploadState.multiOrchestrator?.let {
MarmotGroupFileUploadDialog(
nostrGroupId = nostrGroupId,
state = uploadState,
accountViewModel = accountViewModel,
nav = nav,
onUpload = { onMessageSent() },
onCancel = uploadState::reset,
)
DisposableEffect(nostrGroupId) {
onDispose { newMessageModel.userSuggestions?.reset() }
}
replyTo.value?.let {
// Upload dialog
newMessageModel.uploadState?.let { uploadState ->
uploadState.multiOrchestrator?.let {
MarmotGroupFileUploadDialog(
nostrGroupId = nostrGroupId,
state = uploadState,
accountViewModel = accountViewModel,
nav = nav,
onUpload = { onMessageSent() },
onCancel = uploadState::reset,
)
}
}
newMessageModel.replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
replyTo.value = null
newMessageModel.clearReply()
}
}
Column(modifier = EditFieldModifier) {
newMessageModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
newMessageModel::autocompleteWithUser,
accountViewModel,
SuggestionListDefaultHeightChat,
)
}
ThinPaddingTextField(
state = messageState,
state = newMessageModel.message,
onTextChanged = { newMessageModel.onMessageChanged() },
onContentReceived = { uri, mimeType ->
uploadState.load(persistentListOf(SelectedMedia(uri, mimeType)))
newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
},
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
@@ -216,9 +221,7 @@ fun MarmotGroupMessageComposer(
leadingIcon = {
MarmotGalleryLeadingIcon(
isUploading = isUploading,
onImageChosen = { selectedMedia ->
uploadState.load(selectedMedia)
},
onImageChosen = newMessageModel::pickedMedia,
)
},
trailingIcon = {
@@ -226,33 +229,18 @@ fun MarmotGroupMessageComposer(
isActive = canPost,
modifier = EditFieldTrailingIconModifier,
) {
val text = messageState.text.toString().trim()
if (text.isNotEmpty()) {
// Capture id+pubKey snapshot under the value? guard so
// a slow send doesn't race a user-cleared reply state.
val parentEvent = replyTo.value?.event
val replyId = parentEvent?.id
val replyAuthor = parentEvent?.pubKey
scope.launch(Dispatchers.IO) {
try {
accountViewModel.sendMarmotGroupMessage(
nostrGroupId = nostrGroupId,
text = text,
replyToInnerEventId = replyId,
replyToInnerAuthorPubKey = replyAuthor,
)
messageState.clearText()
replyTo.value = null
onMessageSent()
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to send message: ${e.message}",
Toast.LENGTH_SHORT,
).show()
}
scope.launch(Dispatchers.IO) {
try {
newMessageModel.sendPost()
onMessageSent()
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to send message: ${e.message}",
Toast.LENGTH_SHORT,
).show()
}
}
}
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
/**
* Composition state for the Marmot/MLS group message field, mirroring the
* structure of the other chat composers (ChatNewMessageViewModel,
* ChannelNewMessageViewModel, NestNewMessageViewModel): @-mention
* suggestions, reply state, and file-upload state. Sending goes through
* AccountViewModel.sendMarmotGroupMessage, which owns mention tagging.
*/
@Stable
open class MarmotNewMessageViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
var nostrGroupId: HexKey? = null
var chatroom: MarmotGroupChatroom? = null
val message = TextFieldState()
val replyTo = mutableStateOf<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
var userSuggestions: UserSuggestionState? = null
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
this.userSuggestions?.reset()
this.userSuggestions =
UserSuggestionState(
accountVM.account,
accountVM.nip05ClientBuilder(),
priorityPubkeys = { chatroom?.members?.value?.mapTo(mutableSetOf()) { it.pubkey } ?: emptySet() },
)
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
}
open fun load(nostrGroupId: HexKey) {
if (this.nostrGroupId != nostrGroupId) {
this.nostrGroupId = nostrGroupId
this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
this.message.clearText()
this.replyTo.value = null
}
}
fun reply(note: Note) {
replyTo.value = note
}
fun clearReply() {
replyTo.value = null
}
fun editFromDraft(draftMessage: String) {
message.setTextAndPlaceCursorAtEnd(draftMessage)
}
fun canPost() = message.text.isNotBlank()
fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestions?.processCurrentWord(lastWord)
} else {
userSuggestions?.reset()
}
}
}
fun autocompleteWithUser(item: User) {
userSuggestions?.let {
it.replaceCurrentWord(message, message.currentWord(), item)
it.reset()
}
}
fun pickedMedia(media: ImmutableList<SelectedMedia>) {
uploadState?.load(media)
}
/** Sends the field's text. Mention rewriting and p-tagging happen in
* AccountViewModel.sendMarmotGroupMessage. Throws on send failure so
* the caller can surface the error. */
suspend fun sendPost() {
val groupId = nostrGroupId ?: return
val text = message.text.toString().trim()
if (text.isEmpty()) return
// Capture id+pubKey snapshot before suspending so a slow send
// doesn't race a user-cleared reply state.
val parentEvent = replyTo.value?.event
accountViewModel.sendMarmotGroupMessage(
nostrGroupId = groupId,
text = text,
replyToInnerEventId = parentEvent?.id,
replyToInnerAuthorPubKey = parentEvent?.pubKey,
)
message.clearText()
replyTo.value = null
userSuggestions?.reset()
}
}
@@ -261,7 +261,12 @@ class ChatNewMessageViewModel :
this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.userSuggestions =
UserSuggestionState(
accountVM.account,
accountVM.nip05ClientBuilder(),
priorityPubkeys = { room.value?.users ?: emptySet() },
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -188,7 +188,16 @@ open class ChannelNewMessageViewModel :
this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.userSuggestions =
UserSuggestionState(
accountVM.account,
accountVM.nip05ClientBuilder(),
priorityPubkeys = {
// Public channels have no membership; recent posters are the
// closest thing. The cutoff also bounds the note scan.
channel?.participatingAuthors(TimeUtils.oneMonthAgo())?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet()
},
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -49,6 +49,7 @@ 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.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -379,39 +380,57 @@ fun DvmPaymentActions(
if (invoice != null) {
val context = LocalContext.current
Button(onClick = {
if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) {
accountViewModel.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = null,
onSent = {
onStatusUpdate(nwcPaymentRequest)
},
onResponse = { response ->
when (val source = accountViewModel.account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
onStatusUpdate(nwcPaymentRequest)
accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response ->
onStatusUpdate(
if (response is PayInvoiceErrorResponse) {
stringRes(
context,
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
)
} else {
if (response?.isOk() == true) {
thankYou
} else {
response?.error?.takeIf { it.isNotBlank() }
?: stringRes(context, R.string.clink_debit_no_response)
},
)
},
)
} else {
payViaIntent(
invoice,
context,
onPaid = {
onStatusUpdate(thankYou)
},
onError = {
onStatusUpdate(it)
},
)
}
}
is PaymentSource.Nwc -> {
accountViewModel.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = null,
onSent = {
onStatusUpdate(nwcPaymentRequest)
},
onResponse = { response ->
onStatusUpdate(
if (response is PayInvoiceErrorResponse) {
stringRes(
context,
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
)
} else {
thankYou
},
)
},
)
}
null -> {
payViaIntent(
invoice,
context,
onPaid = {
onStatusUpdate(thankYou)
},
onError = {
onStatusUpdate(it)
},
)
}
}
}) {
val amountInInvoice =
@@ -194,7 +194,16 @@ open class NestNewMessageViewModel :
this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.userSuggestions =
UserSuggestionState(
accountVM.account,
accountVM.nip05ClientBuilder(),
priorityPubkeys = {
(room?.event as? MeetingSpaceEvent)?.let { space ->
space.participantKeys().toSet() + space.pubKey
} ?: emptySet()
},
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
class UserProfileGalleryFeedFilter(
@@ -81,7 +81,7 @@ class UserProfileGalleryFeedFilter(
(
noteEvent is PictureEvent ||
noteEvent is RegularVideoEvent ||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
(noteEvent is AddressableVideoEvent && it is AddressableNote) ||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
)
@@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText
@@ -109,22 +110,38 @@ fun DisplayLNAddress(
lud16,
user,
accountViewModel,
onSuccess = {
onSuccess = { invoice ->
zapExpanded = false
// pay directly
if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) {
accountViewModel.sendZapPaymentRequestFor(it, null) { response ->
if (response is PayInvoiceSuccessResponse) {
showInfoMessageDialog = stringRes(context, R.string.payment_successful)
} else if (response is PayInvoiceErrorResponse) {
showErrorMessageDialog =
response.error?.message
?: response.error?.code?.toString()
?: stringRes(context, R.string.error_parsing_error_message)
// pay directly through the selected default payment source
when (val source = accountViewModel.account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response ->
if (response?.isOk() == true) {
showInfoMessageDialog = stringRes(context, R.string.payment_successful)
} else {
showErrorMessageDialog =
response?.error?.takeIf { it.isNotBlank() }
?: stringRes(context, R.string.clink_debit_no_response)
}
}
}
} else {
payViaIntent(it, context, { zapExpanded = false }, { showErrorMessageDialog = it })
is PaymentSource.Nwc -> {
accountViewModel.sendZapPaymentRequestFor(invoice, null) { response ->
if (response is PayInvoiceSuccessResponse) {
showInfoMessageDialog = stringRes(context, R.string.payment_successful)
} else if (response is PayInvoiceErrorResponse) {
showErrorMessageDialog =
response.error?.message
?: response.error?.code?.toString()
?: stringRes(context, R.string.error_parsing_error_message)
}
}
}
null -> {
payViaIntent(invoice, context, { zapExpanded = false }, { showErrorMessageDialog = it })
}
}
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
@@ -21,6 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.ClipData
import android.util.LruCache
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
@@ -28,15 +32,19 @@ 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.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -52,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.model.User
@@ -63,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview
import com.vitorpamplona.amethyst.ui.note.lastSeenSentence
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -71,13 +81,18 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserApp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
@@ -87,6 +102,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private const val IDENTITY_ICON_CACHE_KEY = 0
@@ -218,6 +234,8 @@ fun DrawAdditionalInfo(
}
DisplayLNAddress(lud16, baseUser, accountViewModel, nav)
DisplayClinkOffer(user, accountViewModel)
DisplayPaymentTargets(baseUser, accountViewModel)
val website = user.info.website
@@ -377,3 +395,113 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int =
is GitHubIdentity -> R.string.github
else -> R.string.github
}
/**
* Process-wide cache of NIP-05 `.well-known` `clink_offer` lookups, keyed by the
* lowercased nip05 address (NIP-05 identifiers are case-insensitive). Without it, every
* profile visit (and every relay-pushed kind-0 refresh while a profile is open) would
* re-fetch the domain's nostr.json. Caches "no offer" results too so profiles without one
* aren't re-hit. A [ResolvedClinkOffer] wrapper holds the nullable parsed pointer
* (LruCache can't store nulls); absence means "not fetched yet".
*/
private class ResolvedClinkOffer(
val noffer: NOffer?,
)
private val clinkOfferNip05Cache = LruCache<String, ResolvedClinkOffer>(256)
/**
* Shows a profile's advertised CLINK Offer as a compact, tappable chip (preferring the kind-0
* `clink_offer` field, falling back to the NIP-05 `.well-known` `clink_offer`, cached). Tapping
* the chip expands the payable [ClinkOfferPreview] card — collapsed by default so the full card
* isn't shown until the user opts in.
*/
@Composable
private fun DisplayClinkOffer(
userInfo: UserInfo,
accountViewModel: AccountViewModel,
) {
val kind0Offer =
remember(userInfo) {
userInfo.info.clinkOffer()?.let { ClinkPointerParser.parse(it) as? NOffer }
}
var offer by remember(userInfo) { mutableStateOf(kind0Offer) }
val nip05 = userInfo.info.nip05
LaunchedEffect(kind0Offer, nip05) {
if (kind0Offer != null) {
offer = kind0Offer
return@LaunchedEffect
}
// Fall back to the NIP-05 .well-known clink_offer (cached per address).
val id = nip05?.let { Nip05Id.parse(it) }
offer =
if (id != null && nip05 != null) {
// Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch.
val cacheKey = nip05.lowercase()
val cached = clinkOfferNip05Cache.get(cacheKey)
if (cached != null) {
cached.noffer
} else {
val fetched = withContext(Dispatchers.IO) { accountViewModel.nip05ClientBuilder().loadClinkOffer(id) }
val parsed = fetched?.let { ClinkPointerParser.parse(it) as? NOffer }
clinkOfferNip05Cache.put(cacheKey, ResolvedClinkOffer(parsed))
parsed
}
} else {
null
}
}
offer?.let { resolved ->
var expanded by remember(resolved) { mutableStateOf(false) }
Column {
ClinkOfferChip(expanded) { expanded = !expanded }
if (expanded) {
ClinkOfferPreview(resolved, accountViewModel)
}
}
}
}
/**
* Compact, payment-target-style chip for a profile's CLINK Offer. Tapping it toggles the
* payable [ClinkOfferPreview] card open/closed; collapsed by default so the profile mirrors the
* other payment-target chips instead of showing the full card up front.
*/
@Composable
private fun ClinkOfferChip(
expanded: Boolean,
onClick: () -> Unit,
) {
val label = stringRes(R.string.clink_lightning_offer)
Surface(
shape = RoundedCornerShape(50),
color = BitcoinOrange.copy(alpha = 0.10f),
border = BorderStroke(1.dp, BitcoinOrange.copy(alpha = if (expanded) 0.6f else 0.35f)),
modifier =
Modifier
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = label,
tint = BitcoinOrange,
modifier = Size16Modifier,
)
Text(
text = label,
color = BitcoinOrange,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT 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.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.util.getText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import kotlinx.coroutines.launch
/**
* Adds a CLINK Debits pointer (`ndebit1…`) as a spend-only payment source. Unlike NWC
* there is no secret to paste — authorization is the account's own identity, pre-approved
* on the wallet service — so this screen only collects a name and the pointer.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddClinkDebitWalletScreen(
accountViewModel: AccountViewModel,
nav: INav,
ndebit: String? = null,
) {
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
var walletName by remember { mutableStateOf("") }
var ndebitUri by remember { mutableStateOf(ndebit.orEmpty()) }
var error by remember { mutableStateOf<String?>(null) }
var qrScanning by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_add_clink_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.imePadding()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = walletName,
onValueChange = { walletName = it },
label = { Text(stringRes(R.string.wallet_name)) },
placeholder = { Text(stringRes(R.string.wallet_name_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
// Paste from clipboard
IconButton(
onClick = {
scope.launch {
val clipText = clipboardManager.getText()
if (clipText != null) {
ndebitUri = clipText
error = null
}
}
},
) {
Icon(
symbol = MaterialSymbols.ContentPaste,
contentDescription = stringRes(id = R.string.paste_from_clipboard),
modifier = Size24Modifier,
tint = MaterialTheme.colorScheme.primary,
)
}
// QR code scanner
IconButton(onClick = { qrScanning = true }) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 3),
contentDescription = stringRes(id = R.string.accessibility_scan_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
ndebitUri = it
error = null
}
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = ndebitUri,
onValueChange = {
ndebitUri = it
error = null
},
label = { Text(stringRes(R.string.wallet_paste_ndebit)) },
placeholder = { Text("ndebit1...") },
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(24.dp))
val invalidMessage = stringRes(R.string.wallet_add_clink_invalid)
Button(
onClick = {
if (walletViewModel.addClinkDebitWallet(walletName.trim(), ndebitUri.trim())) {
nav.popBack()
} else {
error = invalidMessage
}
},
enabled = ndebitUri.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.wallet_save))
}
}
}
}
@@ -105,6 +105,12 @@ fun AddWalletScreen(
description = stringRes(R.string.wallet_add_cashu_description),
onClick = { nav.popUpTo(Route.WalletAddCashu, Route.WalletAdd::class) },
)
WalletTypeCard(
icon = MaterialSymbols.Bolt,
title = stringRes(R.string.wallet_add_clink_title),
description = stringRes(R.string.wallet_add_clink_description),
onClick = { nav.popUpTo(Route.WalletAddClinkDebit(), Route.WalletAdd::class) },
)
}
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
/**
* Asks the user for a CLINK debit spending budget: an amount and a cadence (one-time, or
* recurring per day/week/month). [onConfirm] receives the amount in sats and the chosen
* [DebitFrequency] (null for one-time).
*/
@Composable
fun ClinkBudgetDialog(
onConfirm: (amountSats: Long, frequency: DebitFrequency?) -> Unit,
onDismiss: () -> Unit,
) {
var amount by remember { mutableStateOf("") }
var cadence by remember { mutableStateOf(BudgetCadence.ONE_TIME) }
val parsedAmount = amount.toLongOrNull() ?: 0L
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.clink_budget_title)) },
text = {
Column {
OutlinedTextField(
value = amount,
onValueChange = { new -> amount = new.filter(Char::isDigit) },
label = { Text(stringRes(R.string.clink_budget_amount_sats)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
BudgetCadence.entries.forEach { option ->
CadenceRow(
option = option,
selected = cadence == option,
onSelect = { cadence = option },
)
}
}
},
confirmButton = {
Button(
enabled = parsedAmount > 0,
onClick = { onConfirm(parsedAmount, cadence.toFrequency()) },
) {
Text(stringRes(R.string.clink_budget_request))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@Composable
private fun CadenceRow(
option: BudgetCadence,
selected: Boolean,
onSelect: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.height(44.dp)
.selectable(selected = selected, onClick = onSelect),
) {
RadioButton(selected = selected, onClick = onSelect)
Text(stringRes(option.labelRes))
}
}
private enum class BudgetCadence(
val labelRes: Int,
) {
ONE_TIME(R.string.clink_budget_one_time),
DAILY(R.string.clink_budget_daily),
WEEKLY(R.string.clink_budget_weekly),
MONTHLY(R.string.clink_budget_monthly),
;
fun toFrequency(): DebitFrequency? =
when (this) {
ONE_TIME -> null
DAILY -> DebitFrequency(1, DebitFrequency.UNIT_DAY)
WEEKLY -> DebitFrequency(1, DebitFrequency.UNIT_WEEK)
MONTHLY -> DebitFrequency(1, DebitFrequency.UNIT_MONTH)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -66,6 +67,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -82,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
import kotlinx.coroutines.launch
import java.text.NumberFormat
import androidx.compose.material3.Icon as Material3Icon
@@ -214,6 +217,7 @@ private fun MultiWalletHomeContent(
cashuMintCount: Int,
) {
val walletInfoList by walletViewModel.walletInfoList.collectAsState()
val context = LocalContext.current
LaunchedEffect(Unit) {
walletViewModel.fetchAllBalances()
@@ -234,8 +238,11 @@ private fun MultiWalletHomeContent(
WalletCard(
walletInfo = walletInfo,
onSelect = {
walletViewModel.selectWallet(walletInfo.walletId)
nav.nav(Route.WalletDetail(walletInfo.walletId))
// The detail screen is NWC-only (balance/transactions); debits have neither.
if (walletInfo.canShowBalance) {
walletViewModel.selectWallet(walletInfo.walletId)
nav.nav(Route.WalletDetail(walletInfo.walletId))
}
},
onSetDefault = {
walletViewModel.setDefaultWallet(walletInfo.walletId)
@@ -246,6 +253,24 @@ private fun MultiWalletHomeContent(
onRemove = {
walletViewModel.removeWallet(walletInfo.walletId)
},
// Spending-budget authorization is a CLINK-debit-only capability.
onSetBudget =
if (!walletInfo.canShowBalance) {
{ amount, frequency ->
walletViewModel.requestDebitBudget(walletInfo.walletId, amount, frequency) { response ->
val error = response?.failureDetail()
val msg =
when {
response?.isOk() == true -> context.getString(R.string.clink_budget_approved)
!error.isNullOrBlank() -> error
else -> context.getString(R.string.clink_debit_no_response)
}
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
}
}
} else {
null
},
)
}
@@ -285,9 +310,21 @@ private fun WalletCard(
onSetDefault: () -> Unit,
onRename: (String) -> Unit,
onRemove: () -> Unit,
onSetBudget: ((Long, DebitFrequency?) -> Unit)? = null,
) {
var showRemoveDialog by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showBudgetDialog by remember { mutableStateOf(false) }
if (showBudgetDialog && onSetBudget != null) {
ClinkBudgetDialog(
onConfirm = { amount, frequency ->
showBudgetDialog = false
onSetBudget(amount, frequency)
},
onDismiss = { showBudgetDialog = false },
)
}
if (showRemoveDialog) {
AlertDialog(
@@ -325,7 +362,7 @@ private fun WalletCard(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onSelect),
.clickable(enabled = walletInfo.canShowBalance, onClick = onSelect),
shape = RoundedCornerShape(16.dp),
border =
if (walletInfo.isDefault) {
@@ -377,8 +414,14 @@ private fun WalletCard(
}
}
// Balance
if (walletInfo.isLoading && walletInfo.balanceSats == null) {
// Balance — debits are spend-only, so show a capability badge instead.
if (!walletInfo.canShowBalance) {
Text(
text = stringRes(R.string.clink_debit_pay_only),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (walletInfo.isLoading && walletInfo.balanceSats == null) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Column(horizontalAlignment = Alignment.End) {
@@ -440,6 +483,16 @@ private fun WalletCard(
Text(stringRes(R.string.wallet_rename), style = MaterialTheme.typography.bodySmall)
}
if (onSetBudget != null) {
OutlinedButton(
onClick = { showBudgetDialog = true },
modifier = Modifier.height(36.dp),
shape = RoundedCornerShape(8.dp),
) {
Text(stringRes(R.string.clink_budget_set), style = MaterialTheme.typography.bodySmall)
}
}
Spacer(modifier = Modifier.weight(1f))
IconButton(
@@ -22,9 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
@@ -94,6 +100,8 @@ data class WalletInfo(
val isDefault: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
// CLINK debits are spend-only: no balance/transactions to fetch or show.
val canShowBalance: Boolean = true,
)
private const val NWC_TIMEOUT_MS = 30_000L
@@ -111,23 +119,43 @@ class WalletViewModel : ViewModel() {
private val _wallets = MutableStateFlow<List<NwcWalletEntryNorm>>(emptyList())
val wallets = _wallets.asStateFlow()
private val _debitWallets = MutableStateFlow<List<ClinkDebitWalletEntryNorm>>(emptyList())
val debitWallets = _debitWallets.asStateFlow()
private val _defaultWalletId = MutableStateFlow<String?>(null)
val defaultWalletId = _defaultWalletId.asStateFlow()
val walletInfoList =
combine(_wallets, _defaultWalletId, walletInfoMap) { wallets, defaultId, infoMap ->
wallets.map { wallet ->
val info = infoMap[wallet.id]
WalletInfo(
walletId = wallet.id,
name = wallet.name,
alias = info?.alias,
balanceSats = info?.balanceSats,
isDefault = wallet.id == defaultId || (defaultId == null && wallet == wallets.firstOrNull()),
isLoading = info?.isLoading == true,
error = info?.error,
)
}
combine(_wallets, _debitWallets, _defaultWalletId, walletInfoMap) { wallets, debits, defaultId, infoMap ->
// The unified default falls back to the first source overall (NWC before debits).
val effectiveDefault = defaultId ?: wallets.firstOrNull()?.id ?: debits.firstOrNull()?.id
val nwcRows =
wallets.map { wallet ->
val info = infoMap[wallet.id]
WalletInfo(
walletId = wallet.id,
name = wallet.name,
alias = info?.alias,
balanceSats = info?.balanceSats,
isDefault = wallet.id == effectiveDefault,
isLoading = info?.isLoading == true,
error = info?.error,
canShowBalance = true,
)
}
val debitRows =
debits.map { debit ->
WalletInfo(
walletId = debit.id,
name = debit.name,
isDefault = debit.id == effectiveDefault,
canShowBalance = false,
)
}
nwcRows + debitRows
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Selected wallet for detail view
@@ -218,8 +246,9 @@ class WalletViewModel : ViewModel() {
fun refreshWalletList() {
val acc = account ?: return
_wallets.value = acc.settings.nwcWallets.value
_defaultWalletId.value = acc.settings.defaultNwcWalletId.value
_hasWalletSetup.value = _wallets.value.isNotEmpty()
_debitWallets.value = acc.settings.clinkDebitWallets.value
_defaultWalletId.value = acc.settings.defaultPaymentSourceId.value
_hasWalletSetup.value = _wallets.value.isNotEmpty() || _debitWallets.value.isNotEmpty()
}
fun refreshWalletSetup() {
@@ -258,16 +287,70 @@ class WalletViewModel : ViewModel() {
fun setDefaultWallet(walletId: String) {
val acc = account ?: return
acc.settings.setDefaultNwcWallet(walletId)
_defaultWalletId.value = walletId
// Only reflect the change locally if it actually persisted (the id must exist
// in one of the lists); otherwise the star and the stored default would diverge.
if (acc.settings.setDefaultPaymentSource(walletId)) {
_defaultWalletId.value = walletId
}
}
fun removeWallet(walletId: String) {
val acc = account ?: return
acc.settings.removeNwcWallet(walletId)
if (_debitWallets.value.any { it.id == walletId }) {
acc.settings.removeClinkDebitWallet(walletId)
} else {
acc.settings.removeNwcWallet(walletId)
}
refreshWalletList()
}
/** Adds a CLINK debit pointer (`ndebit1…`) as a spend-only payment source. */
fun addClinkDebitWallet(
name: String,
ndebit: String,
): Boolean {
val acc = account ?: return false
val pointer = ClinkPointerParser.parse(ndebit.trim()) as? NDebit ?: return false
val entry =
ClinkDebitWalletEntryNorm(
id =
java.util.UUID
.randomUUID()
.toString(),
name = name.ifBlank { "Debit" },
pointer = pointer,
)
acc.settings.addClinkDebitWallet(entry)
refreshWalletList()
return true
}
/**
* Asks a CLINK debit wallet to authorize a spending budget (kind-21002). Omit
* [frequency] for a one-time budget; otherwise it recurs every day/week/month.
* [onResult] reports the wallet's decision (ok, GFY error text, or null on timeout).
*/
fun requestDebitBudget(
walletId: String,
amountSats: Long,
frequency: DebitFrequency?,
onResult: (DebitResponse?) -> Unit,
) {
val acc = account ?: return
val pointer = _debitWallets.value.firstOrNull { it.id == walletId }?.pointer ?: return
viewModelScope.launch {
// A malformed budget (e.g. an out-of-spec frequency unit) makes requestBudget throw;
// treat it as "no response" so the dialog dismisses instead of hanging on a spinner.
val response =
try {
ClinkDebitPayer.requestBudget(acc, pointer, amountSats, frequency)
} catch (_: IllegalArgumentException) {
null
}
onResult(response)
}
}
fun addWallet(
name: String,
uri: Nip47WalletConnect.Nip47URINorm,
@@ -292,7 +375,11 @@ class WalletViewModel : ViewModel() {
newName: String,
) {
val acc = account ?: return
acc.settings.renameNwcWallet(walletId, newName)
if (_debitWallets.value.any { it.id == walletId }) {
acc.settings.renameClinkDebitWallet(walletId, newName)
} else {
acc.settings.renameNwcWallet(walletId, newName)
}
refreshWalletList()
}
@@ -207,6 +207,7 @@
<string name="voice_anonymize_description">Upravuje výšku vašeho hlasu. Poznámka: základní změny výšky hlasu mohou být odhodlanými posluchači potenciálně zpětně rozpoznány.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel nemá nastavenou LN adresu pro přijímání sats</string>
<string name="reply_here">"Odpověď zde…"</string>
<string name="user_suggestion_in_this_chat">V tomto chatu</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Zkopíruje ID poznámky do schránky pro sdílení</string>
<string name="copy_channel_id_note_to_the_clipboard">Zkopírovat ID kanálu (poznámka) do schránky</string>
<string name="edits_the_channel_metadata">Upravit metadata kanálu</string>
@@ -267,8 +268,13 @@
<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>
<string name="chats_history_proto_nip17">šifrované</string>
<string name="chats_history_proto_nip04">zastaralé</string>
<string name="chats_reply_searching_history">Hledání původní zprávy…</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<string name="chats_reply_not_found">Tuto zprávu se nepodařilo najít</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Prohledány všechny relaye · klepnutím zobrazíte</string>
<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>
@@ -205,6 +205,7 @@ erie gespeichert</string>
<string name="voice_anonymize_description">Verändert die Tonhöhe deiner Stimme. Hinweis: einfache Tonhöhenänderungen können von entschlossenen Zuhörern möglicherweise rückgängig gemacht werden.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
<string name="reply_here">"Hier antworten…"</string>
<string name="user_suggestion_in_this_chat">In diesem Chat</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopiert die Notiz-ID zum Teilen in die Zwischenablage</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopiere Kanal-ID (Notiz) in die Zwischenablage</string>
<string name="edits_the_channel_metadata">Bearbeitet die Kanalmetadaten</string>
@@ -267,8 +268,13 @@ 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>
<string name="chats_history_proto_nip17">verschlüsselt</string>
<string name="chats_history_proto_nip04">veraltet</string>
<string name="chats_reply_searching_history">Suche nach der ursprünglichen Nachricht…</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<string name="chats_reply_not_found">Diese Nachricht konnte nicht gefunden werden</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Alle Relays durchsucht · zum Anzeigen tippen</string>
<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>
@@ -267,8 +267,13 @@
<string name="generate_a_new_key">Wygeneruj nowy klucz</string>
<string name="loading_feed">Wczytywanie zawartości</string>
<string name="loading_account">Ładowanie konta</string>
<string name="chats_history_proto_nip17">zaszyfrowane</string>
<string name="chats_history_proto_nip04">starsza wersja</string>
<string name="chats_reply_searching_history">Szukam oryginalnej wiadomości…</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<string name="chats_reply_not_found">Nie można znaleźć tej wiadomości</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Przeszukano wszystkie transmitery · kliknij, by wyświetlić</string>
<string name="error_loading_replies">"Błąd wczytywania odpowiedzi: "</string>
<string name="try_again">Spróbuj ponownie</string>
<string name="notification_feed_is_empty">Brak powiadomień.</string>
@@ -2104,6 +2109,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="video_player_settings_action_pip_description">Odtwórz wideo w pływającym oknie (opcja ukryta, jeśli nie jest obsługiwana)</string>
<string name="video_player_settings_action_cast">Przenieś na urządzenie</string>
<string name="video_player_settings_action_cast_description">Prześlij film do urządzenia Chromecast podłączonego do sieci Wi-Fi (opcja ukryta w przypadku plików lokalnych)</string>
<string name="audio_visualizer_settings">Wizualizator dźwięku</string>
<string name="audio_visualizer_settings_description">Wybierz animację wyświetlaną podczas odtwarzania plików audio.</string>
<string name="audio_visualizer_off">Wyłączone</string>
<string name="audio_visualizer_bars">Spektrogram</string>
<string name="audio_visualizer_waves">Barwa fali</string>
<string name="audio_visualizer_radial">Pierścień promieniowy</string>
<string name="audio_visualizer_aurora">Aurora Glow</string>
<string name="audio_visualizer_classic">Fala klasyczna</string>
<string name="audio_visualizer_static">Obraz statyczny</string>
<string name="profile_image_of_user">Zdjęcie profilowe %1$s</string>
<string name="relay_info">Transmiter %1$s</string>
<string name="expand_relay_list">Rozwiń listę transmiterów</string>
@@ -2857,6 +2871,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="goal_progress">Sfinansowano %1$s z %2$s satoszów</string>
<string name="fundraiser_ends">Kończy się %1$s</string>
<string name="fundraiser_onchain_donation">Darowizna on-chain</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d gatunek</item>
<item quantity="few">Birdex · %1$d gatunków</item>
<item quantity="many">Birdex · %1$d gatunków</item>
<item quantity="other">Birdex · %1$d gatunki</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="one">%1$s +%2$d więcej</item>
<item quantity="few">%1$s +%2$d więcej</item>
<item quantity="many">%1$s +%2$d więcej</item>
<item quantity="other">%1$s +%2$d więcej</item>
</plurals>
<string name="goal_amount_label">Kwota zbiorki (w satoszach)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Opisz cel zbiórki</string>
@@ -203,6 +203,7 @@
<string name="voice_anonymize_description">Altera o tom da sua voz. Nota: alterações básicas de tom podem potencialmente ser revertidas por ouvintes determinados.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string>
<string name="reply_here">"responda aqui.. "</string>
<string name="user_suggestion_in_this_chat">Neste chat</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copia o ID do canal (note) para compartilhar</string>
<string name="copy_channel_id_note_to_the_clipboard">Copiar ID do canal (Note)</string>
<string name="edits_the_channel_metadata">Editar os dados do canal</string>
@@ -263,8 +264,13 @@
<string name="generate_a_new_key">Gerar uma nova chave</string>
<string name="loading_feed">Carregando feed</string>
<string name="loading_account">Carregando conta</string>
<string name="chats_history_proto_nip17">criptografado</string>
<string name="chats_history_proto_nip04">legado</string>
<string name="chats_reply_searching_history">Procurando a mensagem original…</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<string name="chats_reply_not_found">Não foi possível encontrar esta mensagem</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Pesquisado em todos os relays · toque para ver</string>
<string name="error_loading_replies">"Erro ao carregar respostas"</string>
<string name="try_again">Tente novamente</string>
<string name="notification_feed_is_empty">Ainda não há notificações.</string>
@@ -203,6 +203,7 @@
<string name="voice_anonymize_description">Ändrar tonhöjden på din röst. Obs: enkla förändringar av tonhöjd kan potentiellt återskapas av målmedvetna lyssnare.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string>
<string name="reply_here">"svara här.. "</string>
<string name="user_suggestion_in_this_chat">I den här chatten</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopierar antecknings-ID till urklipp för delning</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopiera kanal-ID (anteckningen) till Urklipp</string>
<string name="edits_the_channel_metadata">Redigerar kanalmetadata</string>
@@ -263,8 +264,13 @@
<string name="generate_a_new_key">Skapa en ny nyckel</string>
<string name="loading_feed">Ladda flöde</string>
<string name="loading_account">Laddar kontot</string>
<string name="chats_history_proto_nip17">krypterat</string>
<string name="chats_history_proto_nip04">föråldrat</string>
<string name="chats_reply_searching_history">Letar efter ursprungsmeddelandet…</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<string name="chats_reply_not_found">Kunde inte hitta detta meddelande</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Sökte på alla relayer · tryck för att visa</string>
<string name="error_loading_replies">"Det gick inte att läsa in svar: "</string>
<string name="try_again">Försök igen</string>
<string name="notification_feed_is_empty">Inga aviseringar ännu.</string>
@@ -201,6 +201,7 @@
<string name="voice_anonymize_description">更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
<string name="reply_here">"🔏在此回复… "</string>
<string name="user_suggestion_in_this_chat">在此聊天中</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板以便于在 Nostr 中分享</string>
<string name="copy_channel_id_note_to_the_clipboard">复制频道ID(笔记)到剪贴板</string>
<string name="edits_the_channel_metadata">修改频道元数据</string>
+25
View File
@@ -112,6 +112,30 @@
<string name="log_out">Logout</string>
<string name="show_more">Show More</string>
<string name="lightning_invoice">Lightning Invoice</string>
<string name="clink_lightning_offer">CLINK Offer</string>
<string name="clink_requesting_invoice">Requesting invoice…</string>
<string name="clink_debit_no_response">The debit service did not complete the payment.</string>
<string name="clink_confirm_payment_title">Confirm payment</string>
<string name="clink_confirm_pay_amount_via_source">Pay %1$s via %2$s?</string>
<string name="clink_confirm_pay_via_source">Pay this invoice via %1$s?</string>
<string name="clink_offer_amount_sats">Amount (sats)</string>
<string name="clink_offer_invalid_amount">Enter a valid amount for this offer.</string>
<string name="clink_offer_amount_range">Allowed range: %1$s%2$s sats</string>
<string name="clink_offer_label">CLINK Offer (noffer)</string>
<string name="clink_budget_set">Budget</string>
<string name="clink_budget_title">Spending budget</string>
<string name="clink_budget_amount_sats">Amount (sats)</string>
<string name="clink_budget_request">Request</string>
<string name="clink_budget_approved">Budget approved</string>
<string name="clink_budget_one_time">One-time</string>
<string name="clink_budget_daily">Daily</string>
<string name="clink_budget_weekly">Weekly</string>
<string name="clink_budget_monthly">Monthly</string>
<string name="clink_debit_pay_only">Pay only</string>
<string name="wallet_add_clink_title">CLINK Debit</string>
<string name="wallet_add_clink_description">Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history.</string>
<string name="wallet_add_clink_invalid">Invalid CLINK debit pointer. Expected an ndebit1… string.</string>
<string name="wallet_paste_ndebit">Paste ndebit pointer</string>
<string name="pay">Pay</string>
<string name="lightning_tips">Lightning Tips</string>
<string name="note_to_receiver">Note to Receiver</string>
@@ -214,6 +238,7 @@
<string name="voice_anonymize_description">Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string>
<string name="reply_here">"reply here… "</string>
<string name="user_suggestion_in_this_chat">In this chat</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing in Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Copy Channel ID (Note) to the Clipboard</string>
<string name="edits_the_channel_metadata">Edits the Channel Metadata</string>
@@ -31,9 +31,12 @@ import com.vitorpamplona.amethyst.commons.actions.FollowActions
import com.vitorpamplona.amethyst.commons.actions.SearchActions
import com.vitorpamplona.amethyst.commons.actions.ZapActions
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -1408,7 +1411,7 @@ class AmethystAppFunctions {
// "I zapped Alice 21 sats" instead of "here's a BOLT11 invoice
// for you to paste somewhere." Falls back to manual when NWC
// isn't set up or the wallet declines.
val nwc = payViaNwcOrNull(account, invoice, null)
val nwc = payViaDefaultSourceOrNull(account, invoice, null)
return ZapResult(
chain = "lightning",
@@ -1633,7 +1636,7 @@ class AmethystAppFunctions {
// Try NWC for every invoice that came back. Failed splits
// stay as a manual invoice with nwcError set — the others
// still go through.
val nwc = invoice?.let { payViaNwcOrNull(account, it, note) }
val nwc = invoice?.let { payViaDefaultSourceOrNull(account, it, note) }
ZapInvoice(
recipientNpub = req.recipient.pubkey?.let { NPub.create(it) },
recipientPubkeyHex = req.recipient.pubkey,
@@ -1694,42 +1697,65 @@ class AmethystAppFunctions {
?.lnAddress()
}
/** Internal result of [payViaNwcOrNull]. */
private data class NwcOutcome(
/** Internal result of [payViaDefaultSourceOrNull]. */
private data class PayOutcome(
val success: Boolean,
val preimage: String?,
val errorMessage: String?,
)
/**
* Try to pay [bolt11] through the active account's Nostr Wallet
* Connect setup. Returns null when no NWC wallet is configured
* caller should fall back to surfacing the invoice for manual
* payment. Returns an outcome with [NwcOutcome.success] = true on
* a wallet-confirmed payment, false (with [NwcOutcome.errorMessage]
* set) on rejection or timeout.
*
* The wallet's response can take a few seconds; bounded by
* [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall the
* dispatch.
* Try to pay [bolt11] through the account's selected default payment source an NWC
* wallet or a CLINK debit. Returns null when no in-app source is configured (caller
* should fall back to surfacing the invoice for manual payment), otherwise an outcome
* with [PayOutcome.success] = true on a wallet-confirmed payment, or false (with
* [PayOutcome.errorMessage]) on rejection or timeout.
*/
private suspend fun payViaNwcOrNull(
private suspend fun payViaDefaultSourceOrNull(
account: com.vitorpamplona.amethyst.model.Account,
bolt11: String,
zappedNote: com.vitorpamplona.amethyst.model.Note?,
): NwcOutcome? {
if (!account.nip47SignerState.hasWalletConnectSetup()) return null
): PayOutcome? =
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.Nwc -> payViaNwc(account, bolt11, zappedNote)
is PaymentSource.ClinkDebit -> payViaClinkDebit(account, source.wallet.pointer, bolt11)
null -> null
}
/** Pays [bolt11] via a CLINK debit pointer, mapping the kind-21002 reply to a [PayOutcome]. */
private suspend fun payViaClinkDebit(
account: com.vitorpamplona.amethyst.model.Account,
pointer: NDebit,
bolt11: String,
): PayOutcome {
val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11)
return when {
response == null ->
PayOutcome(false, null, "CLINK debit wallet didn't respond within ${ClinkDebitPayer.DEFAULT_TIMEOUT_MS / 1000}s")
response.isOk() -> PayOutcome(true, response.preimage, null)
else ->
PayOutcome(false, null, response.error?.takeIf { it.isNotBlank() } ?: "debit declined (code ${response.code})")
}
}
/**
* Pays [bolt11] via the default NWC wallet. The wallet's response can take a few
* seconds; bounded by [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall dispatch.
*/
private suspend fun payViaNwc(
account: com.vitorpamplona.amethyst.model.Account,
bolt11: String,
zappedNote: com.vitorpamplona.amethyst.model.Note?,
): PayOutcome {
val deferred = CompletableDeferred<Response?>()
// sendZapPaymentRequestFor fires onResponse exactly once when
// the wallet replies (success, error, or NwcError). On timeout
// we discard the late response.
// sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies
// (success, error, or NwcError). On timeout we discard the late response.
account.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
if (!deferred.isCompleted) deferred.complete(response)
}
val response =
withTimeoutOrNull(NWC_PAYMENT_TIMEOUT_MS) { deferred.await() }
?: return NwcOutcome(
?: return PayOutcome(
success = false,
preimage = null,
errorMessage =
@@ -1739,35 +1765,13 @@ class AmethystAppFunctions {
return when (response) {
is PayInvoiceSuccessResponse ->
NwcOutcome(
success = true,
preimage = response.result?.preimage,
errorMessage = null,
)
PayOutcome(true, response.result?.preimage, null)
is PayInvoiceErrorResponse ->
NwcOutcome(
success = false,
preimage = null,
errorMessage =
response.error?.message
?: response.error?.code?.name
?: "wallet returned an unspecified pay_invoice error",
)
PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an unspecified pay_invoice error")
is NwcErrorResponse ->
NwcOutcome(
success = false,
preimage = null,
errorMessage =
response.error?.message
?: response.error?.code?.name
?: "wallet returned an NWC error",
)
PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an NWC error")
else ->
NwcOutcome(
success = false,
preimage = null,
errorMessage = "Unexpected NWC response type: ${response::class.simpleName}",
)
PayOutcome(false, null, "Unexpected NWC response type: ${response::class.simpleName}")
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.rankPriorityFirst
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/**
* Locks in the @-mention priority semantics: priority pubkeys only move
* users that already matched the search to the top of the list they
* never inject new entries, never remove any, and never disturb the
* search's relevance order within the priority / non-priority groups.
*/
class UserSuggestionPriorityRankingTest {
// User eagerly pins a few addressable note shells on construction;
// empty shells are enough since the ranking never reads them.
private val noContext = UserContext { addr -> AddressableNote(addr) }
private fun user(hex: String) = User(hex, noContext)
private val alice = user("aa".repeat(32))
private val bob = user("bb".repeat(32))
private val carol = user("cc".repeat(32))
private val dave = user("dd".repeat(32))
@Test
fun emptyPriorityKeepsTheListUntouched() {
val found = listOf(alice, bob, carol)
assertSame(found, rankPriorityFirst(found, emptySet()))
}
@Test
fun priorityUsersMoveToTheTop() {
val found = listOf(alice, bob, carol, dave)
val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex))
assertEquals(listOf(carol, alice, bob, dave), ranked)
}
@Test
fun relativeOrderIsPreservedWithinBothGroups() {
// findUsersStartingWith returns relevance order; the stable sort
// must keep alice-before-carol (priority) and bob-before-dave (rest).
val found = listOf(alice, bob, carol, dave)
val ranked = rankPriorityFirst(found, setOf(alice.pubkeyHex, carol.pubkeyHex))
assertEquals(listOf(alice, carol, bob, dave), ranked)
}
@Test
fun priorityKeysThatDidNotMatchTheSearchAreNotInjected() {
val found = listOf(alice, bob)
val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex, dave.pubkeyHex))
assertEquals(found, ranked)
}
}
+15
View File
@@ -244,6 +244,21 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
### CLINK Offers
| Command | What it does |
|---|---|
| `amy offer info NOFFER` | Decode a `noffer1…` pointer (pubkey, relays, price type/amount). Local, no network. |
| `amy offer request NOFFER [--amount SATS] [--timeout MS]` | kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. `--amount` is required for spontaneous offers; fixed offers default to the pointer's price. |
### CLINK Debits
| Command | What it does |
|---|---|
| `amy debit info NDEBIT` | Decode an `ndebit1…` pointer (pubkey, relays, pointer id, session flag). Local, no network. |
| `amy debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]` | kind:21002 round-trip: ask the pointed-to wallet to pay the invoice; print the preimage or the service's GFY error. |
| `amy debit budget NDEBIT --amount SATS [--frequency day\|week\|month] [--timeout MS]` | Authorize a spending budget; omit `--frequency` for a one-time budget. |
### Wait-for-condition (`await`)
Every `await` verb blocks until the condition holds, then prints the
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.selects.select
@@ -310,6 +311,43 @@ class Context(
return collected
}
/**
* Publish [request] to [relays], then wait for the FIRST event matching [responseFilter]
* a live reply that arrives after our own EOSE, which [drain] would miss (it returns at
* EOSE). Verifies and stores the reply. Returns it, or null on timeout; always tears the
* subscription down. Used for request/response round-trips (e.g. a CLINK offer invoice).
*/
suspend fun requestResponse(
request: Event,
relays: Set<NormalizedRelayUrl>,
responseFilter: Filter,
timeoutMs: Long = 15_000,
): Event? {
if (relays.isEmpty()) return null
val reply = CompletableDeferred<Event>()
val subId = newSubId()
val filters = relays.associateWith { listOf(responseFilter) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (!reply.isCompleted) reply.complete(event)
}
}
client.subscribe(subId, filters, listener)
return try {
publish(request, relays)
val event = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null
if (verifyAndStore(event)) event else null
} finally {
client.unsubscribe(subId)
}
}
/**
* Verify [event]'s NIP-01 id+signature and, if valid, persist it
* to [store]. Returns `true` when the event was accepted (and
@@ -182,6 +182,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
Commands.zap(dataDir, tail)
}
"offer" -> {
Commands.offer(dataDir, tail)
}
"debit" -> {
Commands.debit(dataDir, tail)
}
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -363,6 +371,19 @@ private fun printUsage() {
| [--comment X] [--anon|--private] event (must be in local store)
| [--timeout SECS]
|
|CLINK Offers:
| offer info NOFFER decode a noffer1 pointer (local, no network)
| offer request NOFFER [--amount SATS] kind:21001 round-trip: ask the service for a
| [--timeout MS] fresh BOLT11 (amount required for spontaneous
| offers; defaults to the pointer's fixed price)
|
|CLINK Debits:
| debit info NDEBIT decode an ndebit1 pointer (local, no network)
| debit pay NDEBIT BOLT11 [--amount SATS] kind:21002 round-trip: ask the wallet to pay the
| [--timeout MS] invoice; prints the preimage or a GFY error
| debit budget NDEBIT --amount SATS authorize a spending budget; omit --frequency
| [--frequency day|week|month] [--timeout MS] for a one-time budget
|
|Search (NIP-50):
| search user QUERY [--limit N] search kind:0 profiles
| [--timeout SECS]
@@ -63,11 +63,14 @@ object Output {
fun error(
code: String,
detail: String? = null,
extra: Map<String, Any?> = emptyMap(),
): Int {
val cleanExtra = extra.filterValues { it != null }
when (mode) {
Mode.JSON -> {
val payload = mutableMapOf<String, Any>("error" to code)
val payload = mutableMapOf<String, Any?>("error" to code)
if (detail != null) payload["detail"] = detail
payload.putAll(cleanExtra)
System.err.println(mapper.writeValueAsString(payload))
}
@@ -75,7 +78,9 @@ object Output {
val color = Ansi.forStream(isStderr = true)
val prefix = color.bold(color.red("error"))
val codePart = color.yellow(code)
System.err.println(if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart")
val base = if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart"
val suffix = if (cleanExtra.isEmpty()) "" else cleanExtra.entries.joinToString(", ", " (", ")") { "${it.key}=${it.value}" }
System.err.println(base + suffix)
}
}
return 1
@@ -115,4 +115,14 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = ZapCommand.dispatch(dataDir, tail)
suspend fun offer(
dataDir: DataDir,
tail: Array<String>,
): Int = OfferCommands.dispatch(dataDir, tail)
suspend fun debit(
dataDir: DataDir,
tail: Array<String>,
): Int = DebitCommands.dispatch(dataDir, tail)
}
@@ -0,0 +1,218 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.experimental.clink.client.DebitClient
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
/**
* `amy debit ` CLINK Debits (`ndebit1`) from the command line, for headless interop
* testing against a real debit service (e.g. a Lightning.Pub that pre-authorized this
* account's npub).
*
* - `info <ndebit>` decodes a pointer locally (no network).
* - `pay <ndebit> <bolt11> [--amount SATS]` runs the kind-21002 pay round-trip.
* - `budget <ndebit> --amount SATS [--frequency day|week|month]` authorizes a budget.
*
* Thin assembly only: pointer decode + the request/response events live in `quartz`
* (`ClinkPointerParser`, `DebitClient`); the round-trip uses `Context.requestResponse`.
*/
object DebitCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "debit <info|pay|budget>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"info" -> info(rest)
"pay" -> pay(dataDir, rest)
"budget" -> budget(dataDir, rest)
else -> Output.error("bad_args", "debit ${tail[0]} (expected info|pay|budget)")
}
}
/** Local decode of an `ndebit` pointer — no network, no account needed. */
private fun info(rest: Array<String>): Int {
val args = Args(rest)
val debit =
ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit
?: return Output.error("bad_args", "not a valid ndebit pointer")
Output.emit(
mapOf(
"pubkey" to debit.pubKey,
"relays" to debit.relays.map { it.url },
"pointer" to debit.pointer,
"session" to debit.isSession,
),
)
return 0
}
/** Ask the wallet to pay [bolt11] (kind-21002 round-trip). */
private suspend fun pay(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val bolt11 = args.positional(1, "bolt11")
val amount = args.flag("amount")?.toLongOrNull()
val timeoutMs = args.longFlag("timeout", 15_000)
return roundTrip(dataDir, args, timeoutMs) { client -> client.payInvoice(bolt11, amount) }
}
/** Ask the wallet to authorize a spending budget; omit --frequency for a one-time budget. */
private suspend fun budget(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val amount =
args.flag("amount")?.toLongOrNull()
?: return Output.error("bad_args", "--amount SATS is required for a budget")
val frequency = parseFrequency(args.flag("frequency")) ?: return Output.error("bad_args", "unknown --frequency '${args.flag("frequency")}' (day|week|month)")
val timeoutMs = args.longFlag("timeout", 15_000)
return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency.value) }
}
/**
* Shared 21002 round-trip: decode the pointer (positional 0), build the request via
* [buildRequest], publish, await the reply, and emit the preimage or GFY error.
*/
private suspend fun roundTrip(
dataDir: DataDir,
args: Args,
timeoutMs: Long,
buildRequest: suspend (DebitClient) -> DebitEvent,
): Int {
val debit =
ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit
?: return Output.error("bad_args", "not a valid ndebit pointer")
if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
return when (val outcome = settle(ctx, debit, timeoutMs, buildRequest)) {
Settle.Timeout -> {
Output.error("timeout", "no response from the debit service within ${timeoutMs}ms")
124
}
Settle.BadReply -> Output.error("bad_response", "service reply was not a kind-21002 debit event")
is Settle.Replied -> emitDebit(outcome, debit.pubKey)
}
} finally {
ctx.close()
}
}
/** Emit a [DebitResponse] as the standard `ok`+preimage success or a structured GFY error. */
internal fun emitDebit(
outcome: Settle.Replied,
servicePubKey: String,
): Int {
val response = outcome.response
return if (response.isOk()) {
Output.emit(
mapOf(
"result" to "ok",
"preimage" to response.preimage,
"request_id" to outcome.requestId,
"service" to servicePubKey,
),
)
0
} else {
Output.error(
"debit_error",
response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}",
gfyExtra(response),
)
}
}
/** Structured GFY extras (code + any actionable range/retry_after/delta) for error output. */
internal fun gfyExtra(response: DebitResponse): Map<String, Any?> =
mapOf(
"code" to response.code,
"range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) },
"retry_after" to response.retry_after,
"delta" to response.delta?.let { mapOf("max_delta_ms" to it.max_delta_ms, "actual_delta_ms" to it.actual_delta_ms) },
)
/** Result of a single 21002 round-trip, decoupled from how it is emitted. */
internal sealed interface Settle {
data class Replied(
val requestId: String,
val response: DebitResponse,
) : Settle
data object Timeout : Settle
data object BadReply : Settle
}
/**
* Core 21002 round-trip against an already-decoded [debit] on an open [ctx]: build the
* request, publish, await the reply, decrypt. Reused by `debit pay/budget` and by
* `offer pay` (fetch invoice settle via debit).
*/
internal suspend fun settle(
ctx: Context,
debit: NDebit,
timeoutMs: Long,
buildRequest: suspend (DebitClient) -> DebitEvent,
): Settle {
val client = DebitClient(debit, ctx.signer)
val requestEvent = buildRequest(client)
val reply =
ctx.requestResponse(requestEvent, debit.relays.toSet(), client.responseFilter(requestEvent.id), timeoutMs)
?: return Settle.Timeout
val response = (reply as? DebitEvent)?.let { client.parseResponse(it) } ?: return Settle.BadReply
return Settle.Replied(requestEvent.id, response)
}
/** Parses a `--frequency` value into a one-time (null) or recurring cadence. Null = invalid. */
internal fun parseFrequency(raw: String?): Frequency? =
when (raw?.lowercase()) {
null, "once", "one-time" -> Frequency(null)
"day", "daily" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_DAY))
"week", "weekly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_WEEK))
"month", "monthly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_MONTH))
else -> null
}
/** Wrapper so a valid "one-time" budget (null cadence) is distinguishable from an invalid flag. */
internal data class Frequency(
val value: DebitFrequency?,
)
}
@@ -0,0 +1,291 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.experimental.clink.client.OfferClient
import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
/**
* `amy offer ` CLINK Offers (`noffer1`) from the command line, for headless interop
* testing against a real offer service.
*
* - `info <noffer>` decodes a pointer locally (no network).
* - `discover <nip05>` resolves a profile's advertised offer from its NIP-05 `.well-known`.
* - `request <noffer> [--amount N] [--timeout MS] [--follow]` runs the kind-21001 round-trip:
* publishes the request to the pointer's relays and prints the returned BOLT-11. With
* `--follow` it chases an "Expired or Moved" (code 3) reply to the `latest` pointer.
* - `pay <noffer> --with <ndebit> [--amount N]` fetches the invoice and settles it end-to-end
* through a CLINK debit pointer (offer round-trip debit round-trip).
*
* Thin assembly only: pointer decode + the request/response events live in `quartz`
* (`ClinkPointerParser`, `OfferClient`, `DebitClient`); the relay round-trips use
* `Context.requestResponse` (debit settlement is shared with [DebitCommands]).
*/
object OfferCommands {
private const val MAX_FOLLOW_HOPS = 3
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "offer <info|discover|request|pay>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"info" -> info(rest)
"discover" -> discover(dataDir, rest)
"request" -> request(dataDir, rest)
"pay" -> pay(dataDir, rest)
else -> Output.error("bad_args", "offer ${tail[0]} (expected info|discover|request|pay)")
}
}
/**
* Resolve a profile's advertised offer from its NIP-05 `.well-known/nostr.json` `clink_offer`
* (the app's discovery fallback). A profile's kind-0 `clink_offer` is readable via
* `amy profile show <user>`.
*/
private suspend fun discover(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val id =
Nip05Id.parse(args.positional(0, "nip05").trim())
?: return Output.error("bad_args", "not a valid NIP-05 address (e.g. bob@example.com)")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val noffer = ctx.nip05Client.loadClinkOffer(id)
if (noffer == null) {
Output.emit(mapOf("nip05" to id.toDisplayValue(), "found" to false))
return 0
}
val offer = ClinkPointerParser.parse(noffer) as? NOffer
Output.emit(
mapOf(
"nip05" to id.toDisplayValue(),
"found" to true,
"noffer" to noffer,
"pubkey" to offer?.pubKey,
"relays" to offer?.relays?.map { it.url },
"pointer" to offer?.pointer,
"price_type" to offer?.priceType?.name?.lowercase(),
"price_sats" to offer?.price,
),
)
return 0
} finally {
ctx.close()
}
}
/** Local decode of a `noffer` pointer — no network, no account needed. */
private fun info(rest: Array<String>): Int {
val args = Args(rest)
val offer =
ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer
?: return Output.error("bad_args", "not a valid noffer pointer")
Output.emit(
mapOf(
"pubkey" to offer.pubKey,
"relays" to offer.relays.map { it.url },
"pointer" to offer.pointer,
"price_type" to offer.priceType.name.lowercase(),
"price_sats" to offer.price,
),
)
return 0
}
/** Request a fresh BOLT-11 from the offer service (kind-21001 round-trip). */
private suspend fun request(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val amount = args.flag("amount")?.toLongOrNull()
val timeoutMs = args.longFlag("timeout", 15_000)
val follow = args.bool("follow")
var offer =
ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer
?: return Output.error("bad_args", "not a valid noffer pointer")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
var hops = 0
while (hops <= MAX_FOLLOW_HOPS) {
val relays = offer.relays.toSet()
if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach")
val client = OfferClient(offer, ctx.signer)
val requestEvent = client.requestInvoice(amountSats = amount)
val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs)
if (reply == null) {
Output.error("timeout", "no response from the offer service within ${timeoutMs}ms")
return 124
}
val response =
(reply as? OfferEvent)?.let { client.parseResponse(it) }
?: return Output.error("bad_response", "service reply was not a kind-21001 offer event")
if (response.isSuccess()) {
Output.emit(
mapOf(
"bolt11" to response.bolt11,
"request_id" to requestEvent.id,
"service" to offer.pubKey,
"followed_hops" to hops,
),
)
return 0
}
// "Expired or Moved" (code 3) may carry a replacement `noffer`; chase it on --follow.
val moved = response.latest?.let { ClinkPointerParser.parse(it) as? NOffer }
if (follow && response.code == OfferErrorCode.EXPIRED_OR_MOVED && moved != null) {
offer = moved
hops++
continue
}
return Output.error(
"offer_error",
response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}",
offerErrorExtra(response),
)
}
return Output.error("offer_error", "too many redirects following moved offers (>$MAX_FOLLOW_HOPS)")
} finally {
ctx.close()
}
}
/**
* Pay an offer end-to-end: fetch a fresh BOLT-11 (kind-21001) and settle it through a
* CLINK debit pointer (kind-21002). The CLI is stateless, so the funding source is given
* explicitly with `--with <ndebit>`.
*/
private suspend fun pay(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val amount = args.flag("amount")?.toLongOrNull()
val timeoutMs = args.longFlag("timeout", 15_000)
val offer =
ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer
?: return Output.error("bad_args", "not a valid noffer pointer")
val withFlag =
args.flag("with")
?: return Output.error("bad_args", "offer pay needs --with <ndebit> to settle the fetched invoice")
val debit =
ClinkPointerParser.parse(withFlag.trim()) as? NDebit
?: return Output.error("bad_args", "--with is not a valid ndebit pointer")
val offerRelays = offer.relays.toSet()
if (offerRelays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach")
if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
// 1. fetch a fresh BOLT-11 from the offer service.
val offerClient = OfferClient(offer, ctx.signer)
val offerReq = offerClient.requestInvoice(amountSats = amount)
val offerReply = ctx.requestResponse(offerReq, offerRelays, offerClient.responseFilter(offerReq.id), timeoutMs)
if (offerReply == null) {
Output.error("timeout", "no response from the offer service within ${timeoutMs}ms")
return 124
}
val offerResp =
(offerReply as? OfferEvent)?.let { offerClient.parseResponse(it) }
?: return Output.error("bad_response", "offer reply was not a kind-21001 event")
if (!offerResp.isSuccess()) {
return Output.error(
"offer_error",
offerResp.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${offerResp.code}",
offerErrorExtra(offerResp),
)
}
val bolt11 =
offerResp.bolt11
?: return Output.error("bad_response", "offer succeeded but returned no bolt11")
// 2. settle the invoice through the debit service (shared with `debit pay`).
return when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, amount) }) {
DebitCommands.Settle.Timeout -> {
Output.error("timeout", "no response from the debit service within ${timeoutMs}ms")
124
}
DebitCommands.Settle.BadReply -> Output.error("bad_response", "debit reply was not a kind-21002 event")
is DebitCommands.Settle.Replied ->
if (outcome.response.isOk()) {
Output.emit(
mapOf(
"result" to "ok",
"preimage" to outcome.response.preimage,
"bolt11" to bolt11,
"offer_request_id" to offerReq.id,
"debit_request_id" to outcome.requestId,
"offer_service" to offer.pubKey,
"debit_service" to debit.pubKey,
),
)
0
} else {
Output.error(
"debit_error",
outcome.response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${outcome.response.code}",
DebitCommands.gfyExtra(outcome.response),
)
}
}
} finally {
ctx.close()
}
}
/** Structured offer-error extras (code + moved `latest` pointer + acceptable range). */
private fun offerErrorExtra(response: OfferResponse): Map<String, Any?> =
mapOf(
"code" to response.code,
"latest" to response.latest,
"range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) },
)
}
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -139,17 +141,23 @@ object ProfileCommands {
val twitter = args.flag("twitter")
val mastodon = args.flag("mastodon")
val github = args.flag("github")
val clinkOffer = args.flag("clink-offer")
val timeoutSecs = args.longFlag("timeout", 8L)
// A non-blank --clink-offer must be a real noffer; pass "" to clear the field.
if (!clinkOffer.isNullOrBlank() && ClinkPointerParser.parse(clinkOffer.trim()) !is NOffer) {
return Output.error("bad_args", "--clink-offer is not a valid noffer pointer (pass \"\" to clear)")
}
val touched =
listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github)
listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github, clinkOffer)
.any { it != null }
if (!touched) {
return Output.error(
"bad_args",
"profile edit needs at least one of " +
"--name --display-name --about --picture --banner --website " +
"--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github",
"--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github --clink-offer",
)
}
@@ -182,6 +190,7 @@ object ProfileCommands {
twitter = twitter,
mastodon = mastodon,
github = github,
clinkOffer = clinkOffer,
)
} else {
MetadataEvent.createNew(
@@ -198,6 +207,7 @@ object ProfileCommands {
twitter = twitter,
mastodon = mastodon,
github = github,
clinkOffer = clinkOffer,
)
}
@@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.actions.ZapActions
import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -51,8 +53,10 @@ import okhttp3.OkHttpClient
* 4. POST it to the recipient's LNURL-pay callback via
* [LightningAddressResolver] to receive a BOLT11 invoice.
*
* The invoice is printed but **not** auto-paid amy has no NWC wallet
* wired up yet. Paste the invoice into any LN wallet to settle.
* By default the invoice is printed but **not** auto-paid paste it into any LN
* wallet to settle. Pass `--with <ndebit>` to settle it in-place through a CLINK
* debit pointer (kind-21002), mirroring how the app routes a zap through its
* default payment source; each recipient then also reports `paid` + `preimage`.
*/
object ZapCommand {
suspend fun dispatch(
@@ -72,7 +76,7 @@ object ZapCommand {
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--timeout SECS]")
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--with <ndebit>] [--timeout SECS]")
val userArg = rest[0]
val sats =
rest[1].toLongOrNull()?.takeIf { it > 0 }
@@ -81,6 +85,14 @@ object ZapCommand {
val comment = args.flag("comment") ?: ""
val zapType = parseZapType(args)
val timeoutMs = args.longFlag("timeout", 8L) * 1000
val withFlag = args.flag("with")
val settleWith =
if (withFlag == null) {
null
} else {
(ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() }
?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay")
}
val ctx = Context.open(dataDir)
try {
@@ -103,7 +115,7 @@ object ZapCommand {
zapType = zapType,
)
emitZapResult(ctx, sats, lnAddress, comment, request, zapType)
emitZapResult(ctx, sats, lnAddress, comment, request, zapType, timeoutMs, settleWith)
return 0
} finally {
ctx.close()
@@ -114,7 +126,7 @@ object ZapCommand {
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--private] [--timeout SECS]")
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--private] [--with <ndebit>] [--timeout SECS]")
val eventId = rest[0]
if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)")
val sats =
@@ -124,6 +136,14 @@ object ZapCommand {
val comment = args.flag("comment") ?: ""
val zapType = parseZapType(args)
val timeoutMs = args.longFlag("timeout", 8L) * 1000
val withFlag = args.flag("with")
val settleWith =
if (withFlag == null) {
null
} else {
(ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() }
?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay")
}
val ctx = Context.open(dataDir)
try {
@@ -175,7 +195,7 @@ object ZapCommand {
)
}
emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests)
emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests, timeoutMs, settleWith)
return 0
} finally {
ctx.close()
@@ -189,6 +209,8 @@ object ZapCommand {
comment: String,
request: LnZapRequestEvent,
zapType: LnZapEvent.ZapType,
timeoutMs: Long,
settleWith: NDebit?,
zappedEventId: HexKey? = null,
) {
// Reuse the same OkHttp instance the Context uses for nip-05 / WS;
@@ -205,8 +227,8 @@ object ZapCommand {
when (result) {
is LightningAddressResolver.Result.Success -> {
Output.emit(
buildMap {
val base =
buildMap<String, Any?> {
put("ln_address", lnAddress)
put("amount_sats", sats)
put("zap_type", zapType.name.lowercase())
@@ -214,8 +236,9 @@ object ZapCommand {
put("zap_request_id", request.id)
if (zappedEventId != null) put("zapped_event_id", zappedEventId)
put("invoice", result.invoice)
},
)
}
val settled = if (settleWith != null) settleEntry(ctx, settleWith, result.invoice, timeoutMs) else emptyMap()
Output.emit(base + settled)
}
is LightningAddressResolver.Result.Error -> {
Output.error("invoice_failed", result.message)
@@ -223,6 +246,32 @@ object ZapCommand {
}
}
/**
* Settles [bolt11] through a CLINK debit pointer (kind-21002, reusing [DebitCommands.settle])
* and returns the result fields (`paid` + `preimage`/`pay_error`) to merge into the zap
* output. Per-recipient for splits.
*/
private suspend fun settleEntry(
ctx: Context,
debit: NDebit,
bolt11: String,
timeoutMs: Long,
): Map<String, Any?> =
when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, null) }) {
DebitCommands.Settle.Timeout -> mapOf("paid" to false, "pay_error" to "no response from the debit service")
DebitCommands.Settle.BadReply -> mapOf("paid" to false, "pay_error" to "debit reply was not a kind-21002 event")
is DebitCommands.Settle.Replied ->
if (outcome.response.isOk()) {
mapOf("paid" to true, "preimage" to outcome.response.preimage, "debit_request_id" to outcome.requestId)
} else {
mapOf(
"paid" to false,
"pay_error" to (outcome.response.error?.takeIf { it.isNotBlank() } ?: "code ${outcome.response.code}"),
"debit_request_id" to outcome.requestId,
)
}
}
/**
* Multi-recipient (split-aware) event-zap result emitter. Fetches one
* BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a
@@ -238,6 +287,8 @@ object ZapCommand {
zappedEventId: HexKey,
zapType: LnZapEvent.ZapType,
requests: List<ZapActions.ZapRequestForSplit>,
timeoutMs: Long,
settleWith: NDebit?,
) {
val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx))
@@ -260,8 +311,10 @@ object ZapCommand {
"zap_request_id" to req.request.id,
)
when (result) {
is LightningAddressResolver.Result.Success ->
is LightningAddressResolver.Result.Success -> {
entry["invoice"] = result.invoice
if (settleWith != null) entry.putAll(settleEntry(ctx, settleWith, result.invoice, timeoutMs))
}
is LightningAddressResolver.Result.Error ->
entry["invoice_error"] = result.message
+1
View File
@@ -2,3 +2,4 @@ marmot/state/
marmot/state-headless/
dm/state-dm-headless/
nests/state/
clink/state-clink-headless/
+5
View File
@@ -25,6 +25,11 @@ cli/tests/
└── README.md # operator brief + per-test matrix
```
The CLINK suite is local-only (no relay): `clink/clink-headless.sh` asserts that
`amy offer info` / `amy debit info` decode the canonical interop vectors to the
right fields, plus the argument-error paths. The round-trip verbs (`offer
request`, `debit pay/budget`) need a live CLINK service and aren't covered here.
The Marmot harnesses come in two flavours, same scenarios:
- **`marmot/marmot-interop.sh`** — interactive. Drives B/C via `wn` and
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
#
# clink-headless.sh — local-decode checks for `amy offer info` / `amy debit info`.
#
# These verbs are pure pointer decode (no network), so this suite needs no relay:
# it asserts that amy decodes the canonical CLINK interop vectors (the same fixtures
# the quartz ClinkInteropTest uses) to the right fields, in both success and error
# paths. The round-trip verbs (`offer request`, `debit pay`, `debit budget`) need a
# live CLINK service and are out of scope here.
#
# Usage: ./clink-headless.sh [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-clink-headless"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
NO_BUILD=0
[[ "${1:-}" == "--no-build" ]] && NO_BUILD=1
mkdir -p "$LOG_DIR"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
# shellcheck source=../headless/helpers.sh
source "$TESTS_DIR/headless/helpers.sh"
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
print_summary
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Canonical interop vectors (fixed pubkey/relay), from quartz ClinkInteropTest.
EXPECTED_PUB="7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
NOFFER_FIXED="noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c"
NOFFER_SPONT="noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2"
NDEBIT_STATIC="ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44"
banner "CLINK pointer decode headless ($RUN_TS)"
if [[ "$NO_BUILD" -eq 0 ]]; then
step "Building amy (installDist)"
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist >>"$LOG_FILE" 2>&1) ||
{ fail_msg "amy build failed (see $LOG_FILE)"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing: $AMY_BIN"; exit 1; }
# Bare local account — `init` does no relay traffic, which is all we need.
rm -rf "${STATE_DIR:?}/.amy"
amy_a init >>"$LOG_FILE" 2>&1 || { fail_msg "amy init failed (see $LOG_FILE)"; exit 1; }
# --- offer info: fixed-price ---
step "offer info decodes a fixed-price noffer"
OUT=$(amy_json offer info "$NOFFER_FIXED") || true
assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" offer.info.pubkey &&
record_result offer.info.pubkey pass "pubkey decoded"
assert_eq "$(jq -r '.pointer' <<<"$OUT")" "offer-id" offer.info.pointer &&
record_result offer.info.pointer pass "pointer=offer-id"
assert_eq "$(jq -r '.price_type' <<<"$OUT")" "fixed" offer.info.price_type &&
record_result offer.info.price_type pass "price_type=fixed"
assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "21000" offer.info.price_sats &&
record_result offer.info.price_sats pass "price_sats=21000"
# --- offer info: spontaneous (no price) ---
step "offer info decodes a spontaneous noffer (no price)"
OUT=$(amy_json offer info "$NOFFER_SPONT") || true
assert_eq "$(jq -r '.price_type' <<<"$OUT")" "spontaneous" offer.info.spont_type &&
record_result offer.info.spont_type pass "price_type=spontaneous"
assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "null" offer.info.spont_price &&
record_result offer.info.spont_price pass "price_sats=null"
# --- offer info: bad pointer => non-zero exit ---
step "offer info rejects a non-noffer string"
if amy_a offer info "definitely-not-a-noffer" >>"$LOG_FILE" 2>&1; then
record_result offer.info.bad fail "bad pointer should exit non-zero"
else
record_result offer.info.bad pass "bad pointer exits non-zero"
fi
# --- debit info: static pointer ---
step "debit info decodes a static ndebit"
OUT=$(amy_json debit info "$NDEBIT_STATIC") || true
assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" debit.info.pubkey &&
record_result debit.info.pubkey pass "pubkey decoded"
assert_eq "$(jq -r '.pointer' <<<"$OUT")" "pointer-7" debit.info.pointer &&
record_result debit.info.pointer pass "pointer=pointer-7"
assert_eq "$(jq -r '.session' <<<"$OUT")" "false" debit.info.session &&
record_result debit.info.session pass "session=false (no k1)"
# --- debit budget: argument validation (no network needed) ---
step "debit budget rejects an unknown frequency"
if amy_a debit budget "$NDEBIT_STATIC" --amount 1000 --frequency fortnight >>"$LOG_FILE" 2>&1; then
record_result debit.budget.badfreq fail "unknown frequency should exit non-zero"
else
record_result debit.budget.badfreq pass "unknown frequency exits non-zero"
fi
step "debit budget requires --amount"
if amy_a debit budget "$NDEBIT_STATIC" >>"$LOG_FILE" 2>&1; then
record_result debit.budget.noamount fail "missing --amount should exit non-zero"
else
record_result debit.budget.noamount pass "missing --amount exits non-zero"
fi
# --- offer pay: requires a --with funding pointer (validated before any network) ---
step "offer pay requires --with <ndebit>"
if amy_a offer pay "$NOFFER_SPONT" --amount 1000 >>"$LOG_FILE" 2>&1; then
record_result offer.pay.nowith fail "missing --with should exit non-zero"
else
record_result offer.pay.nowith pass "missing --with exits non-zero"
fi
step "offer pay rejects a non-ndebit --with"
if amy_a offer pay "$NOFFER_SPONT" --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then
record_result offer.pay.badwith fail "bad --with should exit non-zero"
else
record_result offer.pay.badwith pass "bad --with exits non-zero"
fi
# --- profile edit --clink-offer: validates the noffer locally before publishing ---
step "profile edit rejects a non-noffer --clink-offer"
if amy_a profile edit --clink-offer "not-a-noffer" >>"$LOG_FILE" 2>&1; then
record_result profile.clinkoffer.bad fail "bad --clink-offer should exit non-zero"
else
record_result profile.clinkoffer.bad pass "bad --clink-offer exits non-zero"
fi
# --- zap --with: rejects a non-ndebit funding pointer (validated before any network) ---
step "zap user rejects a non-ndebit --with"
if amy_a zap user "$EXPECTED_PUB" 1000 --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then
record_result zap.with.bad fail "bad --with should exit non-zero"
else
record_result zap.with.bad pass "bad --with exits non-zero"
fi
# --- offer discover: rejects a malformed NIP-05 (validated before any network) ---
step "offer discover rejects a non-nip05 address"
if amy_a offer discover "not-a-nip05" >>"$LOG_FILE" 2>&1; then
record_result offer.discover.bad fail "bad nip05 should exit non-zero"
else
record_result offer.discover.bad pass "bad nip05 exits non-zero"
fi
@@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
import com.vitorpamplona.quartz.utils.Log
@@ -178,6 +180,11 @@ class MarmotManager(
* `persistOwn = false`. Headless callers (CLI) should leave it at
* the default.
*
* [mentions] become p-tags on the inner kind:9 (users referenced via
* `nostr:npub`/`nostr:nprofile` in [text]), mirroring how NIP-17
* chat messages tag mentioned users. They stay inside the MLS
* ciphertext the outer kind:445 never carries member pubkeys.
*
* @return the signed kind:445 outer event together with the inner kind:9
* rumor id, so the caller can reference it for replies/reactions.
*/
@@ -187,10 +194,12 @@ class MarmotManager(
replyToEventId: HexKey? = null,
replyToAuthorPubKey: HexKey? = null,
persistOwn: Boolean = true,
mentions: List<PTag> = emptyList(),
): TextMessageBundle {
val template =
com.vitorpamplona.quartz.nip01Core.signers
.eventTemplate<Event>(kind = 9, description = text) {
pTags(mentions)
if (replyToEventId != null) {
// Mirror ChatEvent.reply(): NIP-18 q-tag references the
// parent inner kind:9 by id (+ optional author, no
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.clink
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import kotlinx.serialization.Serializable
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* A saved CLINK Debits pointer the user can spend from the `ndebit` counterpart of
* [com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry].
*
* Unlike NWC, a debit carries no secret (authorization is the account's own identity,
* pre-approved on the wallet service) and exposes no balance or transaction history
* it is a spend-only payment source. The persisted form keeps the raw `ndebit1`
* string; [normalize] decodes it for use.
*/
@OptIn(ExperimentalUuidApi::class)
@Serializable
data class ClinkDebitWalletEntry(
val id: String = Uuid.random().toString(),
val name: String,
val ndebit: String,
) {
fun normalize(): ClinkDebitWalletEntryNorm? = (ClinkPointerParser.parse(ndebit) as? NDebit)?.let { ClinkDebitWalletEntryNorm(id, name, it) }
}
data class ClinkDebitWalletEntryNorm(
val id: String,
val name: String,
val pointer: NDebit,
) {
fun denormalize(): ClinkDebitWalletEntry = ClinkDebitWalletEntry(id, name, pointer.encode())
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.payments
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
/**
* A user-configured way to pay a BOLT-11 the unit the zap button (and any other
* "pay this invoice" path) selects a default from. Today either a NIP-47 NWC wallet
* or a CLINK Debits pointer; absence of any source means falling back to an external
* wallet app (intent).
*
* [canShowBalance] is the honest capability marker: NWC can report balance/history,
* a CLINK debit cannot, so the UI renders the two rows differently.
*/
sealed interface PaymentSource {
val id: String
val name: String
val canShowBalance: Boolean
data class Nwc(
val wallet: NwcWalletEntryNorm,
) : PaymentSource {
override val id: String get() = wallet.id
override val name: String get() = wallet.name
override val canShowBalance: Boolean get() = true
}
data class ClinkDebit(
val wallet: ClinkDebitWalletEntryNorm,
) : PaymentSource {
override val id: String get() = wallet.id
override val name: String get() = wallet.name
override val canShowBalance: Boolean get() = false
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.payments
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
/**
* Builds the unified list of configured [PaymentSource]s (NWC + CLINK debit) and
* resolves which one is the default for "pay this invoice" paths.
*
* The default is a single id spanning both lists (ids are random UUIDs, unique across
* types), so one selector picks the spend rail regardless of type. When no explicit
* default is set, the first configured source wins NWC wallets are listed before
* debits, preserving today's "first NWC wallet" fallback.
*/
object PaymentSourceResolver {
fun all(
nwcWallets: List<NwcWalletEntryNorm>,
debitWallets: List<ClinkDebitWalletEntryNorm>,
): List<PaymentSource> = nwcWallets.map { PaymentSource.Nwc(it) } + debitWallets.map { PaymentSource.ClinkDebit(it) }
fun resolveDefault(
nwcWallets: List<NwcWalletEntryNorm>,
debitWallets: List<ClinkDebitWalletEntryNorm>,
defaultId: String?,
): PaymentSource? = resolveDefault(all(nwcWallets, debitWallets), defaultId)
fun resolveDefault(
sources: List<PaymentSource>,
defaultId: String?,
): PaymentSource? = defaultId?.let { id -> sources.firstOrNull { it.id == id } } ?: sources.firstOrNull()
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.util.isValidUrl
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -389,6 +391,10 @@ class RichTextParser {
if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word)
if (word.startsWith("noffer1", true)) {
(ClinkPointerParser.parse(word) as? NOffer)?.let { return ClinkOfferSegment(word, it) }
}
if (word.startsWith('#')) return parseHash(word, tags)
if (EmojiCoder.isCoded(word)) return SecretEmoji(word)
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -92,6 +93,12 @@ class CashuSegment(
segment: String,
) : Segment(segment)
@Immutable
class ClinkOfferSegment(
segment: String,
val offer: NOffer,
) : Segment(segment)
@Immutable
class EmailSegment(
segment: String,
@@ -28,6 +28,7 @@ class TorRelayEvaluation(
val torSettings: TorRelaySettings,
val trustedRelayList: Set<NormalizedRelayUrl>,
val dmRelayList: Set<NormalizedRelayUrl>,
val moneyOpRelayList: Set<NormalizedRelayUrl> = emptySet(),
) {
fun useTor(relay: NormalizedRelayUrl): Boolean =
if (torSettings.torType == TorType.OFF) {
@@ -36,7 +37,14 @@ class TorRelayEvaluation(
if (relay.isLocalHost()) {
false
} else if (relay.isOnion()) {
// .onion is only reachable over Tor regardless of any other classification.
torSettings.onionRelaysViaTor
} else if (relay in moneyOpRelayList) {
// Relays used for money operations (NIP-47 wallets, CLINK offer/debit services)
// follow the dedicated money-operations preference, taking precedence over the
// generic DM/trusted/new classification so a payment never silently inherits a
// different Tor policy than the one the user set for money.
torSettings.moneyOperationsViaTor
} else if (relay in dmRelayList) {
torSettings.dmRelaysViaTor
} else if (relay in trustedRelayList) {
@@ -26,4 +26,5 @@ data class TorRelaySettings(
val dmRelaysViaTor: Boolean = false,
val newRelaysViaTor: Boolean = false,
val trustedRelaysViaTor: Boolean = false,
val moneyOperationsViaTor: Boolean = false,
)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.payments
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PaymentSourceResolverTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
private fun nwc(id: String) = NwcWalletEntryNorm(id, "nwc-$id", Nip47WalletConnect.Nip47URINorm(pubKey, relay, secret = "ab".repeat(32)))
private fun debit(id: String) = ClinkDebitWalletEntryNorm(id, "debit-$id", NDebit(pubKey, listOf(relay), "pointer-$id", null))
@Test
fun allListsNwcBeforeDebits() {
val sources = PaymentSourceResolver.all(listOf(nwc("a")), listOf(debit("b")))
assertTrue(sources[0] is PaymentSource.Nwc)
assertTrue(sources[1] is PaymentSource.ClinkDebit)
}
@Test
fun explicitDefaultSelectsAcrossEitherType() {
val nwcWallets = listOf(nwc("a"))
val debits = listOf(debit("b"))
// a debit can be the unified default even when an NWC wallet exists
val resolved = PaymentSourceResolver.resolveDefault(nwcWallets, debits, defaultId = "b")
assertTrue(resolved is PaymentSource.ClinkDebit)
assertEquals("b", resolved.id)
}
@Test
fun fallsBackToFirstNwcWhenNoExplicitDefault() {
val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = null)
assertTrue(resolved is PaymentSource.Nwc)
assertEquals("a", resolved.id)
}
@Test
fun fallsBackToFirstDebitWhenNoNwc() {
val resolved = PaymentSourceResolver.resolveDefault(emptyList(), listOf(debit("b"), debit("c")), defaultId = null)
assertTrue(resolved is PaymentSource.ClinkDebit)
assertEquals("b", resolved.id)
}
@Test
fun staleDefaultIdFallsBackToFirst() {
val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = "deleted")
assertEquals("a", resolved?.id)
}
@Test
fun noSourcesResolvesToNull() {
assertNull(PaymentSourceResolver.resolveDefault(emptyList(), emptyList(), defaultId = null))
}
@Test
fun debitSourceCannotShowBalance() {
assertTrue(PaymentSource.Nwc(nwc("a")).canShowBalance)
assertTrue(!PaymentSource.ClinkDebit(debit("b")).canShowBalance)
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ClinkOfferSegmentTest {
// generated by @shocknet/clink-sdk@1.5.5 (see quartz ClinkInteropTest)
private val offerFixed =
"noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c"
private fun words(text: String) =
RichTextParser()
.parseText(text, EmptyTagList, null)
.paragraphs
.flatMap { it.words }
@Test
fun detectsNofferInlineAsClinkOfferSegment() {
val segment =
words("Pay me here $offerFixed thanks")
.filterIsInstance<ClinkOfferSegment>()
.single()
assertEquals(offerFixed, segment.segmentText)
assertEquals("offer-id", segment.offer.pointer)
assertEquals(OfferPriceType.FIXED, segment.offer.priceType)
assertEquals(21000L, segment.offer.price)
}
@Test
fun malformedNofferIsNotDetected() {
assertTrue(words("nope noffer1notvalidbech32 end").none { it is ClinkOfferSegment })
}
@Test
fun plainTextHasNoOfferSegment() {
assertTrue(words("just a normal sentence").none { it is ClinkOfferSegment })
}
}
@@ -33,6 +33,7 @@ class TorRelayEvaluationTest {
private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/")
private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/")
private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/")
private val moneyRelay = NormalizedRelayUrl("wss://wallet.relay.com/")
private fun buildEvaluation(
torType: TorType = TorType.INTERNAL,
@@ -40,8 +41,10 @@ class TorRelayEvaluationTest {
dmViaTor: Boolean = true,
newViaTor: Boolean = true,
trustedViaTor: Boolean = false,
moneyViaTor: Boolean = false,
dmRelays: Set<NormalizedRelayUrl> = setOf(dmRelay),
trustedRelays: Set<NormalizedRelayUrl> = setOf(trustedRelay),
moneyOpRelays: Set<NormalizedRelayUrl> = setOf(moneyRelay),
) = TorRelayEvaluation(
torSettings =
TorRelaySettings(
@@ -50,9 +53,11 @@ class TorRelayEvaluationTest {
dmRelaysViaTor = dmViaTor,
newRelaysViaTor = newViaTor,
trustedRelaysViaTor = trustedViaTor,
moneyOperationsViaTor = moneyViaTor,
),
trustedRelayList = trustedRelays,
dmRelayList = dmRelays,
moneyOpRelayList = moneyOpRelays,
)
// --- Tor OFF ---
@@ -107,6 +112,44 @@ class TorRelayEvaluationTest {
@Test
fun unknown_disabled_returnsFalse() = assertFalse(buildEvaluation(newViaTor = false).useTor(clearnetRelay))
// --- Money-operation relays ---
@Test
fun money_enabled_returnsTrue() = assertTrue(buildEvaluation(moneyViaTor = true).useTor(moneyRelay))
@Test
fun money_disabled_returnsFalse() = assertFalse(buildEvaluation(moneyViaTor = false).useTor(moneyRelay))
@Test
fun money_takesPrecedenceOverNew() {
// A money-op relay not in any other list must NOT fall through to the new-relay policy.
val eval = buildEvaluation(moneyViaTor = false, newViaTor = true, dmRelays = emptySet(), trustedRelays = emptySet())
assertFalse(eval.useTor(moneyRelay))
}
@Test
fun money_takesPrecedenceOverTrustedAndDm() {
// When the same relay is both a money-op relay and trusted/DM, money policy wins.
val both = NormalizedRelayUrl("wss://wallet-and-trusted.relay.com/")
val eval =
buildEvaluation(
moneyViaTor = true,
dmViaTor = false,
trustedViaTor = false,
dmRelays = setOf(both),
trustedRelays = setOf(both),
moneyOpRelays = setOf(both),
)
assertTrue(eval.useTor(both))
}
@Test
fun money_onionStillWins() {
// .onion reachability check precedes the money classification.
val onionMoney = NormalizedRelayUrl("wss://wallet.onion/")
val eval = buildEvaluation(onionViaTor = false, moneyViaTor = true, moneyOpRelays = setOf(onionMoney))
assertFalse(eval.useTor(onionMoney))
}
// --- Priority ---
@Test
fun onionInDmList_treatedAsOnion() {
+27 -58
View File
@@ -1,4 +1,3 @@
import de.undercouch.gradle.tasks.download.Download
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import java.nio.file.Files
@@ -6,7 +5,6 @@ plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler)
id("ir.mahozad.vlc-setup") version "0.1.0"
}
// RPM rejects dashes in version strings — replace with tilde (~) which RPM uses
@@ -57,8 +55,14 @@ dependencies {
implementation(libs.coil.okhttp)
implementation(libs.coil.svg)
// Video playback
implementation(libs.vlcj)
// Video / audio playback — MIT, OS-native backends (MF / AVFoundation / GStreamer)
implementation(libs.composemediaplayer)
// Thumbnail extraction — JCodec (pure-Java H.264). LGPL FFmpeg subprocess
// for non-H.264 / HLS fallback is invoked via plain ProcessBuilder; no
// wrapper library needed (see VideoThumbnailCache.runFfmpegToImage).
implementation(libs.jcodec)
implementation(libs.jcodec.javase)
// EXIF stripping (lossless)
implementation(libs.commons.imaging)
@@ -94,9 +98,6 @@ compose.desktop {
jvmArgs += "-Xmx2g"
// VLC plugin path fallback — used if JNA setenv and bundled discovery both fail
jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"
// Forward platform-preview overrides from the gradle invocation to the
// launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME`
// works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`).
@@ -114,7 +115,7 @@ compose.desktop {
"java.prefs", // java.util.prefs (desktop persistence)
"java.sql", // JDBC metadata (Jackson, SQLite driver)
"jdk.security.auth", // JAAS authentication callbacks
"jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory)
"jdk.unsupported", // sun.misc.Unsafe (secp256k1-kmp-jni-jvm, JNA)
)
packageName = "Amethyst"
@@ -138,7 +139,13 @@ compose.desktop {
menuGroup = "Network"
appCategory = "Network"
debMaintainer = "vitor@vitorpamplona.com"
rpmLicenseType = "MIT"
// SPDX compound expression. Bundled components:
// MIT — Amethyst + kdroidFilter ComposeMediaPlayer
// LGPL-2.1-or-later — FFmpeg (LGPL build, bundled per OS for thumbnail fallback) +
// GStreamer (Linux runtime dep, system-installed)
// BSD-2-Clause — JCodec
// Apache-2.0 — Jaffree + many transitive Java libraries
rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0"
// RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering).
rpmPackageVersion = appVersion.replace("-", "~")
}
@@ -149,8 +156,8 @@ compose.desktop {
// problems with `-dontobfuscate` plus global `-keepnames` / `-keep enum`
// rules (see `amethyst/proguard-rules.pro`). We mirror that strategy in
// `compose-rules.pro` so the desktop release survives JNI callbacks
// (secp256k1-kmp, sqlite-bundled, jkeychain, VLCj) and reflection-heavy
// libraries (Jackson, JNA) without renaming.
// (secp256k1-kmp, sqlite-bundled, jkeychain, kdroidFilter native)
// and reflection-heavy libraries (Jackson, JNA) without renaming.
//
// Shrink and optimize stay ON. One ProGuard optimize sub-pass is
// disabled in `compose-rules.pro` to avoid a generated okio bridge
@@ -163,61 +170,23 @@ compose.desktop {
}
}
vlcSetup {
// Pinned to 3.0.20 because the Linux VLC plugins on Maven Central
// (ir.mahozad:vlc-plugins-linux) have not been republished for 3.0.21 — the
// latest there is 3.0.20-2. Using 3.0.21 makes vlcDownload 404 on Linux CI.
vlcVersion.set("3.0.20")
shouldCompressVlcFiles.set(true)
shouldIncludeAllVlcFiles.set(true)
pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc"))
pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc"))
pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc"))
}
tasks.named("spotlessKotlin") {
mustRunAfter("vlcSetup")
}
// `ir.mahozad.vlc-setup` registers `vlcDownload` / `upxDownload` tasks that
// extend `de.undercouch.gradle.tasks.download.Download`. Defaults are 0 retries
// and a short read timeout, so a transient blip on get.videolan.org fails the
// whole desktop build on CI (Windows MSI, macOS DMG, Linux DEB). Configure all
// Download tasks in this project to retry with generous timeouts so flaky
// network conditions do not break packaging jobs.
tasks.withType<Download>().configureEach {
// 5 attempts total (initial + 4 retries) before failing the task.
retries(4)
// 30s to establish a TCP / TLS connection.
connectTimeout(30_000)
// 5 minutes per attempt for the body — VLC archives are 40-90 MB and
// get.videolan.org can be slow under load.
readTimeout(5 * 60_000)
// Stage to a temp file and rename only on full success, so a partial
// download from one attempt cannot poison the next.
tempAndMove(true)
}
// --- AppImage packaging (Linux) ---
//
// Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101).
// Instead: wrap `createReleaseDistributable` output with `appimagetool`, which
// just packages an AppDir as-is. We deliberately avoid `linuxdeploy` here —
// linuxdeploy auto-walks every binary in the AppDir with ldd to bundle deps,
// but jpackage already ships a self-contained tree we don't want it touching:
// - The bundled JRE puts libjvm.so under usr/lib/runtime/lib/server/ while
// sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so) have
// RPATH=$ORIGIN, so ldd cannot resolve libjvm.so without help.
// - The bundled VLC plugins are UPX-compressed; linuxdeploy aborts on those
// with "patchelf: no section headers" because they look like static ELFs.
// - Several VLC libs have RUNPATH that does not point at sibling libs in
// the same directory, so ldd errors with "Could not find dependency".
// appimagetool sidesteps all of this — it only embeds the AppDir into a
// SquashFS, runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH
// at launch.
// but jpackage already ships a self-contained tree we don't want it touching
// (the bundled JRE has libjvm.so under usr/lib/runtime/lib/server/ while sibling
// libs use $ORIGIN RPATH — ldd can't resolve without help).
// appimagetool sidesteps that — it only embeds the AppDir into a SquashFS,
// runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH at launch.
//
// kdroidFilter (video/audio) links against system GStreamer at runtime — the
// AppImage does not bundle GStreamer; the host system must have it installed.
//
// Build inputs live in desktopApp/packaging/appimage/:
// - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC)
// - AppRun shell launcher
// - amethyst.desktop XDG desktop entry
// - amethyst.png 512x512 icon
//
+6 -4
View File
@@ -1,11 +1,13 @@
#!/bin/bash
# AppImage launcher for Amethyst Desktop.
# Sets LD_LIBRARY_PATH so vlcj finds bundled libvlc.so at runtime.
# jpackage puts app resources at usr/lib/app/<platform>/vlc/ inside the AppDir.
# kdroidFilter ComposeMediaPlayer uses the host system's GStreamer (linked at
# runtime). Users need:
# sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
# gstreamer1.0-plugins-bad gstreamer1.0-libav
# (Equivalent packages on Fedora/Arch.)
set -eu
HERE="$(dirname "$(readlink -f "${0}")")"
export LD_LIBRARY_PATH="${HERE}/usr/lib/app/linux/vlc:${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
export VLC_PLUGIN_PATH="${HERE}/usr/lib/app/linux/vlc/plugins"
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
export PATH="${HERE}/usr/bin:${PATH}"
export APPDIR="${HERE}"
exec "${HERE}/usr/bin/Amethyst" "$@"
+54
View File
@@ -0,0 +1,54 @@
# Flathub packaging for Amethyst Desktop
This directory contains the Flatpak manifest and associated metadata for
publishing Amethyst Desktop on Flathub.
## Files
- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest
- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata
(categories, license, screenshots — needs screenshots added before
submission)
- `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry
- `icons/256/com.vitorpamplona.amethyst.Desktop.png` — TODO: copy a 256x256
PNG icon from `desktopApp/src/jvmMain/resources/icon.png` before
submission
## Build prerequisites
- `./gradlew :desktopApp:createReleaseDistributable` — produces
`desktopApp/build/compose/binaries/main-release/app/Amethyst/`
- `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08`
## Local build
```bash
cd desktopApp/packaging/flatpak
flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml
flatpak run com.vitorpamplona.amethyst.Desktop
```
## Submission to Flathub
Follow https://docs.flathub.org/docs/for-app-authors/submission
1. Fork `flathub/flathub` on GitHub.
2. Branch from `new-pr` (NOT `master`).
3. Copy this manifest + AppStream + desktop into a new directory matching
the app id.
4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop".
5. After merge, a per-app repo is created with write access for ongoing
updates.
## Codec coverage
- HEVC / VP9 / AV1: covered via `org.freedesktop.Platform.ffmpeg-full`
add-extension declared in the manifest. Flatpak downloads it on install.
- HLS, H.264, AAC, MP3, Opus: covered by the GStreamer plugin set in
`org.freedesktop.Platform 24.08` itself.
## License metadata
The manifest declares the binary as
`MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0`. This SPDX
expression validates via `appstreamcli validate`.
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Amethyst Desktop
Comment=Nostr client for desktop
Categories=Network;InstantMessaging;
Exec=amethyst-desktop
Icon=com.vitorpamplona.amethyst.Desktop
Terminal=false
StartupWMClass=Amethyst
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.vitorpamplona.amethyst.Desktop</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0</project_license>
<name>Amethyst Desktop</name>
<summary>Nostr client for desktop</summary>
<description>
<p>
Amethyst Desktop is a desktop client for the Nostr protocol.
Browse feeds, post notes, send zaps (Lightning Network), and
participate in NIP-53 live audio rooms.
</p>
<p>
Video playback uses your system's GStreamer (Linux), AVFoundation
(macOS), or Media Foundation (Windows). On Linux, install the
GStreamer plugin packages (good, bad, libav) for full codec coverage.
</p>
</description>
<launchable type="desktop-id">com.vitorpamplona.amethyst.Desktop.desktop</launchable>
<url type="homepage">https://github.com/vitorpamplona/amethyst</url>
<url type="bugtracker">https://github.com/vitorpamplona/amethyst/issues</url>
<url type="vcs-browser">https://github.com/vitorpamplona/amethyst</url>
<developer id="com.vitorpamplona">
<name>Vitor Pamplona</name>
</developer>
<content_rating type="oars-1.1" />
<categories>
<category>Network</category>
<category>InstantMessaging</category>
</categories>
<screenshots>
<!-- Add at least one screenshot tagged with `<image>...</image>`
before Flathub submission. Suggested 1280x800 PNG. -->
</screenshots>
<releases>
<!-- Populated by release CI. Example:
<release version="0.0.0" date="2026-06-11">
<description>
<p>Initial Flathub release with kdroidFilter video playback.</p>
</description>
</release>
-->
</releases>
</component>
@@ -0,0 +1,77 @@
# Flathub manifest for Amethyst Desktop.
#
# Pattern based on flathub/com.jetpackduba.Gitnuro (Kotlin Compose Desktop).
# Submission to Flathub is a separate operation — see
# desktopApp/packaging/flatpak/README.md for the workflow.
app-id: com.vitorpamplona.amethyst.Desktop
runtime: org.freedesktop.Platform
runtime-version: '24.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.openjdk21
command: amethyst-desktop
add-extensions:
org.freedesktop.Platform.ffmpeg-full:
directory: lib/ffmpeg
version: '24.08'
add-ld-path: .
autodownload: true
autodelete: false
cleanup-commands:
- mkdir -p /app/lib/ffmpeg
finish-args:
- --share=network
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --socket=pulseaudio
- --device=dri
- --filesystem=xdg-download
- --filesystem=xdg-pictures
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.secrets
# GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default).
- --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0
modules:
- name: openjdk
buildsystem: simple
build-commands:
- /usr/lib/sdk/openjdk21/install.sh
- name: amethyst-desktop
buildsystem: simple
build-commands:
# Drop the jpackage-emitted self-contained tree into /app/lib/Amethyst.
# Wrapper at /app/bin/amethyst-desktop forwards args.
- mkdir -p /app/lib/Amethyst
- cp -r ./Amethyst/* /app/lib/Amethyst/
- install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop
- install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/
- install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/
- install -Dm644 icons/256/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/256x256/apps/
sources:
# Built artifact from `./gradlew :desktopApp:createReleaseDistributable`.
# Path matches Compose Multiplatform 1.11's output layout.
- type: dir
path: ../../build/compose/binaries/main-release/app
dest: ./
- type: script
dest-filename: amethyst-desktop.sh
commands:
- "#!/bin/sh"
- exec /app/lib/Amethyst/bin/Amethyst "$@"
- type: file
path: com.vitorpamplona.amethyst.Desktop.metainfo.xml
- type: file
path: com.vitorpamplona.amethyst.Desktop.desktop
- type: file
path: icons/256/com.vitorpamplona.amethyst.Desktop.png

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