mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge remote-tracking branch 'origin/main' into claude/beautiful-ride-n6y3s2
This commit is contained in:
+14
-43
@@ -12,8 +12,10 @@ architecture while sharing the back end components with the android counterpart.
|
||||
a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by
|
||||
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
|
||||
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
|
||||
exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53
|
||||
audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
|
||||
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
|
||||
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and
|
||||
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
|
||||
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
|
||||
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
|
||||
production listener AND speaker paths both run on moq-lite to interop with the
|
||||
nostrnests reference relay. The IETF code is kept as a reference + unit-test
|
||||
@@ -23,27 +25,13 @@ implementation for any future IETF target; see
|
||||
Canonical NIP specs live at <https://github.com/nostr-protocol/nips> — use
|
||||
`/nip <number>` to pull a specific one (it fetches the spec file directly).
|
||||
|
||||
## Verify, Don't Guess (standing instruction)
|
||||
## Verify, Don't Guess
|
||||
|
||||
A plausible-sounding explanation is cheap; being right is not. Before
|
||||
asserting what a problem is or how something behaves:
|
||||
|
||||
1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm
|
||||
guessing" or "haven't verified" — never dress an untested guess up as a
|
||||
diagnosis. Use "I verified X by running Y" only when you actually did.
|
||||
2. **Reproduce before diagnosing.** If a claim is checkable in under a
|
||||
minute, check it before stating it. This repo gives you the means:
|
||||
`./gradlew test`, the per-module tests, and `amy` (the CLI exists partly
|
||||
to drive `quartz`/`commons` for interop checks). Write the failing case
|
||||
first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix`
|
||||
(reproduce-first) or `/investigate` (competing hypotheses + refutation).
|
||||
3. **Predict, then run.** Before running a command, state the output you
|
||||
expect. A mismatch is the cheapest signal that your model is wrong.
|
||||
4. **Don't commit to one cause.** A single immediate explanation stops you
|
||||
from looking. Hold 2–3 candidates and a discriminating test for each.
|
||||
|
||||
If you find yourself writing paragraphs to defend a theory, that effort
|
||||
almost always should have been one test.
|
||||
Don't assert a diagnosis you haven't reproduced. This repo gives you cheap
|
||||
verification tools: `./gradlew test`, per-module test suites, and the `amy`
|
||||
CLI (built partly to drive `quartz`/`commons` for interop checks). If a
|
||||
claim is checkable in under a minute, check it before stating it — write
|
||||
the failing case first, watch it fail, then explain.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -69,6 +57,7 @@ amethyst/
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # MoQ session, NestsListener, audio glue
|
||||
│ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack
|
||||
├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code
|
||||
├── desktopApp/ # Desktop JVM application (layouts, navigation)
|
||||
├── amethyst/ # Android app (layouts, navigation)
|
||||
└── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
|
||||
@@ -123,16 +112,6 @@ to be used together:
|
||||
skills: `compose-expert` tells you where shared composables live;
|
||||
`compose-slot-api-pattern` tells you how to shape their public API.
|
||||
|
||||
## Workflow
|
||||
|
||||
**When you ask for a feature:**
|
||||
|
||||
1. **Quick skill assessment** - I identify which skills are relevant
|
||||
2. **Propose which skills** - I present which skills I'll use for the task
|
||||
3. **Get approval** - You review and approve (or adjust) the skill selection
|
||||
4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan
|
||||
5. **Execute with skills** - Skills collaborate to implement the feature
|
||||
|
||||
## Feature Workflow
|
||||
|
||||
**CRITICAL: Check existing implementations first — most logic already exists.**
|
||||
@@ -142,17 +121,9 @@ job is usually to **reuse** (`quartz` protocol/business logic), **extract**
|
||||
(Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav —
|
||||
not to duplicate existing managers, caches, or state.
|
||||
|
||||
Capture the survey as a matrix in your plan:
|
||||
|
||||
| File/Component | Status | Location | Action |
|
||||
|----------------|--------|----------|--------|
|
||||
| FilterBuilders | ✅ Reuse | quartz/relay/filters/ | Use as-is |
|
||||
| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons |
|
||||
| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern |
|
||||
|
||||
**Legend:** ✅ Reuse (exists, use directly) · 📦 Extract (exists in Android, move
|
||||
to `commons`) · 🆕 New (doesn't exist — platform-specific only) · ⚠️ Avoid
|
||||
(duplicate; use existing pattern).
|
||||
Summarize the survey in your plan: for each component, note whether it's
|
||||
reused as-is, extracted from `amethyst/` to `commons/`, genuinely new
|
||||
(platform-specific only), or a duplicate of an existing pattern to avoid.
|
||||
|
||||
**Share vs keep platform-native:**
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application:
|
||||
|
||||
If the build fails, check:
|
||||
|
||||
1. **JDK Version**: Requires JDK 17+
|
||||
1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`)
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
@@ -39,7 +39,7 @@ If the build fails, check:
|
||||
./gradlew :desktopApp:packageMsi
|
||||
|
||||
# Linux
|
||||
./gradlew :desktopApp:packageDeb
|
||||
./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm
|
||||
```
|
||||
|
||||
Outputs will be in `desktopApp/build/compose/binaries/`
|
||||
Outputs will be in `desktopApp/build/compose/binaries/main/`
|
||||
|
||||
@@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
|
||||
|
||||
1. **Locate the component** in the amethyst module:
|
||||
```bash
|
||||
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
|
||||
find amethyst/src -name "*$ARGUMENTS*"
|
||||
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
|
||||
- Android Compose specifics vs standard Compose
|
||||
|
||||
3. **Identify what can be shared**:
|
||||
- Pure Composable functions → `shared-ui/commonMain/`
|
||||
- Pure Composable functions → `commons/commonMain/`
|
||||
- Business logic → `quartz/commonMain/`
|
||||
- Platform-specific → create expect/actual
|
||||
|
||||
|
||||
+64
-355
@@ -1,339 +1,37 @@
|
||||
# AmethystMultiplatform Skills Creation Plan
|
||||
|
||||
## Overview
|
||||
Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns.
|
||||
|
||||
**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase.
|
||||
|
||||
## Skills to Implement
|
||||
|
||||
### 1. kotlin-multiplatform ✅ COMPLETED
|
||||
**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Mental model: KMP hierarchy as dependency graph
|
||||
- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain}
|
||||
- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149)
|
||||
- expect/actual mechanics with 24+ examples from codebase
|
||||
- iOS framework setup for Quartz distribution
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/source-set-hierarchy.md` - Visual diagram + examples
|
||||
- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns
|
||||
- `scripts/validate-kmp-structure.sh` - Verify source set dependencies
|
||||
- `assets/kmp-hierarchy-diagram.png` - Visual graph
|
||||
|
||||
**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples.
|
||||
|
||||
**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/`
|
||||
|
||||
---
|
||||
|
||||
### 2. gradle-expert ✅ COMPLETED
|
||||
**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Build architecture: 4 modules, dependency flow
|
||||
- Version catalog mastery (libs.versions.toml)
|
||||
- Module dependency patterns (api vs implementation)
|
||||
- Android-specific: compileSdk, proguard
|
||||
- Desktop packaging: TargetFormat, distributions
|
||||
- Build performance: daemon, parallel, caching
|
||||
- Common errors: compose version conflicts, secp256k1 JNI variants
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/build-commands.md` - Common gradle tasks
|
||||
- `references/dependency-graph.md` - Module visualization
|
||||
- `references/version-catalog-guide.md` - Version catalog patterns
|
||||
- `references/common-errors.md` - Troubleshooting guide
|
||||
- `scripts/analyze-build-time.sh` - Performance report
|
||||
- `scripts/fix-dependency-conflicts.sh` - Conflict patterns
|
||||
|
||||
**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts).
|
||||
|
||||
**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/`
|
||||
|
||||
---
|
||||
|
||||
### 3. kotlin-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager)
|
||||
- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult)
|
||||
- Immutability: @Immutable for Compose performance (173+ event classes)
|
||||
- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder)
|
||||
- Inline functions: reified generics, performance optimization (OptimizedJsonMapper)
|
||||
- Value classes: Zero-cost wrappers (optimization opportunity)
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns
|
||||
- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns
|
||||
- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns
|
||||
- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set
|
||||
|
||||
**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders).
|
||||
|
||||
**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline)
|
||||
2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder
|
||||
3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025
|
||||
4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf)
|
||||
5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability)
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later (requires real usage scenarios)
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 4. compose-expert ✅ COMPLETED
|
||||
**Focus:** Shared composables, state management, animations, Material3
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Shared composables philosophy (100+ already shared in commons/commonMain)
|
||||
- State management: remember, derivedStateOf, produceState (visual patterns)
|
||||
- Recomposition optimization: @Stable/@Immutable (visual usage)
|
||||
- Material3 conventions: theming
|
||||
- Custom icons: ImageVector builders (robohash pattern)
|
||||
- Platform differences: Desktop vs Android UI
|
||||
- Performance: lazy lists, image loading
|
||||
- Decision framework: share by default in commonMain
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/shared-composables-catalog.md` - Complete catalog with patterns
|
||||
- `references/state-patterns.md` - State hoisting, derivedStateOf examples
|
||||
- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL
|
||||
- `scripts/find-composables.sh` - Grep @Composable utility
|
||||
|
||||
**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert.
|
||||
|
||||
**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/`
|
||||
|
||||
---
|
||||
|
||||
### 5. ios-expert
|
||||
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
|
||||
|
||||
**SKILL.md sections:**
|
||||
- iOS source sets: iosMain, iosArm64Main
|
||||
- Swift interop: type mapping, nullability
|
||||
- expect/actual iOS: 10+ examples from quartz/iosMain
|
||||
- XCFramework setup: baseName = "quartz-kmpKit"
|
||||
- Platform APIs: platform.posix, CFNetwork, Security
|
||||
- CocoaPods integration
|
||||
- XCode project setup
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/ios-actual-implementations.md` - 10 iosMain actuals
|
||||
- `references/swift-interop-guide.md` - Type mapping
|
||||
- `references/xcode-integration.md` - XCode setup
|
||||
- `scripts/generate-xcframework.sh` - Build all iOS targets
|
||||
|
||||
**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup.
|
||||
|
||||
---
|
||||
|
||||
### 6. desktop-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Desktop entry point: application {} DSL
|
||||
- Window management: WindowState, positioning, multi-window
|
||||
- Menu system: MenuBar, keyboard shortcuts (OS-aware)
|
||||
- System tray: minimize to tray
|
||||
- Desktop navigation: NavigationRail pattern (vs Android bottom nav)
|
||||
- File system: Desktop.getDesktop(), file pickers, drag-drop
|
||||
- Desktop UX principles: keyboard-first, native feel, tooltips
|
||||
- OS-specific behavior: macOS vs Windows vs Linux
|
||||
- Platform detection: PlatformDetector utility
|
||||
- Packaging: DMG, MSI, DEB distribution
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.)
|
||||
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
|
||||
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper
|
||||
- `references/os-detection.md` - Platform detection, file paths, system integration
|
||||
|
||||
**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert.
|
||||
|
||||
**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined desktop usage scenarios
|
||||
2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt)
|
||||
3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG)
|
||||
4. ✅ SYNTHESIZE - Extracted desktop principles from codebase
|
||||
5. ✅ DRAFT - Created SKILL.md + 4 reference files
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later (requires real desktop scenarios)
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 7. android-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Android platform APIs, navigation, permissions, Material Design
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Android module structure: amethyst/ layout
|
||||
- Navigation: Navigation Compose, bottom nav
|
||||
- Permissions: runtime (camera, biometric)
|
||||
- Platform APIs: Intent, Context, ContentResolver
|
||||
- Lifecycle: Lifecycle-aware, ViewModel
|
||||
- Material Design: Android Material 3
|
||||
- Build config: Proguard, R8
|
||||
- Android UX: mobile-first patterns
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/android-navigation.md` - Navigation Compose
|
||||
- `references/android-permissions.md` - Permission handling
|
||||
- `references/proguard-rules.md` - Proguard explanation
|
||||
- `scripts/analyze-apk-size.sh` - APK optimization
|
||||
|
||||
**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions.
|
||||
|
||||
**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined Android usage scenarios
|
||||
2. ✅ EXPLORE - Analyzed amethyst/ module patterns
|
||||
3. ✅ RESEARCH - Android best practices + KMP Android patterns
|
||||
4. ✅ SYNTHESIZE - Extracted Android principles from codebase
|
||||
5. ✅ DRAFT - Initialized skill, created resources
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 8. nostr-expert ✅ COMPLETED
|
||||
**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Quartz architecture: package structure by NIP (57 NIPs implemented)
|
||||
- Event anatomy: IEvent, Event, kinds, tags
|
||||
- EventTemplate & TagArrayBuilder DSL patterns
|
||||
- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events
|
||||
- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders
|
||||
- Threading (NIP-10): reply/root markers
|
||||
- Cryptography: secp256k1 signing, NIP-44 encryption
|
||||
- Bech32 encoding: npub, nsec, note, nevent
|
||||
- Event validation & verification
|
||||
- Common workflows: publishing, querying, zaps, gift-wrapped DMs
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines)
|
||||
- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines)
|
||||
- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines)
|
||||
- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term
|
||||
|
||||
**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase.
|
||||
|
||||
**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
Using skill-creator 10-step methodology per skill:
|
||||
|
||||
**Overall Plan:**
|
||||
1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained
|
||||
2. **EXPLORE** ✅ - Codebase analyzed via Explore agent
|
||||
3. **RESEARCH** ✅ - Domain patterns identified via Plan agent
|
||||
4. **SYNTHESIZE** ✅ - Skills designed above
|
||||
|
||||
**Per-Skill Implementation:**
|
||||
- kotlin-multiplatform: ✅ COMPLETED
|
||||
- gradle-expert: ✅ COMPLETED
|
||||
- kotlin-expert: ✅ COMPLETED
|
||||
- compose-expert: ✅ COMPLETED
|
||||
- desktop-expert: ✅ COMPLETED
|
||||
- android-expert: ✅ COMPLETED
|
||||
- nostr-expert: ✅ COMPLETED
|
||||
- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform)
|
||||
|
||||
## Critical Files Referenced
|
||||
|
||||
**Build patterns:**
|
||||
- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set
|
||||
- `/commons/build.gradle.kts` - Shared UI setup
|
||||
|
||||
**Code patterns:**
|
||||
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure
|
||||
- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern
|
||||
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual
|
||||
|
||||
**Documentation:**
|
||||
- `/docs/shared-ui-analysis.md` - UI migration strategy
|
||||
|
||||
## Output Location
|
||||
`.claude/skills/<skill-name>/` for each skill
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference
|
||||
2. ✅ Completed kotlin-multiplatform skill
|
||||
3. ✅ Completed gradle-expert skill
|
||||
4. ✅ Completed kotlin-expert skill
|
||||
5. ✅ Completed compose-expert skill
|
||||
6. ✅ Completed desktop-expert skill
|
||||
7. ✅ Completed android-expert skill
|
||||
8. ✅ Completed nostr-expert skill
|
||||
9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase)
|
||||
|
||||
## Current Status: 7/8 Skills Completed
|
||||
|
||||
**Completed Skills (Auto-loaded from `.claude/skills/`):**
|
||||
1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual)
|
||||
2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting)
|
||||
3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders)
|
||||
4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector)
|
||||
5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs)
|
||||
6. ✅ android-expert (Android platform APIs, navigation, permissions)
|
||||
7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags)
|
||||
|
||||
**Deferred:**
|
||||
- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform)
|
||||
|
||||
## Skill Loading
|
||||
|
||||
**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory.
|
||||
|
||||
To manually verify skills are loaded:
|
||||
```bash
|
||||
ls -1 .claude/skills/
|
||||
```
|
||||
|
||||
Should show:
|
||||
- android-expert/
|
||||
- compose-expert/
|
||||
- desktop-expert/
|
||||
- gradle-expert/
|
||||
- kotlin-expert/
|
||||
- kotlin-multiplatform/
|
||||
- nostr-expert/
|
||||
|
||||
---
|
||||
# Amethyst Skill Library — History & Changelog
|
||||
|
||||
> Historical record of how the `.claude/skills/` library was built and audited.
|
||||
> The 8 original skills were created in 2025 using the skill-creator 10-step
|
||||
> methodology (detailed per-skill progress logs pruned in 2026-06 — see git
|
||||
> history of this file if you need them). For the current skill list and how
|
||||
> the two skill layers (codebase-oriented vs technique-oriented) fit together,
|
||||
> see the Skills section of `.claude/CLAUDE.md`.
|
||||
|
||||
## Phase 1 (2025): Core skills created
|
||||
|
||||
Eight hybrid domain skills (general expertise + Amethyst-specific patterns),
|
||||
each with a SKILL.md plus bundled `references/` and `scripts/`:
|
||||
|
||||
1. **kotlin-multiplatform** — KMP architecture, the jvmAndroid source-set pattern, expect/actual catalog
|
||||
2. **gradle-expert** — build system, version catalog, dependency troubleshooting
|
||||
3. **kotlin-expert** — Flow state, sealed hierarchies, @Immutable, DSL builders
|
||||
4. **compose-expert** — shared composables, state management, Material3, ImageVector
|
||||
5. **desktop-expert** — Desktop UX, window management, Compose Desktop APIs
|
||||
6. **android-expert** — Android navigation, permissions, platform APIs
|
||||
7. **nostr-expert** — Nostr protocol, Quartz implementation, NIPs, events, tags
|
||||
8. **ios-expert** — ⏸️ deferred (iOS targets are mature, but no iOS-specific UI work has surfaced in this repo yet)
|
||||
|
||||
## Phase 2 (2026-04): Audit & Expansion
|
||||
|
||||
After a full audit of the skill library, the following changes were made:
|
||||
|
||||
### Stale references fixed
|
||||
- `CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20.
|
||||
- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes.
|
||||
- `desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
|
||||
- `CLAUDE.md` tech-stack versions replaced with a pointer to `gradle/libs.versions.toml` as the source of truth.
|
||||
- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp version notes.
|
||||
- `desktop-expert` Main.kt line references rewritten to match current layout (NavigationRail moved to `ui/deck/SinglePaneLayout.kt`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
|
||||
|
||||
### Redundant files removed
|
||||
- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). `quartz-kmp.md` kept as a small breadcrumb pointer.
|
||||
- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`).
|
||||
|
||||
### New references added to existing skills
|
||||
- `nostr-expert/references/nip19-bech32.md` — `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities.
|
||||
@@ -346,33 +44,44 @@ After a full audit of the skill library, the following changes were made:
|
||||
|
||||
### New skills created
|
||||
- **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store.
|
||||
- `references/account-state-flow.md`, `references/local-cache.md`
|
||||
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`).
|
||||
- `references/filter-assemblers.md`, `references/preloaders.md`
|
||||
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders.
|
||||
- **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`.
|
||||
- `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md`
|
||||
- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55).
|
||||
- `references/nip46-remote-signer.md`, `references/nip55-android-signer.md`
|
||||
- **`auth-signers/`** — `NostrSigner` abstraction across internal, NIP-46 remote, and NIP-55 external signers.
|
||||
|
||||
### Updated skills directory (Phase 2)
|
||||
```
|
||||
- android-expert/
|
||||
- auth-signers/ (new)
|
||||
- account-state/ (new)
|
||||
- compose-expert/
|
||||
- desktop-expert/
|
||||
- feed-patterns/ (new)
|
||||
- find-missing-translations/
|
||||
- find-non-lambda-logs/
|
||||
- gradle-expert/
|
||||
- kotlin-coroutines/
|
||||
- kotlin-expert/
|
||||
- kotlin-multiplatform/
|
||||
- nostr-expert/
|
||||
- quartz-integration/
|
||||
- relay-client/ (new)
|
||||
- quartz-kmp.md (breadcrumb pointer)
|
||||
```
|
||||
## Phase 3 (2026-06): Fable 5 config review
|
||||
|
||||
### Still deferred
|
||||
- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo.
|
||||
Instructions written to coach older models were removed now that the model
|
||||
handles them natively; stale references fixed:
|
||||
|
||||
- `CLAUDE.md`: deleted the 5-step skill-approval "Workflow" section
|
||||
(skills auto-trigger; the approval loop blocked autonomous sessions);
|
||||
condensed "Verify, Don't Guess" to the repo-specific tooling pointers and
|
||||
dropped references to `/bugfix` / `/investigate` (never committed to this
|
||||
repo); replaced the mandated emoji survey matrix with one-line guidance.
|
||||
- `android-expert` and `desktop-expert` SKILL.md gained YAML frontmatter —
|
||||
without it they were listed without trigger descriptions and never
|
||||
auto-invoked.
|
||||
- `commands/extract.md`: fixed stale `shared-ui/` module name → `commons/`.
|
||||
- `skills/quartz-kmp.md` breadcrumb deleted (KMP migration long complete;
|
||||
`quartz-integration` and `nostr-expert` cover its pointers).
|
||||
- Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified
|
||||
Kotlin files, so Q&A-only turns no longer pay a Gradle invocation.
|
||||
|
||||
Second audit pass (every concrete claim checked against the code; `amy-expert`,
|
||||
`find-missing-translations`, `find-non-lambda-logs`, and the vendored technique
|
||||
skills verified clean):
|
||||
|
||||
- `auth-signers`: bunker login entry point corrected — `NostrSignerRemote.fromBunkerUri(...)`
|
||||
+ `connect()`, not the nonexistent `RemoteSignerManager.connect(url)`.
|
||||
- `nostr-expert`: NIP count 57 → 80+ packages; `Nip44v2.encrypt/decrypt`
|
||||
static-object snippet replaced with the real `Nip44` facade
|
||||
(returns `EncryptedInfo`, `encodePayload()` for event content); invented
|
||||
`Nip19.npubEncode`/`Nip19Result` API replaced with the real `ByteArray`
|
||||
extensions (`toNpub()`, …), entity `create()` helpers, and
|
||||
`Nip19Parser.uriToRoute()?.entity`.
|
||||
- `nostr-expert/references/nip-catalog.md`: heading count (60+8) replaced with
|
||||
actual package counts (87 + 23 experimental) and a ground-truth pointer.
|
||||
- `quartz-integration`: NIP-19 decode example rewritten for
|
||||
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
|
||||
Event Store section corrected from "Android only" to commonMain/all platforms
|
||||
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.
|
||||
|
||||
@@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens
|
||||
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
|
||||
|
||||
# Create local.properties if missing
|
||||
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
|
||||
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")"
|
||||
LOCAL_PROPS="$REPO_ROOT/local.properties"
|
||||
if [ ! -f "$LOCAL_PROPS" ]; then
|
||||
echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Stop hook: format Kotlin sources, but only when the working tree actually
|
||||
# has modified Kotlin files — skips the Gradle invocation on Q&A-only turns.
|
||||
set -uo pipefail
|
||||
|
||||
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
|
||||
|
||||
if git status --porcelain 2>/dev/null | grep -qE '[.]kts?$'; then
|
||||
./gradlew spotlessApply 2>/dev/null
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -16,7 +16,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "./gradlew spotlessApply 2>/dev/null",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: account-state
|
||||
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
|
||||
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
|
||||
---
|
||||
|
||||
# Account & Local Cache State
|
||||
@@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus
|
||||
Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change
|
||||
│
|
||||
▼
|
||||
Account observes relevant kinds (3, 10002, 10000, …)
|
||||
Account state objects pin the relevant addressable notes
|
||||
│
|
||||
▼
|
||||
Account StateFlow updates (followList, relays, mutes, …)
|
||||
State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …)
|
||||
│
|
||||
▼
|
||||
ViewModels collect
|
||||
@@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
|
||||
Composables render
|
||||
```
|
||||
|
||||
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering.
|
||||
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering.
|
||||
|
||||
## Key Files
|
||||
|
||||
### `Account.kt` (singleton-per-session)
|
||||
|
||||
- `class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind:
|
||||
- `followListFlow` ← NIP-02 ContactList (kind 3)
|
||||
- `relayListFlow` ← NIP-65 RelayList (kind 10002)
|
||||
- `muteListFlow` ← NIP-51 Lists (kind 10000)
|
||||
- `bookmarkListFlow` ← NIP-51 Lists (kind 10003)
|
||||
- `topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc.
|
||||
- Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, …
|
||||
- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event.
|
||||
- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
|
||||
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
|
||||
- `class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind:
|
||||
- `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3)
|
||||
- `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, …
|
||||
- `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000)
|
||||
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
|
||||
- Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders.
|
||||
- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`.
|
||||
- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event.
|
||||
- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
|
||||
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
|
||||
|
||||
### `LocalCache.kt`
|
||||
|
||||
@@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
|
||||
Typical recipe:
|
||||
|
||||
1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list).
|
||||
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed.
|
||||
3. In `Account.kt`:
|
||||
- Add a private `MutableStateFlow<T>`.
|
||||
- Expose a `StateFlow<T>` read view.
|
||||
- Subscribe to the relay (via the relayClient subscription pattern — see `relay-client` skill).
|
||||
- On event arrival, parse with the quartz event class and update the flow.
|
||||
- Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes.
|
||||
4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
|
||||
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one):
|
||||
- Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`.
|
||||
- Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`.
|
||||
- Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed.
|
||||
3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill).
|
||||
4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
|
||||
|
||||
## `LocalCache` vs `Account` Flow — Which to Read?
|
||||
|
||||
- **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`.
|
||||
- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.<featureFlow>`.
|
||||
- **Are you rendering "my follows", "my mutes", "my relays"?** → `account.<feature>.flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`).
|
||||
- **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache.
|
||||
- **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object.
|
||||
- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it.
|
||||
- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it.
|
||||
- **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state.
|
||||
- **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident.
|
||||
|
||||
## References
|
||||
|
||||
- `references/account-state-flow.md` — catalog of major `Account` StateFlow properties and their source kinds.
|
||||
- `references/account-state-flow.md` — catalog of major `Account` state objects and their source kinds.
|
||||
- `references/local-cache.md` — `LocalCache` internals, insertion path, indexes.
|
||||
- Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events).
|
||||
|
||||
@@ -1,93 +1,94 @@
|
||||
# Account StateFlow Catalog
|
||||
# Account State-Object Catalog
|
||||
|
||||
`Account.kt` exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package.
|
||||
`Account.kt` composes ~50 **feature state objects** (not raw StateFlow
|
||||
properties). Each object pins its backing addressable note in `LocalCache`,
|
||||
exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and
|
||||
offers suspend mutation helpers that return signed events. Consumers read
|
||||
`account.<property>.flow`.
|
||||
|
||||
(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.)
|
||||
(Property and class names are exact as of the current `Account.kt`; if one has
|
||||
been renamed, grep `Account.kt` for the class name.)
|
||||
|
||||
## Identity & Contacts
|
||||
|
||||
| Flow | Kind(s) | Source | Model package |
|
||||
|------|---------|--------|---------------|
|
||||
| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` |
|
||||
| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` |
|
||||
| `followersFlow` | derived | LocalCache scan | — |
|
||||
| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` |
|
||||
| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` |
|
||||
| Account property | State class | Kind | Package |
|
||||
|------------------|-------------|------|---------|
|
||||
| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` |
|
||||
| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` |
|
||||
| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` |
|
||||
| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` |
|
||||
| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` |
|
||||
| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` |
|
||||
| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` |
|
||||
|
||||
## Relays & Connectivity
|
||||
## Relay Lists
|
||||
|
||||
| Flow | Kind | Package |
|
||||
|------|------|---------|
|
||||
| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` |
|
||||
| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` |
|
||||
| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` |
|
||||
| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` |
|
||||
| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` |
|
||||
| Account property | State class | Kind | Package |
|
||||
|------------------|-------------|------|---------|
|
||||
| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` |
|
||||
| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` |
|
||||
| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` |
|
||||
| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` |
|
||||
| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` |
|
||||
| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` |
|
||||
| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` |
|
||||
|
||||
Derived relay views (merge several of the above): `homeRelays`
|
||||
(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`,
|
||||
`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`,
|
||||
`defaultGlobalRelays`.
|
||||
|
||||
## Content Lists
|
||||
|
||||
| Flow | Kind | Package |
|
||||
|------|------|---------|
|
||||
| `bookmarkListFlow` | 10003 | `model/nip51Lists/` |
|
||||
| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` |
|
||||
| `topNavFeedsFlow` | custom | `model/topNavFeeds/` |
|
||||
| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` |
|
||||
| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` |
|
||||
| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` |
|
||||
| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` |
|
||||
| Account property | State class | Kind | Package |
|
||||
|------------------|-------------|------|---------|
|
||||
| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` |
|
||||
| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` |
|
||||
| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` |
|
||||
| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` |
|
||||
| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` |
|
||||
| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` |
|
||||
| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` |
|
||||
| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` |
|
||||
| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` |
|
||||
| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` |
|
||||
| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` |
|
||||
|
||||
## Messaging
|
||||
## Other Feature State
|
||||
|
||||
| Flow | Kind | Package |
|
||||
|------|------|---------|
|
||||
| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` |
|
||||
| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` |
|
||||
| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` |
|
||||
| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` |
|
||||
| Account property | State class | Purpose | Package |
|
||||
|------------------|-------------|---------|---------|
|
||||
| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` |
|
||||
| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` |
|
||||
| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` |
|
||||
| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` |
|
||||
| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — |
|
||||
|
||||
## Settings & UI
|
||||
|
||||
| Flow | Source | Package |
|
||||
|------|--------|---------|
|
||||
| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` |
|
||||
| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` |
|
||||
| `privacyOptionsFlow` | local | `model/privacyOptions/` |
|
||||
| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` |
|
||||
| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` |
|
||||
|
||||
## Advanced / Derived
|
||||
|
||||
| Flow | Purpose | Package |
|
||||
|------|---------|---------|
|
||||
| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` |
|
||||
| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` |
|
||||
| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` |
|
||||
| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` |
|
||||
| `serverListFlow` | media/upload servers | `model/serverList/` |
|
||||
Note the migration direction: newer/extracted state classes live in
|
||||
`commons/src/commonMain/.../commons/model/`, the rest still in
|
||||
`amethyst/src/main/java/.../model/`. Check both when looking for one.
|
||||
|
||||
## Publishing Mutations
|
||||
|
||||
Every flow has a corresponding mutation method on `Account` that:
|
||||
State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`,
|
||||
`BookmarkListState` add/remove) **build and sign** the updated replaceable
|
||||
event via the quartz event class (`XEvent.add / remove / create`) and return
|
||||
it. The caller (usually a method on `Account`) is responsible for sending it
|
||||
through the client. Decryption results are cached in the paired
|
||||
`…DecryptionCache` so re-renders don't re-decrypt.
|
||||
|
||||
1. Constructs the updated event using a `TagArrayBuilder`.
|
||||
2. Signs through the injected `NostrSigner` (see `auth-signers` skill).
|
||||
3. Publishes to the appropriate relay set.
|
||||
4. Updates the local StateFlow *before* relay round-trip (optimistic).
|
||||
5. Rolls back / reconciles on failure.
|
||||
## When a State Object Doesn't Exist Yet
|
||||
|
||||
Examples of mutation methods (names may vary slightly in current code):
|
||||
- `follow(pubKey)` / `unfollow(pubKey)`
|
||||
- `addBookmark(noteId)` / `removeBookmark(noteId)`
|
||||
- `mute(pubKey)` / `unmute(pubKey)`
|
||||
- `updateRelayList(...)`, `updateDmRelayList(...)`
|
||||
- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)`
|
||||
If you're adding a new NIP that's user-scoped, follow the pattern (full recipe
|
||||
in `SKILL.md`):
|
||||
|
||||
## When a Flow Doesn't Exist Yet
|
||||
|
||||
If you're adding a new NIP that's user-scoped, follow the pattern:
|
||||
|
||||
1. Create `model/nipXX…/` with an optional `ExtState`/builder class.
|
||||
2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow<T> = _xFlow.asStateFlow()` to `Account`.
|
||||
3. Wire the relay subscription (see `relay-client` skill).
|
||||
4. Add the mutation method that builds, signs, and publishes.
|
||||
5. Update persistence if the setting is local-only (`AccountSettings.kt`).
|
||||
1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or
|
||||
`BookmarkListState` (plain).
|
||||
2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose
|
||||
`val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`.
|
||||
3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if
|
||||
private), and wire the relay subscription (see `relay-client` skill).
|
||||
4. Add mutation helpers that build, sign, and return the event; publish from
|
||||
the calling site.
|
||||
5. Use `AccountSettings` for the local backup copy if the list must survive
|
||||
relay loss.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: android-expert
|
||||
description: Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform.
|
||||
---
|
||||
|
||||
# android-expert
|
||||
|
||||
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
|
||||
@@ -739,14 +744,14 @@ fun SignerIntegration(accountViewModel: AccountViewModel) {
|
||||
```gradle
|
||||
android {
|
||||
namespace = 'com.vitorpamplona.amethyst'
|
||||
compileSdk = 36
|
||||
compileSdk = 37 // from libs.versions.toml android-compileSdk — check there, it drifts
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = 26 // Android 8.0 (Oreo)
|
||||
targetSdk = 36 // Android 15
|
||||
targetSdk = 37 // android-targetSdk in libs.versions.toml
|
||||
versionCode = 447
|
||||
versionName = "1.11.0"
|
||||
versionName = generateVersionName(libs.versions.app.get(), rootDir)
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -75,7 +75,7 @@ Most feature code should go through `Account`'s mutation methods (`account.sendR
|
||||
Entry points:
|
||||
|
||||
- **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`.
|
||||
- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`.
|
||||
- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake.
|
||||
- **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`.
|
||||
|
||||
The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: desktop-expert
|
||||
description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform.
|
||||
---
|
||||
|
||||
# Desktop Expert
|
||||
|
||||
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
|
||||
@@ -69,7 +74,7 @@ fun main() = application {
|
||||
- `rememberWindowState()` manages size/position
|
||||
- `onCloseRequest` handles window close
|
||||
|
||||
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — `fun main()` at L172, `application {` at L186, top-level `Window` at L229, `MenuBar` at L234.
|
||||
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol).
|
||||
|
||||
---
|
||||
|
||||
@@ -275,9 +280,9 @@ Row(Modifier.fillMaxSize()) {
|
||||
}
|
||||
```
|
||||
|
||||
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces.
|
||||
**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases.
|
||||
|
||||
**Why NavigationRail?**
|
||||
**Why a left sidebar?**
|
||||
- Desktop has horizontal space (1200+ dp width)
|
||||
- Vertical sidebar is standard desktop pattern
|
||||
- Always visible (no tabs hidden)
|
||||
@@ -285,7 +290,7 @@ Row(Modifier.fillMaxSize()) {
|
||||
|
||||
**Android comparison:**
|
||||
- Android: `BottomNavigationBar` (horizontal, bottom)
|
||||
- Desktop: `NavigationRail` (vertical, left)
|
||||
- Desktop: left vertical sidebar (`MainSidebar`)
|
||||
|
||||
### Multi-Pane Layouts
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
|
||||
|
||||
---
|
||||
|
||||
## Desktop: NavigationRail
|
||||
## Desktop: Left Sidebar
|
||||
|
||||
### Current Implementation
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+).
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: feed-patterns
|
||||
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
|
||||
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
|
||||
---
|
||||
|
||||
# Feed Patterns
|
||||
@@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
|
||||
│ ◄── ChatroomFeedViewModel │
|
||||
│ ◄── MarmotGroupFeedViewModel │
|
||||
│ │
|
||||
│ FeedContentState — the flow the UI collects │
|
||||
│ │
|
||||
│ commons/.../ui/feeds/ (shared, KMP) │
|
||||
│ IFeedFilter / FeedFilter<T> (abstract base) │
|
||||
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
|
||||
│ ChangesFlowFilter │
|
||||
│ FeedContentState, FeedState — the flow the UI collects │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ uses
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │
|
||||
│ FeedFilter<T> (abstract) │
|
||||
│ amethyst/.../ui/dal/ (Android-only additions) │
|
||||
│ AdditiveComplexFeedFilter<T, U> │
|
||||
│ ChangesFlowFilter │
|
||||
│ FilterByListParams │
|
||||
│ DefaultFeedOrder │
|
||||
│ DefaultFeedOrder (Note/Event/Card comparators) │
|
||||
│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │
|
||||
│ back-compat typealiases re-exporting commons) │
|
||||
│ │
|
||||
│ Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter, │
|
||||
│ BookmarkListFeedFilter, NotificationFeedFilter, … │
|
||||
│ Concrete feeds: HomeNewThreadFeedFilter, │
|
||||
│ HashtagFeedFilter, NotificationFeedFilter, … live in │
|
||||
│ feature folders under ui/screen/loggedIn/*/dal/ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ reads
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ model/LocalCache.kt + Account.<featureFlow> │
|
||||
│ model/LocalCache.kt + account.<feature>.flow │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
|
||||
- **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed.
|
||||
- **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing.
|
||||
|
||||
### Android DAL (the filters)
|
||||
### Shared filter bases (commons)
|
||||
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
|
||||
|
||||
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
|
||||
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
|
||||
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query.
|
||||
- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects.
|
||||
|
||||
### Android DAL (additions on top)
|
||||
|
||||
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`:
|
||||
|
||||
- **`FeedFilters.kt`** — `abstract class FeedFilter<T>`. Has `feed(): List<T>` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache).
|
||||
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything.
|
||||
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query.
|
||||
- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters.
|
||||
- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging).
|
||||
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`: like `AdditiveFeedFilter` but the incoming items (`Set<U>`) are a different type than the list rows (`T`).
|
||||
- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters.
|
||||
- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`.
|
||||
- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here.
|
||||
|
||||
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`.
|
||||
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature `dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`.
|
||||
|
||||
## Adding a New Feed
|
||||
|
||||
1. **Define the filter.** Extend `AdditiveComplexFeedFilter<Note, Set<HexKey>>` (or plain `FeedFilter<Note>` if additivity doesn't matter). Implement:
|
||||
1. **Define the filter.** Extend `AdditiveFeedFilter<Note>` (or plain `FeedFilter<Note>` if additivity doesn't matter; `AdditiveComplexFeedFilter<T, U>` if incoming items differ in type from list rows). Implement:
|
||||
- `feedKey()` — stable identity (e.g. hashtag name, account pubkey).
|
||||
- `feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list.
|
||||
- `limit()` — pagination hint.
|
||||
- If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
|
||||
- If additive: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
|
||||
2. **Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`.
|
||||
3. **Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes.
|
||||
4. **Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`.
|
||||
@@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
|
||||
|
||||
## Filter Sharing (Android vs Desktop)
|
||||
|
||||
- `FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/` — **Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`.
|
||||
- ViewModels are in `commons/commonMain/` — **shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared.
|
||||
- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it.
|
||||
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**.
|
||||
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
|
||||
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
|
||||
- **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates.
|
||||
- **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts.
|
||||
- **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan.
|
||||
- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc.
|
||||
- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams` exclusion paths rather than filtering post-hoc.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt
|
||||
| If… | Use |
|
||||
|-----|-----|
|
||||
| Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter<Note>` |
|
||||
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter<Note, Set<Note>>` |
|
||||
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter<Note>` |
|
||||
| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter<T, U>` (Android-only) |
|
||||
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `ListChangeFeedViewModel` |
|
||||
| The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) |
|
||||
|
||||
All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
|
||||
The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`.
|
||||
|
||||
## 2. Write the Filter
|
||||
|
||||
@@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
|
||||
class HashtagFeedFilter(
|
||||
private val accountViewModel: AccountViewModel,
|
||||
private val hashtag: String,
|
||||
) : AdditiveComplexFeedFilter<Note, Set<Note>>() {
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String = "Hashtag-$hashtag"
|
||||
|
||||
@@ -28,7 +29,7 @@ class HashtagFeedFilter(
|
||||
override fun feed(): List<Note> {
|
||||
val params = FilterByListParams.create(
|
||||
excludeMuted = true,
|
||||
hiddenUsers = accountViewModel.hiddenUsersFlow.value,
|
||||
hiddenUsers = account.hiddenUsers.flow.value,
|
||||
)
|
||||
return LocalCache.hashtagIndex[hashtag]
|
||||
.orEmpty()
|
||||
@@ -69,7 +70,7 @@ class HashtagFeedViewModel(
|
||||
)
|
||||
```
|
||||
|
||||
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`.
|
||||
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`.
|
||||
|
||||
## 4. Wire Invalidation
|
||||
|
||||
@@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan
|
||||
```kotlin
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
accountViewModel.muteListFlow.collect { invalidateAll() }
|
||||
account.muteList.flow.collect { invalidateAll() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro
|
||||
|
||||
# Gradle Expert
|
||||
|
||||
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
|
||||
Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
|
||||
|
||||
## Build Architecture Mental Model
|
||||
|
||||
Think of this project as **4 layers**:
|
||||
The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses):
|
||||
|
||||
```
|
||||
┌─────────────┬─────────────┐
|
||||
@@ -165,11 +165,11 @@ implementation(libs.jna)
|
||||
|
||||
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
|
||||
|
||||
**Current project config:**
|
||||
**Current project config** (always re-check `gradle/libs.versions.toml` — these drift):
|
||||
```toml
|
||||
composeMultiplatform = "1.9.3" # Plugin + runtime
|
||||
composeBom = "2025.12.01" # AndroidX Compose BOM
|
||||
kotlin = "2.3.0"
|
||||
composeMultiplatform = "1.11.0" # Plugin + runtime
|
||||
composeBom = "2026.05.01" # AndroidX Compose BOM
|
||||
kotlin = "2.3.21"
|
||||
```
|
||||
|
||||
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
|
||||
@@ -3,46 +3,50 @@
|
||||
## Visual Hierarchy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Root Project │
|
||||
│ (Amethyst) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :commons │
|
||||
│ (KMP UI) │
|
||||
│ │
|
||||
│ jvmAndroid │
|
||||
│ / \ │
|
||||
│ jvm android│
|
||||
└─────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :quartz │
|
||||
│(KMP Library)│
|
||||
│ │
|
||||
│ commonMain │
|
||||
│ │ │
|
||||
│ jvmAndroid │
|
||||
│ / | \ │
|
||||
│jvm and ios │
|
||||
└─────────────┘
|
||||
Apps / harnesses Libraries
|
||||
┌─────────────┐ ┌─────────────┐ ┌────────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │
|
||||
└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘
|
||||
│ │ └───────┼────┐ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ ▼ ▼ │ │ ▼ │
|
||||
│ ┌────────────────┐ │ │ (androidTest │
|
||||
│ │ :commons │◄┼──┼──only) │
|
||||
│ │ (KMP UI) │ │ │ │
|
||||
│ └───────┬────────┘ │ │ │
|
||||
│ │ ▲ │ │ │
|
||||
▼ │ │ │ │ │
|
||||
┌──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │
|
||||
│ :nestsClient │ │ │ :cli │ │:geode │ │
|
||||
│ (KMP, MoQ) │ │ │(JVM) │ │(JVM │ │
|
||||
└──┬────────┬──┘ │ └──┬───┘ │relay) │ │
|
||||
│ │ │ │ └───┬───┘ │
|
||||
▼ │ │ │ │ │
|
||||
┌────────┐ │ │ │ │ │
|
||||
│ :quic │ │ │ │ │ │
|
||||
│ (KMP) │ │ │ │ │ │
|
||||
└───┬────┘ │ │ │ │ │
|
||||
│ ▲ │ │ │ │ │
|
||||
│ └── :quic-interop │ │ │
|
||||
▼ ▼ ▼ ▼ ▼ ▼
|
||||
┌──────────────────────────────┐
|
||||
│ :quartz │
|
||||
│ (KMP Library) │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
Verified edges (from each module's `build.gradle.kts`):
|
||||
|
||||
- `:amethyst` → `:quartz`, `:commons`, `:nestsClient`
|
||||
- `:desktopApp` → `:quartz`, `:commons`
|
||||
- `:benchmark` → `:quartz`, `:commons` (androidTest only)
|
||||
- `:cli` → `:quartz`, `:commons`
|
||||
- `:geode` → `:quartz` (api + testFixtures)
|
||||
- `:nestsClient` → `:quartz` (api), `:quic`
|
||||
- `:quic` → `:quartz` (api)
|
||||
- `:quic-interop` → `:quic` (project dir: `quic/interop`)
|
||||
|
||||
## Module Details
|
||||
|
||||
### :quartz (KMP Nostr Library)
|
||||
@@ -86,11 +90,41 @@
|
||||
**Type:** Android Library
|
||||
**Targets:** Android
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`
|
||||
- Modules: `:commons`, `:quartz` (androidTest only)
|
||||
- External: AndroidX Benchmark
|
||||
|
||||
**Role:** Performance benchmarking for Android builds
|
||||
|
||||
### :cli (Amy CLI)
|
||||
**Type:** JVM Application (no Compose)
|
||||
**Dependencies:** `:quartz`, `:commons`
|
||||
|
||||
**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill)
|
||||
|
||||
### :geode (Relay Server)
|
||||
**Type:** JVM Application (Ktor)
|
||||
**Dependencies:** `:quartz` (api + testFixtures)
|
||||
|
||||
**Role:** Standalone Nostr relay built on quartz's relay-server code
|
||||
|
||||
### :quic (QUIC Transport)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Dependencies:** `:quartz` (api)
|
||||
|
||||
**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ
|
||||
|
||||
### :nestsClient (Audio Rooms)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Dependencies:** `:quartz` (api), `:quic`
|
||||
|
||||
**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature
|
||||
|
||||
### :quic-interop (Interop Harness)
|
||||
**Type:** JVM Application (project dir `quic/interop`)
|
||||
**Dependencies:** `:quic`
|
||||
|
||||
**Role:** QUIC interop-runner test client
|
||||
|
||||
## Dependency Flow Patterns
|
||||
|
||||
### Desktop Build Chain
|
||||
@@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant
|
||||
implementation(compose.ui) // Compose Multiplatform BOM
|
||||
implementation(compose.material3)
|
||||
|
||||
// Version catalog alignment
|
||||
composeMultiplatform = "1.9.3"
|
||||
composeBom = "2025.12.01" // AndroidX Compose
|
||||
// Version catalog alignment (re-check libs.versions.toml — these drift)
|
||||
composeMultiplatform = "1.11.0"
|
||||
composeBom = "2026.05.01" // AndroidX Compose
|
||||
```
|
||||
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
|
||||
|
||||
|
||||
@@ -807,6 +807,5 @@ Passing lambda to function?
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-12-30
|
||||
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
|
||||
**Version:** 1.0.1
|
||||
**Last Updated:** 2026-06-10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: nostr-expert
|
||||
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
|
||||
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
|
||||
---
|
||||
|
||||
# Nostr Protocol Expert (Quartz Implementation)
|
||||
@@ -313,26 +313,24 @@ class LocalSigner(private val privateKey: ByteArray) : ISigner {
|
||||
### Encryption (NIP-44)
|
||||
|
||||
```kotlin
|
||||
// Modern encryption (ChaCha20-Poly1305)
|
||||
object Nip44v2 {
|
||||
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade
|
||||
// (nip44Encryption/Nip44.kt — picks the current version, decrypts any)
|
||||
object Nip44 {
|
||||
fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo
|
||||
fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String
|
||||
}
|
||||
|
||||
// Usage
|
||||
val encrypted = Nip44v2.encrypt(
|
||||
plaintext = "Secret message",
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = recipientPubKey
|
||||
)
|
||||
val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey)
|
||||
val payload = encrypted.encodePayload() // base64 string for event content
|
||||
|
||||
val decrypted = Nip44v2.decrypt(
|
||||
ciphertext = encrypted,
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = senderPubKey
|
||||
)
|
||||
val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey)
|
||||
```
|
||||
|
||||
Most code should not call `Nip44` directly — go through
|
||||
`signer.nip44Encrypt(plaintext, toPublicKey)` / `signer.nip44Decrypt(ciphertext, fromPublicKey)`
|
||||
so remote/external signers keep working.
|
||||
|
||||
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
|
||||
|
||||
### NIP-04 (Deprecated)
|
||||
@@ -345,44 +343,34 @@ object Nip04 {
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
|
||||
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
|
||||
|
||||
## Bech32 Encoding (NIP-19)
|
||||
|
||||
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
|
||||
TLV entities carry relay hints via `create()` helpers on the entity classes in
|
||||
`nip19Bech32/entities/`. Decoding goes through `Nip19Parser`, whose
|
||||
`uriToRoute()` returns a `ParseReturn?` wrapping the parsed `Entity`.
|
||||
|
||||
```kotlin
|
||||
object Nip19 {
|
||||
// Encode
|
||||
fun npubEncode(pubkey: HexKey): String // npub1...
|
||||
fun nsecEncode(privateKey: ByteArray): String // nsec1...
|
||||
fun noteEncode(eventId: HexKey): String // note1...
|
||||
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
|
||||
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
|
||||
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
|
||||
// Encode simple entities: ByteArray extensions
|
||||
val npub = pubkeyBytes.toNpub() // "npub1..."
|
||||
val nsec = privKeyBytes.toNsec() // "nsec1..."
|
||||
val note = eventIdBytes.toNote() // "note1..."
|
||||
|
||||
// Decode
|
||||
fun decode(bech32: String): Nip19Result
|
||||
}
|
||||
|
||||
sealed class Nip19Result {
|
||||
data class NPub(val hex: HexKey) : Nip19Result()
|
||||
data class NSec(val hex: HexKey) : Nip19Result()
|
||||
data class Note(val hex: HexKey) : Nip19Result()
|
||||
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
|
||||
}
|
||||
// Encode TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
|
||||
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
|
||||
val nprofile = NProfile.create(pubkeyHex, relays)
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
// Encode
|
||||
val npub = Nip19.npubEncode(pubkeyHex)
|
||||
// Output: "npub1..."
|
||||
|
||||
// Decode
|
||||
when (val result = Nip19.decode(npub)) {
|
||||
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
|
||||
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
|
||||
// Decode (also accepts nostr: URIs); entity types live in nip19Bech32.entities
|
||||
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
|
||||
is NPub -> println("Pubkey: ${entity.hex}")
|
||||
is NEvent -> println("Event: ${entity.hex}, relays: ${entity.relay}")
|
||||
is NAddress -> println("Address: ${entity.aTag()}")
|
||||
null -> println("not a valid bech32 entity")
|
||||
else -> println("Other type")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
|
||||
# NIP Catalog: Quartz NIP Packages
|
||||
|
||||
As of 2026-06 Quartz has **87 standard `nip*` packages** plus **23 packages
|
||||
under `experimental/`**. The categorized list below may lag behind —
|
||||
`ls quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/` is ground truth.
|
||||
|
||||
## Standard NIPs by Category
|
||||
|
||||
|
||||
@@ -447,23 +447,28 @@ val textNote = Event.fromJson(json) as? TextNoteEvent
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
|
||||
// Decode any bech32 entity
|
||||
val result = Nip19Parser.uriToRoute("npub1abc...")
|
||||
// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null
|
||||
|
||||
when (val r = Nip19Parser.uriToRoute(input)) {
|
||||
is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}")
|
||||
is Nip19Parser.Return.Note -> println("event id: ${r.hex}")
|
||||
is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}")
|
||||
is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}")
|
||||
is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}")
|
||||
null -> println("not a valid bech32 entity")
|
||||
else -> {}
|
||||
// Decode any bech32 entity (plain or nostr:-prefixed).
|
||||
// uriToRoute() returns Nip19Parser.ParseReturn? — the parsed Entity is in .entity
|
||||
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
|
||||
is NPub -> println("pubkey: ${entity.hex}")
|
||||
is NNote -> println("event id: ${entity.hex}")
|
||||
is NEvent -> println("event: ${entity.hex}, relays: ${entity.relay}")
|
||||
is NProfile -> println("profile: ${entity.hex}")
|
||||
is NAddress -> println("address: ${entity.aTag()}")
|
||||
null -> println("not a valid bech32 entity")
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// The parser also handles nostr: URI scheme
|
||||
val result = Nip19Parser.uriToRoute("nostr:npub1abc...")
|
||||
// Encode: ByteArray extensions from nip19Bech32/ByteArrayExt.kt
|
||||
val npub = pubkeyBytes.toNpub() // also toNsec(), toNote(), ...
|
||||
// TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
|
||||
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -601,21 +606,22 @@ In Xcode: drag & drop the `.xcframework` into your project, then use from Swift
|
||||
|
||||
---
|
||||
|
||||
## 14. Event Store (Android only)
|
||||
## 14. Event Store (SQLite, all platforms)
|
||||
|
||||
SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62):
|
||||
SQLite-backed storage in `commonMain` (JVM, Android, iOS — uses the bundled
|
||||
androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50,
|
||||
NIP-62). All operations are `suspend`:
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.store.EventStore
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
|
||||
val store = EventStore()
|
||||
val store = EventStore() // default DB file "events.db"
|
||||
|
||||
// Insert
|
||||
store.insert(event)
|
||||
|
||||
// Query
|
||||
val events = store.query(
|
||||
val events = store.query<Event>(
|
||||
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
|
||||
)
|
||||
|
||||
@@ -623,7 +629,7 @@ val events = store.query(
|
||||
val count = store.count(Filter(kinds = listOf(1)))
|
||||
|
||||
// Full-text search (NIP-50)
|
||||
val results = store.query(Filter(search = "bitcoin"))
|
||||
val results = store.query<Event>(Filter(search = "bitcoin"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Quartz KMP (Legacy Skill — Migration Complete)
|
||||
|
||||
> The KMP migration of Quartz is **complete**. This file is kept for historical reference.
|
||||
>
|
||||
> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead.
|
||||
> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill.
|
||||
|
||||
## What was migrated
|
||||
|
||||
The Quartz library was successfully converted from Android-only to full KMP supporting:
|
||||
- **commonMain** — All Nostr protocol logic, events, filters, tags
|
||||
- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers
|
||||
- **androidMain** — SQLite event store, NIP-55 Android signer
|
||||
- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm)
|
||||
- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`)
|
||||
|
||||
## Current artifact
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.11.0
|
||||
```
|
||||
|
||||
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
||||
@@ -24,17 +24,23 @@ relayClient/
|
||||
├── assemblers/ # "Given these inputs, build this relay Filter"
|
||||
│ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys
|
||||
│ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids
|
||||
│ └── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
|
||||
│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
|
||||
│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt
|
||||
├── composeSubscriptionManagers/
|
||||
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
|
||||
│ ├── MutableComposeSubscriptionManager.kt # reference impl
|
||||
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
|
||||
├── eoseManagers/ # EOSE tracking per subscription
|
||||
│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager
|
||||
├── nip17Dm/ # gift-wrap DM plumbing
|
||||
│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt
|
||||
├── preload/
|
||||
│ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting
|
||||
│ └── MetadataRateLimiter.kt # token-bucket-ish limiter
|
||||
└── subscriptions/
|
||||
└── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
|
||||
├── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
|
||||
├── LifecycleAwareKeyDataSourceSubscription.kt
|
||||
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt
|
||||
```
|
||||
|
||||
## Core Concept: `Subscribable<T>`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-6
@@ -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())
|
||||
|
||||
|
||||
+38
@@ -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),
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+287
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+11
-3
@@ -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(
|
||||
|
||||
+32
-1
@@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -43,6 +46,7 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
|
||||
@@ -116,13 +121,23 @@ fun WatchResponses(
|
||||
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
if (suggestions.isNotEmpty()) {
|
||||
// Snapshot once per result list, not per row.
|
||||
val priority = remember(suggestions) { userSuggestions.priorityPubkeys() }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(top = 10.dp),
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item ->
|
||||
UserLine(item, accountViewModel, trailingContent) { onSelect(item) }
|
||||
val trailing =
|
||||
trailingContent
|
||||
?: if (item.pubkeyHex in priority) {
|
||||
{ InThisChatChip() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
UserLine(item, accountViewModel, trailing) { onSelect(item) }
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
@@ -133,6 +148,22 @@ fun WatchResponses(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InThisChatChip() {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.user_suggestion_in_this_chat),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserLine(
|
||||
baseUser: User,
|
||||
|
||||
+31
-1
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
|
||||
@@ -58,10 +59,36 @@ val userUriPrefixes =
|
||||
DualCase("nostr:nprofile"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Moves users whose pubkey is in [priority] to the top of [found],
|
||||
* preserving the relative order everywhere else (stable sort). Reorders
|
||||
* only — it never adds or removes entries, so priority keys whose users
|
||||
* didn't match the search have no effect.
|
||||
*/
|
||||
fun rankPriorityFirst(
|
||||
found: List<User>,
|
||||
priority: Set<HexKey>,
|
||||
): List<User> =
|
||||
if (priority.isEmpty()) {
|
||||
found
|
||||
} else {
|
||||
found.sortedByDescending { it.pubkeyHex in priority }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the @-mention autocomplete dropdown: searches the local cache,
|
||||
* relays, and NIP-05 identifiers for the word currently being typed.
|
||||
*
|
||||
* [priorityPubkeys] is a live supplier of pubkeys to rank first in the
|
||||
* results — pass the current conversation's participants (NIP-17 room
|
||||
* users, public-chat authors, MLS group members, …) so they beat
|
||||
* network-wide matches. Ranking only; it never filters anyone out.
|
||||
*/
|
||||
@Stable
|
||||
class UserSuggestionState(
|
||||
val account: Account,
|
||||
val nip05Client: INip05Client,
|
||||
val priorityPubkeys: () -> Set<HexKey> = { emptySet() },
|
||||
) {
|
||||
val invalidations = MutableStateFlow(0)
|
||||
val currentWord = MutableStateFlow("")
|
||||
@@ -158,7 +185,10 @@ class UserSuggestionState(
|
||||
}
|
||||
if (prefix != null) {
|
||||
logTime("UserSuggestionState Search $prefix version $version") {
|
||||
account.cache.findUsersStartingWith(prefix, account)
|
||||
rankPriorityFirst(
|
||||
account.cache.findUsersStartingWith(prefix, account),
|
||||
priorityPubkeys(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
|
||||
+27
-1
@@ -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(
|
||||
|
||||
+60
-72
@@ -27,16 +27,12 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -50,16 +46,19 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
@@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -96,19 +96,15 @@ fun MarmotGroupChatView(
|
||||
|
||||
WatchLifecycleAndUpdateModel(feedViewModel)
|
||||
|
||||
val chatroom =
|
||||
remember(nostrGroupId) {
|
||||
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
}
|
||||
val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel")
|
||||
newMessageModel.init(accountViewModel)
|
||||
newMessageModel.load(nostrGroupId)
|
||||
|
||||
DisposableEffect(nostrGroupId) {
|
||||
chatroom.markAsRead()
|
||||
newMessageModel.chatroom?.markAsRead()
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
val messageState = remember(nostrGroupId) { TextFieldState() }
|
||||
val replyTo = remember(nostrGroupId) { mutableStateOf<Note?>(null) }
|
||||
|
||||
// Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS
|
||||
// message in the Notifications screen) into the actual Note once it has
|
||||
// landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids.
|
||||
@@ -116,14 +112,14 @@ fun MarmotGroupChatView(
|
||||
LaunchedEffect(replyToInnerNote) {
|
||||
val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote)
|
||||
if (parent != null) {
|
||||
replyTo.value = parent
|
||||
newMessageModel.reply(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (draftMessage != null) {
|
||||
LaunchedEffect(draftMessage) {
|
||||
messageState.setTextAndPlaceCursorAtEnd(draftMessage)
|
||||
newMessageModel.editFromDraft(draftMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +135,7 @@ fun MarmotGroupChatView(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
routeForLastRead = "MarmotGroup/$nostrGroupId",
|
||||
onWantsToReply = { note -> replyTo.value = note },
|
||||
onWantsToReply = { note -> newMessageModel.reply(note) },
|
||||
onWantsToEditDraft = { },
|
||||
)
|
||||
}
|
||||
@@ -148,8 +144,7 @@ fun MarmotGroupChatView(
|
||||
|
||||
MarmotGroupMessageComposer(
|
||||
nostrGroupId = nostrGroupId,
|
||||
messageState = messageState,
|
||||
replyTo = replyTo,
|
||||
newMessageModel = newMessageModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onMessageSent = {
|
||||
@@ -162,49 +157,59 @@ fun MarmotGroupChatView(
|
||||
@Composable
|
||||
fun MarmotGroupMessageComposer(
|
||||
nostrGroupId: HexKey,
|
||||
messageState: TextFieldState,
|
||||
replyTo: MutableState<Note?>,
|
||||
newMessageModel: MarmotNewMessageViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onMessageSent: suspend () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } }
|
||||
val canPost by remember { derivedStateOf { newMessageModel.canPost() } }
|
||||
val context = LocalContext.current
|
||||
|
||||
var isUploading by remember { mutableStateOf(false) }
|
||||
val uploadState =
|
||||
remember {
|
||||
ChatFileUploadState(
|
||||
defaultServer = accountViewModel.account.settings.defaultFileServer,
|
||||
defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload,
|
||||
)
|
||||
}
|
||||
|
||||
// Upload dialog
|
||||
uploadState.multiOrchestrator?.let {
|
||||
MarmotGroupFileUploadDialog(
|
||||
nostrGroupId = nostrGroupId,
|
||||
state = uploadState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onUpload = { onMessageSent() },
|
||||
onCancel = uploadState::reset,
|
||||
)
|
||||
DisposableEffect(nostrGroupId) {
|
||||
onDispose { newMessageModel.userSuggestions?.reset() }
|
||||
}
|
||||
|
||||
replyTo.value?.let {
|
||||
// Upload dialog
|
||||
newMessageModel.uploadState?.let { uploadState ->
|
||||
uploadState.multiOrchestrator?.let {
|
||||
MarmotGroupFileUploadDialog(
|
||||
nostrGroupId = nostrGroupId,
|
||||
state = uploadState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onUpload = { onMessageSent() },
|
||||
onCancel = uploadState::reset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
newMessageModel.replyTo.value?.let {
|
||||
DisplayReplyingToNote(it, accountViewModel, nav) {
|
||||
replyTo.value = null
|
||||
newMessageModel.clearReply()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = EditFieldModifier) {
|
||||
newMessageModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
it,
|
||||
newMessageModel::autocompleteWithUser,
|
||||
accountViewModel,
|
||||
SuggestionListDefaultHeightChat,
|
||||
)
|
||||
}
|
||||
|
||||
ThinPaddingTextField(
|
||||
state = messageState,
|
||||
state = newMessageModel.message,
|
||||
onTextChanged = { newMessageModel.onMessageChanged() },
|
||||
onContentReceived = { uri, mimeType ->
|
||||
uploadState.load(persistentListOf(SelectedMedia(uri, mimeType)))
|
||||
newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
|
||||
},
|
||||
inputTransformation = MentionPreservingInputTransformation,
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = EditFieldBorder,
|
||||
placeholder = {
|
||||
@@ -216,9 +221,7 @@ fun MarmotGroupMessageComposer(
|
||||
leadingIcon = {
|
||||
MarmotGalleryLeadingIcon(
|
||||
isUploading = isUploading,
|
||||
onImageChosen = { selectedMedia ->
|
||||
uploadState.load(selectedMedia)
|
||||
},
|
||||
onImageChosen = newMessageModel::pickedMedia,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
@@ -226,33 +229,18 @@ fun MarmotGroupMessageComposer(
|
||||
isActive = canPost,
|
||||
modifier = EditFieldTrailingIconModifier,
|
||||
) {
|
||||
val text = messageState.text.toString().trim()
|
||||
if (text.isNotEmpty()) {
|
||||
// Capture id+pubKey snapshot under the value? guard so
|
||||
// a slow send doesn't race a user-cleared reply state.
|
||||
val parentEvent = replyTo.value?.event
|
||||
val replyId = parentEvent?.id
|
||||
val replyAuthor = parentEvent?.pubKey
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
accountViewModel.sendMarmotGroupMessage(
|
||||
nostrGroupId = nostrGroupId,
|
||||
text = text,
|
||||
replyToInnerEventId = replyId,
|
||||
replyToInnerAuthorPubKey = replyAuthor,
|
||||
)
|
||||
messageState.clearText()
|
||||
replyTo.value = null
|
||||
onMessageSent()
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"Failed to send message: ${e.message}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
newMessageModel.sendPost()
|
||||
onMessageSent()
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"Failed to send message: ${e.message}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Composition state for the Marmot/MLS group message field, mirroring the
|
||||
* structure of the other chat composers (ChatNewMessageViewModel,
|
||||
* ChannelNewMessageViewModel, NestNewMessageViewModel): @-mention
|
||||
* suggestions, reply state, and file-upload state. Sending goes through
|
||||
* AccountViewModel.sendMarmotGroupMessage, which owns mention tagging.
|
||||
*/
|
||||
@Stable
|
||||
open class MarmotNewMessageViewModel : ViewModel() {
|
||||
lateinit var accountViewModel: AccountViewModel
|
||||
lateinit var account: Account
|
||||
|
||||
var nostrGroupId: HexKey? = null
|
||||
var chatroom: MarmotGroupChatroom? = null
|
||||
|
||||
val message = TextFieldState()
|
||||
val replyTo = mutableStateOf<Note?>(null)
|
||||
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
|
||||
open fun init(accountVM: AccountViewModel) {
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions =
|
||||
UserSuggestionState(
|
||||
accountVM.account,
|
||||
accountVM.nip05ClientBuilder(),
|
||||
priorityPubkeys = { chatroom?.members?.value?.mapTo(mutableSetOf()) { it.pubkey } ?: emptySet() },
|
||||
)
|
||||
|
||||
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
|
||||
}
|
||||
|
||||
open fun load(nostrGroupId: HexKey) {
|
||||
if (this.nostrGroupId != nostrGroupId) {
|
||||
this.nostrGroupId = nostrGroupId
|
||||
this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
this.message.clearText()
|
||||
this.replyTo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun reply(note: Note) {
|
||||
replyTo.value = note
|
||||
}
|
||||
|
||||
fun clearReply() {
|
||||
replyTo.value = null
|
||||
}
|
||||
|
||||
fun editFromDraft(draftMessage: String) {
|
||||
message.setTextAndPlaceCursorAtEnd(draftMessage)
|
||||
}
|
||||
|
||||
fun canPost() = message.text.isNotBlank()
|
||||
|
||||
fun onMessageChanged() {
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
} else {
|
||||
userSuggestions?.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun autocompleteWithUser(item: User) {
|
||||
userSuggestions?.let {
|
||||
it.replaceCurrentWord(message, message.currentWord(), item)
|
||||
it.reset()
|
||||
}
|
||||
}
|
||||
|
||||
fun pickedMedia(media: ImmutableList<SelectedMedia>) {
|
||||
uploadState?.load(media)
|
||||
}
|
||||
|
||||
/** Sends the field's text. Mention rewriting and p-tagging happen in
|
||||
* AccountViewModel.sendMarmotGroupMessage. Throws on send failure so
|
||||
* the caller can surface the error. */
|
||||
suspend fun sendPost() {
|
||||
val groupId = nostrGroupId ?: return
|
||||
val text = message.text.toString().trim()
|
||||
if (text.isEmpty()) return
|
||||
|
||||
// Capture id+pubKey snapshot before suspending so a slow send
|
||||
// doesn't race a user-cleared reply state.
|
||||
val parentEvent = replyTo.value?.event
|
||||
|
||||
accountViewModel.sendMarmotGroupMessage(
|
||||
nostrGroupId = groupId,
|
||||
text = text,
|
||||
replyToInnerEventId = parentEvent?.id,
|
||||
replyToInnerAuthorPubKey = parentEvent?.pubKey,
|
||||
)
|
||||
|
||||
message.clearText()
|
||||
replyTo.value = null
|
||||
userSuggestions?.reset()
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -261,7 +261,12 @@ class ChatNewMessageViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
this.userSuggestions =
|
||||
UserSuggestionState(
|
||||
accountVM.account,
|
||||
accountVM.nip05ClientBuilder(),
|
||||
priorityPubkeys = { room.value?.users ?: emptySet() },
|
||||
)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+10
-1
@@ -188,7 +188,16 @@ open class ChannelNewMessageViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
this.userSuggestions =
|
||||
UserSuggestionState(
|
||||
accountVM.account,
|
||||
accountVM.nip05ClientBuilder(),
|
||||
priorityPubkeys = {
|
||||
// Public channels have no membership; recent posters are the
|
||||
// closest thing. The cutoff also bounds the note scan.
|
||||
channel?.participatingAuthors(TimeUtils.oneMonthAgo())?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet()
|
||||
},
|
||||
)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+48
-29
@@ -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 =
|
||||
|
||||
+10
-1
@@ -194,7 +194,16 @@ open class NestNewMessageViewModel :
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
this.userSuggestions =
|
||||
UserSuggestionState(
|
||||
accountVM.account,
|
||||
accountVM.nip05ClientBuilder(),
|
||||
priorityPubkeys = {
|
||||
(room?.event as? MeetingSpaceEvent)?.let { space ->
|
||||
space.participantKeys().toSet() + space.pubKey
|
||||
} ?: emptySet()
|
||||
},
|
||||
)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
|
||||
class UserProfileGalleryFeedFilter(
|
||||
@@ -81,7 +81,7 @@ class UserProfileGalleryFeedFilter(
|
||||
(
|
||||
noteEvent is PictureEvent ||
|
||||
noteEvent is RegularVideoEvent ||
|
||||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
|
||||
(noteEvent is AddressableVideoEvent && it is AddressableNote) ||
|
||||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
|
||||
)
|
||||
|
||||
|
||||
+30
-13
@@ -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) },
|
||||
|
||||
+128
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+214
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -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)
|
||||
}
|
||||
}
|
||||
+58
-5
@@ -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(
|
||||
|
||||
+106
-19
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+51
-47
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,3 +2,4 @@ marmot/state/
|
||||
marmot/state-headless/
|
||||
dm/state-dm-headless/
|
||||
nests/state/
|
||||
clink/state-clink-headless/
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+156
@@ -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
|
||||
+9
@@ -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
|
||||
|
||||
+54
@@ -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())
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+51
@@ -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()
|
||||
}
|
||||
+6
@@ -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)
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+1
@@ -26,4 +26,5 @@ data class TorRelaySettings(
|
||||
val dmRelaysViaTor: Boolean = false,
|
||||
val newRelaysViaTor: Boolean = false,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
val moneyOperationsViaTor: Boolean = false,
|
||||
)
|
||||
|
||||
+89
@@ -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)
|
||||
}
|
||||
}
|
||||
+62
@@ -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 })
|
||||
}
|
||||
}
|
||||
+43
@@ -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
@@ -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
|
||||
//
|
||||
|
||||
@@ -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" "$@"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user