diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c080d01670..8a8408fd51 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,8 +12,10 @@ architecture while sharing the back end components with the android counterpart. a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library -exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53 -audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under +exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's +relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and +`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs +the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under `moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the production listener AND speaker paths both run on moq-lite to interop with the nostrnests reference relay. The IETF code is kept as a reference + unit-test @@ -23,27 +25,13 @@ implementation for any future IETF target; see Canonical NIP specs live at — use `/nip ` to pull a specific one (it fetches the spec file directly). -## Verify, Don't Guess (standing instruction) +## Verify, Don't Guess -A plausible-sounding explanation is cheap; being right is not. Before -asserting what a problem is or how something behaves: - -1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm - guessing" or "haven't verified" — never dress an untested guess up as a - diagnosis. Use "I verified X by running Y" only when you actually did. -2. **Reproduce before diagnosing.** If a claim is checkable in under a - minute, check it before stating it. This repo gives you the means: - `./gradlew test`, the per-module tests, and `amy` (the CLI exists partly - to drive `quartz`/`commons` for interop checks). Write the failing case - first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix` - (reproduce-first) or `/investigate` (competing hypotheses + refutation). -3. **Predict, then run.** Before running a command, state the output you - expect. A mismatch is the cheapest signal that your model is wrong. -4. **Don't commit to one cause.** A single immediate explanation stops you - from looking. Hold 2–3 candidates and a discriminating test for each. - -If you find yourself writing paragraphs to defend a theory, that effort -almost always should have been one test. +Don't assert a diagnosis you haven't reproduced. This repo gives you cheap +verification tools: `./gradlew test`, per-module test suites, and the `amy` +CLI (built partly to drive `quartz`/`commons` for interop checks). If a +claim is checkable in under a minute, check it before stating it — write +the failing case first, watch it fail, then explain. ## Architecture @@ -69,6 +57,7 @@ amethyst/ │ └── src/ │ ├── commonMain/ # MoQ session, NestsListener, audio glue │ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack +├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code ├── desktopApp/ # Desktop JVM application (layouts, navigation) ├── amethyst/ # Android app (layouts, navigation) └── cli/ # Amy — non-interactive CLI (JVM only, no Compose) @@ -87,12 +76,44 @@ amethyst/ - `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport, Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio. - `amethyst/` & `desktopApp/` = Platform-native layouts and navigation -- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed) +- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic + allowed). May also depend on `:geode` (for `amy serve`, which embeds the + standalone relay); never on `:amethyst` or `:desktopApp`. **Plans per module:** design docs for new subsystems live in the owning module's `plans/YYYY-MM-DD-.md` (e.g. `cli/plans/`, `commons/plans/`). The global `docs/plans/` folder is frozen — don't add new plans there. +## Android Runtime Processes (IMPORTANT — the app runs in TWO processes) + +The Android app is **not single-process**. Android instantiates the one `Amethyst` +Application class (there is no per-process Application in the manifest) in **both**: + +- **main** — the normal app: UI, account, signer, `LocalCache`, relay client. + `Amethyst.instance` (`AppModules`) is built here. +- **`:napplet`** — the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites + (`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no** + account or keys; `Amethyst.onCreate()` early-returns here so `Amethyst.instance` + is **left unset** (touching it throws `UninitializedPropertyAccessException`). + The sandbox runtime lives in its own module **`:nappletHost`** (depends only on + `:commons` + `:quartz`, **never** `:amethyst`) so it *cannot* import + `Amethyst`/`LocalCache`/`Account` — the broker-side (signer, gateways, registry) + stays in `:amethyst` and the two halves talk over Messenger IPC. + +Consequences — don't get caught assuming one process: + +- **Processes don't share memory.** Every `object`/companion/`static` is a + *separate copy per process*: `LocalCache` (an `object`), `NappletLaunchRegistry`, + etc. The populated `LocalCache` lives only in **main**; the sandbox neither + builds nor should reference it (a stray reference would lazily create a second, + empty cache there). +- **Don't assume `Amethyst.instance` exists.** Any code reachable from `:napplet` + (the host activity, content server, or an Application lifecycle callback like + `onTrimMemory`) must guard on the process and never reach for `instance`. +- **Cross-process state goes over Messenger IPC**, never a shared singleton — this + is why the broker (main) owns `NappletLaunchRegistry` and the sandbox only relays + an opaque token. See `amethyst/plans/2026-06-22-napplet-nsite-security.md`. + ## Tech Stack Exact versions live in `gradle/libs.versions.toml` (the source of truth — check @@ -123,16 +144,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 +153,9 @@ job is usually to **reuse** (`quartz` protocol/business logic), **extract** (Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav — not to duplicate existing managers, caches, or state. -Capture the survey as a matrix in your plan: - -| File/Component | Status | Location | Action | -|----------------|--------|----------|--------| -| FilterBuilders | ✅ Reuse | quartz/relay/filters/ | Use as-is | -| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons | -| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern | - -**Legend:** ✅ Reuse (exists, use directly) · 📦 Extract (exists in Android, move -to `commons`) · 🆕 New (doesn't exist — platform-specific only) · ⚠️ Avoid -(duplicate; use existing pattern). +Summarize the survey in your plan: for each component, note whether it's +reused as-is, extracted from `amethyst/` to `commons/`, genuinely new +(platform-specific only), or a duplicate of an existing pattern to avoid. **Share vs keep platform-native:** @@ -189,6 +192,37 @@ version. `quartz/` is protocol-only — no composables. ./gradlew spotlessApply ``` +## Dependency Licensing + +**MANDATORY whenever you introduce a new third-party dependency** — in *any* +module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`, +`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a +module's `build.gradle.kts`: determine its license **before** wiring it in. +Amethyst ships under the **MIT** license, so a copyleft dependency linked into a +distributed artifact (APK, desktop binary) can force that artifact's +combined-work terms onto the whole project. + +Verify against the dependency's actual `LICENSE`/`COPYING` file or its published +POM — **not from memory**. Then classify and act: + +- **Permissive** (MIT, Apache-2.0, BSD, ISC, MPL-2.0, zlib, …) → **OK**, + proceed. +- **LGPL, or GPL/EPL with a linking / Classpath exception** → **WARN.** + Acceptable to link (the exception keeps our own code MIT), but call it out in + your summary so the human knows. Confirm the exception actually exists in the + LICENSE text — don't assume it does. +- **Stricter than LGPL** — GPL/AGPL **without** a linking exception, SSPL, + proprietary/commercial-only, or anything where the linking-exception check is + "no" → **STRONGLY WARN and STOP.** Do not add it silently. Surface it + prominently and **require an explicit call-out in the PR description** so a + maintainer makes the decision. Prefer a permissive alternative, a clean-room + implementation, or dropping the feature. + +For any GPL-family hit the decisive question is always **"is there a linking +(LGPL/Classpath) exception?"** — that is what separates a WARN from a STOP. +(Example: TarsosDSP, GPLv3 with no exception, was removed from `amethyst` and +replaced with an in-house pitch shifter for exactly this reason.) + ## Quartz KMP Structure Quartz uses expect/actual for platform-specific implementations (e.g. crypto @@ -243,3 +277,12 @@ Do this before considering the task complete. - Commits: Conventional commits (`feat:`, `fix:`, etc.) - Never use `--no-verify` + +### Remotes & pull requests + +A PR can be published two ways, via two **kinds** of remote — identify them by **URL** (`git remote -v`), because the names vary per clone and **a collaborator may have only one**: + +- a **GitHub** remote (`github.com/vitorpamplona/amethyst`) — the **canonical** `main`; moves constantly. Standard `gh` PR flow. +- a **git-over-nostr** remote (`nostr://…/relay.ngit.dev/amethyst`, via `ngit`) — pushing fans out to GitHub **and** the GRASP git servers; **PRs here are nostr proposals** (the `pr/feat/*` branches), reviewed on **gitworkshop.dev** — *not* GitHub PRs. (In the maintainer's checkout these happen to be named `upstream` and `origin` respectively, but don't rely on that.) + +Before opening, revising, or merging a PR by **either** path, use the **`ngit-pr`** skill. It covers when to use which, identifying your remotes by URL, the `gh` and `ngit` commands, and — critically for the nostr path — the three-mains alignment gate (GitHub main vs the lagging nostr `main` vs local `main`) that its create/revise/merge flows depend on. Skipping it leads to rejected pushes and PRs that don't show up as revisions. diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md index 6b43f0d2bf..0b1f5eacb1 100644 --- a/.claude/commands/desktop-run.md +++ b/.claude/commands/desktop-run.md @@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application: If the build fails, check: -1. **JDK Version**: Requires JDK 17+ +1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`) ```bash java -version ``` @@ -39,7 +39,7 @@ If the build fails, check: ./gradlew :desktopApp:packageMsi # Linux -./gradlew :desktopApp:packageDeb +./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm ``` -Outputs will be in `desktopApp/build/compose/binaries/` +Outputs will be in `desktopApp/build/compose/binaries/main/` diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md index 99c01eea7c..c9648a01ab 100644 --- a/.claude/commands/extract.md +++ b/.claude/commands/extract.md @@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code: 1. **Locate the component** in the amethyst module: ```bash - find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*" + find amethyst/src -name "*$ARGUMENTS*" grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/ ``` @@ -18,7 +18,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code: - Android Compose specifics vs standard Compose 3. **Identify what can be shared**: - - Pure Composable functions → `shared-ui/commonMain/` + - Pure Composable functions → `commons/commonMain/` - Business logic → `quartz/commonMain/` - Platform-specific → create expect/actual diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 16056cd4c4..1af746f6dd 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -1,339 +1,37 @@ -# AmethystMultiplatform Skills Creation Plan - -## Overview -Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns. - -**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase. - -## Skills to Implement - -### 1. kotlin-multiplatform ✅ COMPLETED -**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual - -**SKILL.md sections:** -- Mental model: KMP hierarchy as dependency graph -- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain} -- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149) -- expect/actual mechanics with 24+ examples from codebase -- iOS framework setup for Quartz distribution - -**Bundled resources:** -- `references/source-set-hierarchy.md` - Visual diagram + examples -- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns -- `scripts/validate-kmp-structure.sh` - Verify source set dependencies -- `assets/kmp-hierarchy-diagram.png` - Visual graph - -**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples. - -**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/` - ---- - -### 2. gradle-expert ✅ COMPLETED -**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting - -**SKILL.md sections:** -- Build architecture: 4 modules, dependency flow -- Version catalog mastery (libs.versions.toml) -- Module dependency patterns (api vs implementation) -- Android-specific: compileSdk, proguard -- Desktop packaging: TargetFormat, distributions -- Build performance: daemon, parallel, caching -- Common errors: compose version conflicts, secp256k1 JNI variants - -**Bundled resources:** -- `references/build-commands.md` - Common gradle tasks -- `references/dependency-graph.md` - Module visualization -- `references/version-catalog-guide.md` - Version catalog patterns -- `references/common-errors.md` - Troubleshooting guide -- `scripts/analyze-build-time.sh` - Performance report -- `scripts/fix-dependency-conflicts.sh` - Conflict patterns - -**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts). - -**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/` - ---- - -### 3. kotlin-expert ✅ DRAFT COMPLETE -**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified - -**SKILL.md sections:** -- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager) -- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult) -- Immutability: @Immutable for Compose performance (173+ event classes) -- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder) -- Inline functions: reified generics, performance optimization (OptimizedJsonMapper) -- Value classes: Zero-cost wrappers (optimization opportunity) - -**Bundled resources:** -- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns -- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns -- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns -- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set - -**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders). - -**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline) -2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder -3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025 -4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf) -5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability) -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later (requires real usage scenarios) -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 4. compose-expert ✅ COMPLETED -**Focus:** Shared composables, state management, animations, Material3 - -**SKILL.md sections:** -- Shared composables philosophy (100+ already shared in commons/commonMain) -- State management: remember, derivedStateOf, produceState (visual patterns) -- Recomposition optimization: @Stable/@Immutable (visual usage) -- Material3 conventions: theming -- Custom icons: ImageVector builders (robohash pattern) -- Platform differences: Desktop vs Android UI -- Performance: lazy lists, image loading -- Decision framework: share by default in commonMain - -**Bundled resources:** -- `references/shared-composables-catalog.md` - Complete catalog with patterns -- `references/state-patterns.md` - State hoisting, derivedStateOf examples -- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL -- `scripts/find-composables.sh` - Grep @Composable utility - -**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert. - -**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/` - ---- - -### 5. ios-expert -**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation - -**SKILL.md sections:** -- iOS source sets: iosMain, iosArm64Main -- Swift interop: type mapping, nullability -- expect/actual iOS: 10+ examples from quartz/iosMain -- XCFramework setup: baseName = "quartz-kmpKit" -- Platform APIs: platform.posix, CFNetwork, Security -- CocoaPods integration -- XCode project setup - -**Bundled resources:** -- `references/ios-actual-implementations.md` - 10 iosMain actuals -- `references/swift-interop-guide.md` - Type mapping -- `references/xcode-integration.md` - XCode setup -- `scripts/generate-xcframework.sh` - Build all iOS targets - -**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup. - ---- - -### 6. desktop-expert ✅ DRAFT COMPLETE -**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions - -**SKILL.md sections:** -- Desktop entry point: application {} DSL -- Window management: WindowState, positioning, multi-window -- Menu system: MenuBar, keyboard shortcuts (OS-aware) -- System tray: minimize to tray -- Desktop navigation: NavigationRail pattern (vs Android bottom nav) -- File system: Desktop.getDesktop(), file pickers, drag-drop -- Desktop UX principles: keyboard-first, native feel, tooltips -- OS-specific behavior: macOS vs Windows vs Linux -- Platform detection: PlatformDetector utility -- Packaging: DMG, MSI, DEB distribution - -**Bundled resources:** -- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.) -- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns -- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper -- `references/os-detection.md` - Platform detection, file paths, system integration - -**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert. - -**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined desktop usage scenarios -2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt) -3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG) -4. ✅ SYNTHESIZE - Extracted desktop principles from codebase -5. ✅ DRAFT - Created SKILL.md + 4 reference files -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later (requires real desktop scenarios) -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 7. android-expert ✅ DRAFT COMPLETE -**Focus:** Android platform APIs, navigation, permissions, Material Design - -**SKILL.md sections:** -- Android module structure: amethyst/ layout -- Navigation: Navigation Compose, bottom nav -- Permissions: runtime (camera, biometric) -- Platform APIs: Intent, Context, ContentResolver -- Lifecycle: Lifecycle-aware, ViewModel -- Material Design: Android Material 3 -- Build config: Proguard, R8 -- Android UX: mobile-first patterns - -**Bundled resources:** -- `references/android-navigation.md` - Navigation Compose -- `references/android-permissions.md` - Permission handling -- `references/proguard-rules.md` - Proguard explanation -- `scripts/analyze-apk-size.sh` - APK optimization - -**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions. - -**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined Android usage scenarios -2. ✅ EXPLORE - Analyzed amethyst/ module patterns -3. ✅ RESEARCH - Android best practices + KMP Android patterns -4. ✅ SYNTHESIZE - Extracted Android principles from codebase -5. ✅ DRAFT - Initialized skill, created resources -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 8. nostr-expert ✅ COMPLETED -**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns - -**SKILL.md sections:** -- Quartz architecture: package structure by NIP (57 NIPs implemented) -- Event anatomy: IEvent, Event, kinds, tags -- EventTemplate & TagArrayBuilder DSL patterns -- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events -- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders -- Threading (NIP-10): reply/root markers -- Cryptography: secp256k1 signing, NIP-44 encryption -- Bech32 encoding: npub, nsec, note, nevent -- Event validation & verification -- Common workflows: publishing, querying, zaps, gift-wrapped DMs - -**Bundled resources:** -- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines) -- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines) -- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines) -- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term - -**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase. - -**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/` - ---- - -## Implementation Workflow - -Using skill-creator 10-step methodology per skill: - -**Overall Plan:** -1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained -2. **EXPLORE** ✅ - Codebase analyzed via Explore agent -3. **RESEARCH** ✅ - Domain patterns identified via Plan agent -4. **SYNTHESIZE** ✅ - Skills designed above - -**Per-Skill Implementation:** -- kotlin-multiplatform: ✅ COMPLETED -- gradle-expert: ✅ COMPLETED -- kotlin-expert: ✅ COMPLETED -- compose-expert: ✅ COMPLETED -- desktop-expert: ✅ COMPLETED -- android-expert: ✅ COMPLETED -- nostr-expert: ✅ COMPLETED -- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform) - -## Critical Files Referenced - -**Build patterns:** -- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set -- `/commons/build.gradle.kts` - Shared UI setup - -**Code patterns:** -- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure -- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern -- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual - -**Documentation:** -- `/docs/shared-ui-analysis.md` - UI migration strategy - -## Output Location -`.claude/skills//` for each skill - -## Next Steps - -1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference -2. ✅ Completed kotlin-multiplatform skill -3. ✅ Completed gradle-expert skill -4. ✅ Completed kotlin-expert skill -5. ✅ Completed compose-expert skill -6. ✅ Completed desktop-expert skill -7. ✅ Completed android-expert skill -8. ✅ Completed nostr-expert skill -9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase) - -## Current Status: 7/8 Skills Completed - -**Completed Skills (Auto-loaded from `.claude/skills/`):** -1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual) -2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting) -3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders) -4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector) -5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs) -6. ✅ android-expert (Android platform APIs, navigation, permissions) -7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags) - -**Deferred:** -- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform) - -## Skill Loading - -**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory. - -To manually verify skills are loaded: -```bash -ls -1 .claude/skills/ -``` - -Should show: -- android-expert/ -- compose-expert/ -- desktop-expert/ -- gradle-expert/ -- kotlin-expert/ -- kotlin-multiplatform/ -- nostr-expert/ - ---- +# Amethyst Skill Library — History & Changelog + +> Historical record of how the `.claude/skills/` library was built and audited. +> The 8 original skills were created in 2025 using the skill-creator 10-step +> methodology (detailed per-skill progress logs pruned in 2026-06 — see git +> history of this file if you need them). For the current skill list and how +> the two skill layers (codebase-oriented vs technique-oriented) fit together, +> see the Skills section of `.claude/CLAUDE.md`. + +## Phase 1 (2025): Core skills created + +Eight hybrid domain skills (general expertise + Amethyst-specific patterns), +each with a SKILL.md plus bundled `references/` and `scripts/`: + +1. **kotlin-multiplatform** — KMP architecture, the jvmAndroid source-set pattern, expect/actual catalog +2. **gradle-expert** — build system, version catalog, dependency troubleshooting +3. **kotlin-expert** — Flow state, sealed hierarchies, @Immutable, DSL builders +4. **compose-expert** — shared composables, state management, Material3, ImageVector +5. **desktop-expert** — Desktop UX, window management, Compose Desktop APIs +6. **android-expert** — Android navigation, permissions, platform APIs +7. **nostr-expert** — Nostr protocol, Quartz implementation, NIPs, events, tags +8. **ios-expert** — ⏸️ deferred (iOS targets are mature, but no iOS-specific UI work has surfaced in this repo yet) ## Phase 2 (2026-04): Audit & Expansion After a full audit of the skill library, the following changes were made: ### Stale references fixed -- `CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20. -- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes. -- `desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout. +- `CLAUDE.md` tech-stack versions replaced with a pointer to `gradle/libs.versions.toml` as the source of truth. +- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp version notes. +- `desktop-expert` Main.kt line references rewritten to match current layout (NavigationRail moved to `ui/deck/SinglePaneLayout.kt`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout. ### Redundant files removed -- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). `quartz-kmp.md` kept as a small breadcrumb pointer. +- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). ### New references added to existing skills - `nostr-expert/references/nip19-bech32.md` — `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities. @@ -346,33 +44,44 @@ After a full audit of the skill library, the following changes were made: ### New skills created - **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store. - - `references/account-state-flow.md`, `references/local-cache.md` -- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`). - - `references/filter-assemblers.md`, `references/preloaders.md` +- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders. - **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`. - - `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md` -- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55). - - `references/nip46-remote-signer.md`, `references/nip55-android-signer.md` +- **`auth-signers/`** — `NostrSigner` abstraction across internal, NIP-46 remote, and NIP-55 external signers. -### Updated skills directory (Phase 2) -``` -- android-expert/ -- auth-signers/ (new) -- account-state/ (new) -- compose-expert/ -- desktop-expert/ -- feed-patterns/ (new) -- find-missing-translations/ -- find-non-lambda-logs/ -- gradle-expert/ -- kotlin-coroutines/ -- kotlin-expert/ -- kotlin-multiplatform/ -- nostr-expert/ -- quartz-integration/ -- relay-client/ (new) -- quartz-kmp.md (breadcrumb pointer) -``` +## Phase 3 (2026-06): Fable 5 config review -### Still deferred -- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo. +Instructions written to coach older models were removed now that the model +handles them natively; stale references fixed: + +- `CLAUDE.md`: deleted the 5-step skill-approval "Workflow" section + (skills auto-trigger; the approval loop blocked autonomous sessions); + condensed "Verify, Don't Guess" to the repo-specific tooling pointers and + dropped references to `/bugfix` / `/investigate` (never committed to this + repo); replaced the mandated emoji survey matrix with one-line guidance. +- `android-expert` and `desktop-expert` SKILL.md gained YAML frontmatter — + without it they were listed without trigger descriptions and never + auto-invoked. +- `commands/extract.md`: fixed stale `shared-ui/` module name → `commons/`. +- `skills/quartz-kmp.md` breadcrumb deleted (KMP migration long complete; + `quartz-integration` and `nostr-expert` cover its pointers). +- Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified + Kotlin files, so Q&A-only turns no longer pay a Gradle invocation. + +Second audit pass (every concrete claim checked against the code; `amy-expert`, +`find-missing-translations`, `find-non-lambda-logs`, and the vendored technique +skills verified clean): + +- `auth-signers`: bunker login entry point corrected — `NostrSignerRemote.fromBunkerUri(...)` + + `connect()`, not the nonexistent `RemoteSignerManager.connect(url)`. +- `nostr-expert`: NIP count 57 → 80+ packages; `Nip44v2.encrypt/decrypt` + static-object snippet replaced with the real `Nip44` facade + (returns `EncryptedInfo`, `encodePayload()` for event content); invented + `Nip19.npubEncode`/`Nip19Result` API replaced with the real `ByteArray` + extensions (`toNpub()`, …), entity `create()` helpers, and + `Nip19Parser.uriToRoute()?.entity`. +- `nostr-expert/references/nip-catalog.md`: heading count (60+8) replaced with + actual package counts (87 + 23 experimental) and a ground-truth pointer. +- `quartz-integration`: NIP-19 decode example rewritten for + `ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed); + Event Store section corrected from "Android only" to commonMain/all platforms + with the real `store.sqlite.EventStore` import and suspend generic `query`. diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 5b1409249a..d2e869fb85 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license" # Create local.properties if missing -REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")" +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")" LOCAL_PROPS="$REPO_ROOT/local.properties" if [ ! -f "$LOCAL_PROPS" ]; then echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS" diff --git a/.claude/hooks/stop-spotless.sh b/.claude/hooks/stop-spotless.sh new file mode 100755 index 0000000000..61666ba6ab --- /dev/null +++ b/.claude/hooks/stop-spotless.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Stop hook: format Kotlin sources, but only when the working tree actually +# has modified Kotlin files — skips the Gradle invocation on Q&A-only turns. +set -uo pipefail + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +if git status --porcelain 2>/dev/null | grep -qE '[.]kts?$'; then + ./gradlew spotlessApply 2>/dev/null +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index f44ad3a03c..15b897a70f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "./gradlew spotlessApply 2>/dev/null", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh", "timeout": 120 } ] diff --git a/.claude/skills/account-state/SKILL.md b/.claude/skills/account-state/SKILL.md index ec370630eb..528a2dd273 100644 --- a/.claude/skills/account-state/SKILL.md +++ b/.claude/skills/account-state/SKILL.md @@ -1,6 +1,6 @@ --- name: account-state -description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow. +description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow. --- # Account & Local Cache State @@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change │ ▼ - Account observes relevant kinds (3, 10002, 10000, …) + Account state objects pin the relevant addressable notes │ ▼ - Account StateFlow updates (followList, relays, mutes, …) + State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …) │ ▼ ViewModels collect @@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e Composables render ``` -`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering. +`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering. ## Key Files ### `Account.kt` (singleton-per-session) -- `class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind: - - `followListFlow` ← NIP-02 ContactList (kind 3) - - `relayListFlow` ← NIP-65 RelayList (kind 10002) - - `muteListFlow` ← NIP-51 Lists (kind 10000) - - `bookmarkListFlow` ← NIP-51 Lists (kind 10003) - - `topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc. - - Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, … -- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event. -- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop. -- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc. +- `class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind: + - `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3) + - `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, … + - `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000) + - `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, … + - Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders. +- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`. +- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event. +- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop. +- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc. ### `LocalCache.kt` @@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e Typical recipe: 1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list). -2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed. -3. In `Account.kt`: - - Add a private `MutableStateFlow`. - - Expose a `StateFlow` read view. - - Subscribe to the relay (via the relayClient subscription pattern — see `relay-client` skill). - - On event arrival, parse with the quartz event class and update the flow. - - Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes. -4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`. +2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one): + - Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`. + - Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`. + - Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed. +3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill). +4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`. ## `LocalCache` vs `Account` Flow — Which to Read? - **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`. -- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.`. +- **Are you rendering "my follows", "my mutes", "my relays"?** → `account..flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`). - **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable. ## Gotchas - **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache. - **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object. -- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it. +- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it. - **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state. - **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident. ## References -- `references/account-state-flow.md` — catalog of major `Account` StateFlow properties and their source kinds. +- `references/account-state-flow.md` — catalog of major `Account` state objects and their source kinds. - `references/local-cache.md` — `LocalCache` internals, insertion path, indexes. - Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events). diff --git a/.claude/skills/account-state/references/account-state-flow.md b/.claude/skills/account-state/references/account-state-flow.md index e9bf1256f3..d7e37c5524 100644 --- a/.claude/skills/account-state/references/account-state-flow.md +++ b/.claude/skills/account-state/references/account-state-flow.md @@ -1,93 +1,94 @@ -# Account StateFlow Catalog +# Account State-Object Catalog -`Account.kt` exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package. +`Account.kt` composes ~50 **feature state objects** (not raw StateFlow +properties). Each object pins its backing addressable note in `LocalCache`, +exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and +offers suspend mutation helpers that return signed events. Consumers read +`account..flow`. -(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.) +(Property and class names are exact as of the current `Account.kt`; if one has +been renamed, grep `Account.kt` for the class name.) ## Identity & Contacts -| Flow | Kind(s) | Source | Model package | -|------|---------|--------|---------------| -| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` | -| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` | -| `followersFlow` | derived | LocalCache scan | — | -| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` | -| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` | +| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` | +| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` | +| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` | +| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` | +| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` | +| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` | -## Relays & Connectivity +## Relay Lists -| Flow | Kind | Package | -|------|------|---------| -| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` | -| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` | -| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` | -| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` | -| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` | +| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` | +| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` | +| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` | +| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` | +| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` | +| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` | + +Derived relay views (merge several of the above): `homeRelays` +(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`, +`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`, +`defaultGlobalRelays`. ## Content Lists -| Flow | Kind | Package | -|------|------|---------| -| `bookmarkListFlow` | 10003 | `model/nip51Lists/` | -| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` | -| `topNavFeedsFlow` | custom | `model/topNavFeeds/` | -| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` | -| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` | -| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` | -| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` | +| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` | +| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` | +| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` | +| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` | +| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` | +| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` | +| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` | +| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` | +| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` | +| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` | -## Messaging +## Other Feature State -| Flow | Kind | Package | -|------|------|---------| -| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` | -| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` | -| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` | -| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` | +| Account property | State class | Purpose | Package | +|------------------|-------------|---------|---------| +| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` | +| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` | +| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` | +| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` | +| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — | -## Settings & UI - -| Flow | Source | Package | -|------|--------|---------| -| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` | -| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` | -| `privacyOptionsFlow` | local | `model/privacyOptions/` | -| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` | -| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` | - -## Advanced / Derived - -| Flow | Purpose | Package | -|------|---------|---------| -| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` | -| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` | -| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` | -| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` | -| `serverListFlow` | media/upload servers | `model/serverList/` | +Note the migration direction: newer/extracted state classes live in +`commons/src/commonMain/.../commons/model/`, the rest still in +`amethyst/src/main/java/.../model/`. Check both when looking for one. ## Publishing Mutations -Every flow has a corresponding mutation method on `Account` that: +State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`, +`BookmarkListState` add/remove) **build and sign** the updated replaceable +event via the quartz event class (`XEvent.add / remove / create`) and return +it. The caller (usually a method on `Account`) is responsible for sending it +through the client. Decryption results are cached in the paired +`…DecryptionCache` so re-renders don't re-decrypt. -1. Constructs the updated event using a `TagArrayBuilder`. -2. Signs through the injected `NostrSigner` (see `auth-signers` skill). -3. Publishes to the appropriate relay set. -4. Updates the local StateFlow *before* relay round-trip (optimistic). -5. Rolls back / reconciles on failure. +## When a State Object Doesn't Exist Yet -Examples of mutation methods (names may vary slightly in current code): -- `follow(pubKey)` / `unfollow(pubKey)` -- `addBookmark(noteId)` / `removeBookmark(noteId)` -- `mute(pubKey)` / `unmute(pubKey)` -- `updateRelayList(...)`, `updateDmRelayList(...)` -- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)` +If you're adding a new NIP that's user-scoped, follow the pattern (full recipe +in `SKILL.md`): -## When a Flow Doesn't Exist Yet - -If you're adding a new NIP that's user-scoped, follow the pattern: - -1. Create `model/nipXX…/` with an optional `ExtState`/builder class. -2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow = _xFlow.asStateFlow()` to `Account`. -3. Wire the relay subscription (see `relay-client` skill). -4. Add the mutation method that builds, signs, and publishes. -5. Update persistence if the setting is local-only (`AccountSettings.kt`). +1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or + `BookmarkListState` (plain). +2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose + `val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`. +3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if + private), and wire the relay subscription (see `relay-client` skill). +4. Add mutation helpers that build, sign, and return the event; publish from + the calling site. +5. Use `AccountSettings` for the local backup copy if the list must survive + relay loss. diff --git a/.claude/skills/amy-expert/SKILL.md b/.claude/skills/amy-expert/SKILL.md index c0139959cf..b7eaa489f6 100644 --- a/.claude/skills/amy-expert/SKILL.md +++ b/.claude/skills/amy-expert/SKILL.md @@ -129,10 +129,12 @@ via `Output.emit`. The template is in `references/command-template.md`; copy it rather than re-deriving it. Wire-up checklist: -1. New file in `cli/commands/` with the `object` pattern. -2. Add a branch in `Commands.kt`. -3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch` - / a new group dispatcher). +1. New file in `cli/commands/` with the `object` pattern. Sub-verb + `dispatch` functions use the shared `route(...)` helper in + `Router.kt` rather than a hand-rolled `when (tail[0])`. +2. Add a branch in `Main.kt`'s `dispatch` (top-level verbs call the + command object directly, e.g. `"relay" -> RelayCommands.dispatch(…)`; + `marmot` sub-verbs go through `marmotDispatch`'s `route` map). 4. Extend `printUsage()` in `Main.kt`. 5. Add the row to `cli/README.md`'s command table. 6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅. @@ -173,6 +175,7 @@ cli/ ├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext) └── commands/ # one file (or group) per top-level verb ├── UseCommand.kt # `amy use NAME` + ├── Router.kt # `route(...)` shared sub-verb dispatcher ├── InitCommands.kt # init, whoami ├── CreateCommand.kt + LoginCommand.kt ├── RelayCommands.kt @@ -186,15 +189,30 @@ cli/ ├── MessageCommands.kt ├── MarmotResetCommand.kt ├── AwaitCommands.kt - └── StoreCommands.kt + ├── StoreCommands.kt + ├── AdminCommand.kt # `amy admin RELAY METHOD` (NIP-86) + ├── ServeCommand.kt # `amy serve` (embeds :geode) + └── cashu/ # `amy cashu …` (NIP-60/61) — thin wrappers + ├── CashuCommands.kt # over commons CashuWalletOps / CashuWalletReader + ├── CashuWalletCommands.kt + CashuBalanceCommand.kt + CashuMintCommands.kt + └── CashuReceiveCommands.kt + CashuSendCommands.kt + + CashuMaintenanceCommands.kt + CashuMintRecCommands.kt ``` Shared logic consumed by Amy lives in `commons/`: - `commons/account/` — account bootstrap - `commons/marmot/` — MLS / group state +- `commons/cashu/` — `ops/CashuWalletOps` (jvmAndroid) + `CashuWalletReader` + + `CashuKeysetCounterStore`; the NIP-60/61 wallet, shared with Android. +- `commons/relayManagement/Nip86Retriever` — NIP-86 HTTP client, shared with + the Android relay-management screen. - `commons/defaults/` — default relays, kinds - Consult `commons/plans/` for cross-cutting design work in flight. +A few amy verbs lean on modules beyond `quartz`/`commons`: `amy serve` +depends on `:geode` (the standalone relay) — the one allowed extra module +dependency. `:amethyst` / `:desktopApp` remain forbidden (Rule 5). + ## Common mistakes to refuse - **Adding protocol logic to `cli/`.** Push back, offer to extract. diff --git a/.claude/skills/amy-expert/references/command-template.md b/.claude/skills/amy-expert/references/command-template.md index b2adc99a75..4546de0367 100644 --- a/.claude/skills/amy-expert/references/command-template.md +++ b/.claude/skills/amy-expert/references/command-template.md @@ -18,8 +18,7 @@ object NotePublishCommand { val args = Args(rest) val text = args.positional(0, "text") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val event = com.vitorpamplona.amethyst.commons.note @@ -33,13 +32,15 @@ object NotePublishCommand { "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, )) return 0 - } finally { - ctx.close() } } } ``` +`Context` is `AutoCloseable`; wrap it in `use { }` so it's closed +(RunState flushed, relays disconnected) on every exit path — never a +hand-rolled `try { } finally { ctx.close() }`. + `Output.emit(...)` handles the text-vs-JSON mode automatically. The result map IS the `--json` shape; the human-readable text default is derived from the same map by `Output.kt`'s renderer. @@ -51,39 +52,34 @@ When a feature has several verbs (`note publish`, `note show`, ```kotlin object NoteCommands { - suspend fun dispatch(dataDir: DataDir, tail: Array): Int { - if (tail.isEmpty()) return Output.error("bad_args", "note ") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "publish" -> NotePublishCommand.run(dataDir, rest) - "show" -> NoteShowCommand.run(dataDir, rest) - "react" -> NoteReactCommand.run(dataDir, rest) - else -> Output.error("bad_args", "note ${tail[0]}") - } - } + suspend fun dispatch(dataDir: DataDir, tail: Array): Int = + route("note", tail, "note ", mapOf( + "publish" to { rest -> NotePublishCommand.run(dataDir, rest) }, + "show" to { rest -> NoteShowCommand.run(dataDir, rest) }, + "react" to { rest -> NoteReactCommand.run(dataDir, rest) }, + )) } ``` -Each verb gets its own file. Once a single file crosses ~200 lines, -split it — see `GroupCommands.kt` and its siblings as the reference. +The shared `route(name, tail, usage, routes)` helper (`Router.kt`) +handles the empty-input and unknown-verb `bad_args` branches, so the +`dispatch` body is just the verb→handler map. Each verb gets its own +file. Once a single file crosses ~200 lines, split it — see +`GroupCommands.kt` and its siblings as the reference. ## Wire-up checklist For every new command: 1. File under `cli/commands/`. -2. Branch in `Commands.kt`: +2. Branch in `Main.kt`'s top-level `dispatch`, calling the command + object directly: ```kotlin - suspend fun note(dataDir: DataDir, tail: Array): Int = - NoteCommands.dispatch(dataDir, tail) + "note" -> NoteCommands.dispatch(dataDir, tail) ``` -3. Branch in `Main.kt`'s top-level `dispatch`: - ```kotlin - "note" -> Commands.note(dataDir, tail) - ``` -4. Line in `printUsage()` explaining the verb. -5. Row in `cli/README.md`'s command table. -6. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅). +3. Line in `printUsage()` explaining the verb. +4. Row in `cli/README.md`'s command table. +5. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅). ## What not to do @@ -95,7 +91,7 @@ For every new command: them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the right exit code. - No holding a connection open across invocations — every run opens - a fresh `Context` and closes it in `finally`. + a fresh `Context` inside `use { }` so it closes on every exit path. - No blocking reads for user input — take a flag. - No global flags that collide with subcommand flags. `--name` is reserved for subcommand use (group/profile name); the global diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 1116915273..b5c63a5324 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -1,3 +1,8 @@ +--- +name: android-expert +description: Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform. +--- + # android-expert Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture. @@ -733,125 +738,29 @@ fun SignerIntegration(accountViewModel: AccountViewModel) { ## 6. Build Configuration -### Android Block +Build files — the `android {}` block, the version catalog, dependencies, +Proguard/R8, and Desktop packaging — are **gradle-expert's** domain. Use +`/gradle-expert` instead of duplicating that guidance here. In particular, the +app version and the Android `versionCode` both live in +`gradle/libs.versions.toml` (`app` / `appCode`); `amethyst/build.gradle.kts` +reads both from the catalog, so a release bump is a single-file edit. + +The one build detail that is genuinely Android-specific — not generic Gradle — +is the **product-flavor split** that ships two channels from one codebase: -**build.gradle (Amethyst pattern):** ```gradle -android { - namespace = 'com.vitorpamplona.amethyst' - compileSdk = 36 - - defaultConfig { - applicationId = "com.vitorpamplona.amethyst" - minSdk = 26 // Android 8.0 (Oreo) - targetSdk = 36 // Android 15 - versionCode = 447 - versionName = "1.11.0" - - vectorDrawables { - useSupportLibrary = true - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } - - buildFeatures { - compose = true - buildConfig = true // Enable BuildConfig access - } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } - - packaging { - resources { - excludes += '/META-INF/{AL2.0,LGPL2.1}' - } - } - - // Product flavors for Play Store vs F-Droid - flavorDimensions = ["channel"] - productFlavors { - create("play") { - dimension = "channel" - // Firebase, Google services - } - create("fdroid") { - dimension = "channel" - // UnifiedPush, open-source alternatives - } - } -} - -kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_21 - } +flavorDimensions = ["channel"] +productFlavors { + create("play") { dimension = "channel" } // Firebase, Google services + create("fdroid") { dimension = "channel" } // UnifiedPush, open-source only } ``` -### Dependencies +`play` carries Firebase/Google services; `fdroid` swaps them for UnifiedPush and +open-source alternatives so the F-Droid build stays proprietary-free. -**Key Android Dependencies:** -```gradle -dependencies { - // Compose BOM - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.ui.tooling.preview) - - // Navigation - implementation(libs.androidx.navigation.compose) - - // Lifecycle - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.lifecycle.viewmodel.compose) - - // Activity - implementation(libs.androidx.activity.compose) - - // Accompanist - implementation(libs.accompanist.permissions) - - // Shared module - implementation(project(":commons")) - implementation(project(":quartz")) -} -``` - -### Proguard Rules - -**Common Rules for Amethyst:** -```proguard -# Keep Kotlin metadata --keep class kotlin.Metadata { *; } - -# Keep Nostr event classes --keep class com.vitorpamplona.quartz.events.** { *; } - -# Keep serialization --keepattributes *Annotation*, InnerClasses --dontnote kotlinx.serialization.AnnotationsKt - -# OkHttp --dontwarn okhttp3.** --keep class okhttp3.** { *; } - -# Compose --keep class androidx.compose.** { *; } --dontwarn androidx.compose.** -``` - -**Reference:** See `references/proguard-rules.md` for complete Proguard configuration. - -### APK Optimization - -**Reference:** See `scripts/analyze-apk-size.sh` for APK size analysis. +Proguard/R8 rules: see `references/proguard-rules.md`. APK size analysis: +`scripts/analyze-apk-size.sh`. ## 7. KMP Android Source Sets diff --git a/.claude/skills/auth-signers/SKILL.md b/.claude/skills/auth-signers/SKILL.md index 26b72b1b79..8b11a52d1b 100644 --- a/.claude/skills/auth-signers/SKILL.md +++ b/.claude/skills/auth-signers/SKILL.md @@ -75,7 +75,7 @@ Most feature code should go through `Account`'s mutation methods (`account.sendR Entry points: - **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`. -- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`. +- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake. - **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`. The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen. diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md index cbd3f5d48d..477880202d 100644 --- a/.claude/skills/desktop-expert/SKILL.md +++ b/.claude/skills/desktop-expert/SKILL.md @@ -1,3 +1,8 @@ +--- +name: desktop-expert +description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform. +--- + # Desktop Expert Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles. @@ -69,7 +74,7 @@ fun main() = application { - `rememberWindowState()` manages size/position - `onCloseRequest` handles window close -**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — `fun main()` at L172, `application {` at L186, top-level `Window` at L229, `MenuBar` at L234. +**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol). --- @@ -275,9 +280,9 @@ Row(Modifier.fillMaxSize()) { } ``` -**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces. +**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases. -**Why NavigationRail?** +**Why a left sidebar?** - Desktop has horizontal space (1200+ dp width) - Vertical sidebar is standard desktop pattern - Always visible (no tabs hidden) @@ -285,7 +290,7 @@ Row(Modifier.fillMaxSize()) { **Android comparison:** - Android: `BottomNavigationBar` (horizontal, bottom) -- Desktop: `NavigationRail` (vertical, left) +- Desktop: left vertical sidebar (`MainSidebar`) ### Multi-Pane Layouts diff --git a/.claude/skills/desktop-expert/references/desktop-navigation.md b/.claude/skills/desktop-expert/references/desktop-navigation.md index 38011f82cb..ad829582f0 100644 --- a/.claude/skills/desktop-expert/references/desktop-navigation.md +++ b/.claude/skills/desktop-expert/references/desktop-navigation.md @@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform. --- -## Desktop: NavigationRail +## Desktop: Left Sidebar ### Current Implementation -**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+). +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces. ```kotlin @Composable diff --git a/.claude/skills/feed-patterns/SKILL.md b/.claude/skills/feed-patterns/SKILL.md index 7526d5d34e..aada17ce3c 100644 --- a/.claude/skills/feed-patterns/SKILL.md +++ b/.claude/skills/feed-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: feed-patterns -description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. +description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. --- # Feed Patterns @@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong │ ◄── ChatroomFeedViewModel │ │ ◄── MarmotGroupFeedViewModel │ │ │ -│ FeedContentState — the flow the UI collects │ +│ │ +│ commons/.../ui/feeds/ (shared, KMP) │ +│ IFeedFilter / FeedFilter (abstract base) │ +│ IAdditiveFeedFilter / AdditiveFeedFilter │ +│ ChangesFlowFilter │ +│ FeedContentState, FeedState — the flow the UI collects │ └─────────────────────────────────────────────────────────────┘ ▲ │ uses │ ┌─────────────────────────────────────────────────────────────┐ -│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │ -│ FeedFilter (abstract) │ +│ amethyst/.../ui/dal/ (Android-only additions) │ │ AdditiveComplexFeedFilter │ -│ ChangesFlowFilter │ │ FilterByListParams │ -│ DefaultFeedOrder │ +│ DefaultFeedOrder (Note/Event/Card comparators) │ +│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │ +│ back-compat typealiases re-exporting commons) │ │ │ -│ Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter, │ -│ BookmarkListFeedFilter, NotificationFeedFilter, … │ +│ Concrete feeds: HomeNewThreadFeedFilter, │ +│ HashtagFeedFilter, NotificationFeedFilter, … live in │ +│ feature folders under ui/screen/loggedIn/*/dal/ │ └─────────────────────────────────────────────────────────────┘ ▲ │ reads │ ┌─────────────────────────────────────────────────────────────┐ -│ model/LocalCache.kt + Account. │ +│ model/LocalCache.kt + account..flow │ └─────────────────────────────────────────────────────────────┘ ``` @@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong - **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed. - **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing. -### Android DAL (the filters) +### Shared filter bases (commons) + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`: + +- **`FeedFilter.kt`** — `abstract class FeedFilter : IFeedFilter`. Has `feed(): List` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`. +- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter : FeedFilter(), IAdditiveFeedFilter`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything. +- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query. +- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects. + +### Android DAL (additions on top) `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`: -- **`FeedFilters.kt`** — `abstract class FeedFilter`. Has `feed(): List` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache). -- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything. -- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query. -- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters. -- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging). +- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`: like `AdditiveFeedFilter` but the incoming items (`Set`) are a different type than the list rows (`T`). +- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters. +- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`. +- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here. -Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`. +Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature `dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`. ## Adding a New Feed -1. **Define the filter.** Extend `AdditiveComplexFeedFilter>` (or plain `FeedFilter` if additivity doesn't matter). Implement: +1. **Define the filter.** Extend `AdditiveFeedFilter` (or plain `FeedFilter` if additivity doesn't matter; `AdditiveComplexFeedFilter` if incoming items differ in type from list rows). Implement: - `feedKey()` — stable identity (e.g. hashtag name, account pubkey). - `feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list. - `limit()` — pagination hint. - - If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set): Set` and `sort(collection: Set): List`. + - If additive: `applyFilter(collection: Set): Set` and `sort(collection: Set): List`. 2. **Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`. 3. **Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes. 4. **Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`. @@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, ## Filter Sharing (Android vs Desktop) -- `FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/` — **Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`. -- ViewModels are in `commons/commonMain/` — **shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared. -- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it. +- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**. +- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases. +- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules. ## Gotchas @@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, - **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates. - **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts. - **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan. -- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc. +- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams` exclusion paths rather than filtering post-hoc. ## References diff --git a/.claude/skills/feed-patterns/references/feed-filter-composition.md b/.claude/skills/feed-patterns/references/feed-filter-composition.md index 3f345b1b16..4c00329919 100644 --- a/.claude/skills/feed-patterns/references/feed-filter-composition.md +++ b/.claude/skills/feed-patterns/references/feed-filter-composition.md @@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt | If… | Use | |-----|-----| | Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter` | -| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter>` | +| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter` | +| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter` (Android-only) | | The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter` + `ListChangeFeedViewModel` | | The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) | -All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`. +The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`. ## 2. Write the Filter @@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`. class HashtagFeedFilter( private val accountViewModel: AccountViewModel, private val hashtag: String, -) : AdditiveComplexFeedFilter>() { +) : AdditiveFeedFilter() { override fun feedKey(): String = "Hashtag-$hashtag" @@ -28,7 +29,7 @@ class HashtagFeedFilter( override fun feed(): List { val params = FilterByListParams.create( excludeMuted = true, - hiddenUsers = accountViewModel.hiddenUsersFlow.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return LocalCache.hashtagIndex[hashtag] .orEmpty() @@ -69,7 +70,7 @@ class HashtagFeedViewModel( ) ``` -If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`. +If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`. ## 4. Wire Invalidation @@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan ```kotlin init { viewModelScope.launch { - accountViewModel.muteListFlow.collect { invalidateAll() } + account.muteList.flow.collect { invalidateAll() } } } ``` diff --git a/.claude/skills/gradle-expert/SKILL.md b/.claude/skills/gradle-expert/SKILL.md index 208caa11cc..853a27620e 100644 --- a/.claude/skills/gradle-expert/SKILL.md +++ b/.claude/skills/gradle-expert/SKILL.md @@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro # Gradle Expert -Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. +Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. ## Build Architecture Mental Model -Think of this project as **4 layers**: +The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses): ``` ┌─────────────┬─────────────┐ @@ -165,11 +165,11 @@ implementation(libs.jna) **The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes. -**Current project config:** +**Current project config** (always re-check `gradle/libs.versions.toml` — these drift): ```toml -composeMultiplatform = "1.9.3" # Plugin + runtime -composeBom = "2025.12.01" # AndroidX Compose BOM -kotlin = "2.3.0" +composeMultiplatform = "1.11.1" # Plugin + runtime +composeBom = "2026.05.01" # AndroidX Compose BOM +kotlin = "2.3.21" ``` **Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 411f4d0233..a1985b564e 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -3,46 +3,50 @@ ## Visual Hierarchy ``` -┌─────────────────────────────────────────────────────────┐ -│ Root Project │ -│ (Amethyst) │ -└─────────────────────────────────────────────────────────┘ - │ - ┌────────────────┼────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ :amethyst │ │ :desktopApp │ │ :benchmark │ -│ (Android) │ │ (JVM) │ │ (Android) │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ - │ │ │ - └────────────────┼────────────────┘ - │ - ▼ - ┌─────────────┐ - │ :commons │ - │ (KMP UI) │ - │ │ - │ jvmAndroid │ - │ / \ │ - │ jvm android│ - └─────────────┘ - │ - │ - ▼ - ┌─────────────┐ - │ :quartz │ - │(KMP Library)│ - │ │ - │ commonMain │ - │ │ │ - │ jvmAndroid │ - │ / | \ │ - │jvm and ios │ - └─────────────┘ + Apps / harnesses Libraries +┌─────────────┐ ┌─────────────┐ ┌────────────┐ +│ :amethyst │ │ :desktopApp │ │ :benchmark │ +│ (Android) │ │ (JVM) │ │ (Android) │ +└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘ + │ │ └───────┼────┐ │ │ │ + │ │ │ │ │ │ │ + │ ▼ ▼ │ │ ▼ │ + │ ┌────────────────┐ │ │ (androidTest │ + │ │ :commons │◄┼──┼──only) │ + │ │ (KMP UI) │ │ │ │ + │ └───────┬────────┘ │ │ │ + │ │ ▲ │ │ │ + ▼ │ │ │ │ │ +┌──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │ +│ :nestsClient │ │ │ :cli │ │:geode │ │ +│ (KMP, MoQ) │ │ │(JVM) │ │(JVM │ │ +└──┬────────┬──┘ │ └──┬───┘ │relay) │ │ + │ │ │ │ └───┬───┘ │ + ▼ │ │ │ │ │ +┌────────┐ │ │ │ │ │ +│ :quic │ │ │ │ │ │ +│ (KMP) │ │ │ │ │ │ +└───┬────┘ │ │ │ │ │ + │ ▲ │ │ │ │ │ + │ └── :quic-interop │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌──────────────────────────────┐ + │ :quartz │ + │ (KMP Library) │ + └──────────────────────────────┘ ``` +Verified edges (from each module's `build.gradle.kts`): + +- `:amethyst` → `:quartz`, `:commons`, `:nestsClient` +- `:desktopApp` → `:quartz`, `:commons` +- `:benchmark` → `:quartz`, `:commons` (androidTest only) +- `:cli` → `:quartz`, `:commons` +- `:geode` → `:quartz` (api + testFixtures) +- `:nestsClient` → `:quartz` (api), `:quic` +- `:quic` → `:quartz` (api) +- `:quic-interop` → `:quic` (project dir: `quic/interop`) + ## Module Details ### :quartz (KMP Nostr Library) @@ -86,11 +90,41 @@ **Type:** Android Library **Targets:** Android **Dependencies:** -- Modules: `:commons`, `:quartz` +- Modules: `:commons`, `:quartz` (androidTest only) - External: AndroidX Benchmark **Role:** Performance benchmarking for Android builds +### :cli (Amy CLI) +**Type:** JVM Application (no Compose) +**Dependencies:** `:quartz`, `:commons` + +**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill) + +### :geode (Relay Server) +**Type:** JVM Application (Ktor) +**Dependencies:** `:quartz` (api + testFixtures) + +**Role:** Standalone Nostr relay built on quartz's relay-server code + +### :quic (QUIC Transport) +**Type:** Kotlin Multiplatform Library +**Dependencies:** `:quartz` (api) + +**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ + +### :nestsClient (Audio Rooms) +**Type:** Kotlin Multiplatform Library +**Dependencies:** `:quartz` (api), `:quic` + +**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature + +### :quic-interop (Interop Harness) +**Type:** JVM Application (project dir `quic/interop`) +**Dependencies:** `:quic` + +**Role:** QUIC interop-runner test client + ## Dependency Flow Patterns ### Desktop Build Chain @@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant implementation(compose.ui) // Compose Multiplatform BOM implementation(compose.material3) -// Version catalog alignment -composeMultiplatform = "1.9.3" -composeBom = "2025.12.01" // AndroidX Compose +// Version catalog alignment (re-check libs.versions.toml — these drift) +composeMultiplatform = "1.11.1" +composeBom = "2026.05.01" // AndroidX Compose ``` **Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align diff --git a/.claude/skills/kotlin-expert/SKILL.md b/.claude/skills/kotlin-expert/SKILL.md index bc1d81bc06..619170dd3e 100644 --- a/.claude/skills/kotlin-expert/SKILL.md +++ b/.claude/skills/kotlin-expert/SKILL.md @@ -807,6 +807,5 @@ Passing lambda to function? --- -**Version:** 1.0.0 -**Last Updated:** 2025-12-30 -**Codebase Reference:** AmethystMultiplatform commit 258c4e011 +**Version:** 1.0.1 +**Last Updated:** 2026-06-10 diff --git a/.claude/skills/ngit-pr/SKILL.md b/.claude/skills/ngit-pr/SKILL.md new file mode 100644 index 0000000000..5e882b3cef --- /dev/null +++ b/.claude/skills/ngit-pr/SKILL.md @@ -0,0 +1,183 @@ +--- +name: ngit-pr +description: How to create, review, revise, and merge pull requests in this repo, which can be published TWO ways — GitHub (the `gh` CLI) and git-over-nostr (the `ngit` CLI, where PRs are nostr **proposals** reviewed on gitworkshop.dev). Use whenever a task involves opening/updating/merging a PR by either mechanism, the `pr/feat/*` branches, the `ngit` or `gh` CLIs, gitworkshop.dev, or a `nostr://` remote. Remote names vary per clone (and a collaborator may have only one) — this skill identifies remotes by URL, and covers the three-mains alignment gate the nostr flow depends on. +--- + +# Pull requests: GitHub **and** git-over-nostr + +This repo can be contributed to **two** ways, via **two kinds** of remote. Both end up in GitHub `main`. + +| Remote kind | URL pattern | Role | +|--|--|--| +| **GitHub** | `github.com/vitorpamplona/amethyst` | **Canonical** `main`. Moves constantly (bots merge often) — a moving target. | +| **git-over-nostr** | `nostr://…/relay.ngit.dev/amethyst` | `ngit`. A push fans out to GitHub **and** the GRASP git servers and publishes nostr events. PRs are **proposals**, reviewed on **gitworkshop.dev**. | + +## Step 0 — identify YOUR remotes (names are not universal) + +Remote **names are per-clone**. In the maintainer's checkout the GitHub remote is `upstream` and the nostr remote is `origin`, but yours may differ, and **you may have only one of them** (e.g. cloned straight from `nostr://…`, so the nostr remote is your `origin` and there is no separate GitHub remote — pushing it still reaches GitHub via fan-out). Detect by **URL**, never assume a name: + +```bash +git remote -v +GH_REMOTE=$(git remote -v | awk '/github\.com/ {print $1; exit}') # GitHub remote (may be empty) +NOSTR_REMOTE=$(git remote -v | awk '/nostr:\/\// {print $1; exit}') # git-over-nostr remote (may be empty) +echo "github=$GH_REMOTE nostr=$NOSTR_REMOTE" +``` + +The examples below use `$GH_REMOTE` / `$NOSTR_REMOTE` — substitute whichever you have. + +## Which path? + +| | **GitHub path** (`gh`) | **nostr path** (`ngit`) | +|--|--|--| +| Needs | a GitHub remote + `gh auth status` | a `nostr://` remote + `ngit` ≥ 2.5.0 | +| PR lives on | GitHub only | nostr + GitHub + GRASP (fans out) | +| Use when | Default; PR only needs to be on GitHub. Simplest, no alignment gate. | The PR must be visible/reviewable over nostr (gitworkshop), or you're revising/merging an existing **proposal** (a `pr/feat/*`). | + +**Default to GitHub** unless the task is specifically about a nostr proposal (e.g. "the PRs on origin", a gitworkshop link, a `pr/feat/*` branch). If you only have one remote, that decides the path for you. Revise/merge a PR on **the same path it was created** — don't revise a GitHub PR via ngit or vice-versa. + +--- + +# GitHub path (`gh`) + +The normal flow most of this repo's history uses ("Merge pull request #NNNN …"). Requires a GitHub remote (`$GH_REMOTE`) and `gh auth status` OK. + +```bash +# create — branch off main, push, open the PR +git checkout -b feat/ main +git push -u "$GH_REMOTE" feat/ +gh pr create --repo vitorpamplona/amethyst --base main --head feat/ \ + --title "feat: …" --body "…" + +# review / list +gh pr list --repo vitorpamplona/amethyst +gh pr view --repo vitorpamplona/amethyst # --comments for the thread + +# revise — push more commits to the same branch +git push "$GH_REMOTE" feat/ + +# merge (maintainer) +gh pr merge --repo vitorpamplona/amethyst --merge # or --squash +``` + +GitHub is the source of truth for this path — no three-mains gate. Standard Git Workflow rules from CLAUDE.md still apply (conventional commits, never `--no-verify`). + +--- + +# nostr path (`ngit`) + +Requires a `nostr://` remote (`$NOSTR_REMOTE`) and `ngit` ≥ 2.5.0 (`ngit --version`). + +**Mental model:** an ngit PR ("proposal") is a *linear patch series off `main`*, published as nostr events. A "revision" is a new version of that proposal. Merging applies the series to `main` and publishes a merged-status event. There is **no** GitHub PR number; `ngit pr merge` makes a plain merge commit (amend it to a readable message). + +## ⚠ The three-mains alignment gate (the thing that breaks everything) + +Up to **three** `main` heads drift apart: + +- GitHub main (`$GH_REMOTE/main` if you have it) — newest, moves every few minutes +- nostr main (`$NOSTR_REMOTE/main` tracking ref) — **lags**, often far behind +- local `main` + +**Every create/revise/merge requires the proposal's base to equal the nostr `main`, and pushing `main` to the nostr remote requires GitHub's main to be an ancestor of what you push.** When misaligned, ngit **rejects pre-flight and publishes nothing** (safe — nothing half-breaks; realign and retry). Don't `--force` past it. + +```bash +git fetch --all +echo "github=$([ -n "$GH_REMOTE" ] && git rev-parse "$GH_REMOTE/main") \ +nostr=$(git ls-remote "$NOSTR_REMOTE" -h refs/heads/main | awk '{print $1}') \ +local=$(git rev-parse main)" +# all present heads equal → proceed. +# local behind GitHub? git merge --ff-only "$GH_REMOTE/main" (or "$NOSTR_REMOTE/main" if that's all you have) +# nostr behind local? git push "$NOSTR_REMOTE" main (clean fast-forward only) +``` + +If you have **only** the nostr remote: align local `main` to `$NOSTR_REMOTE/main`; GitHub is handled by fan-out, and any GitHub/GRASP disagreement surfaces as an ngit rejection on push. If GitHub diverged from nostr (`out of sync with nostr` on push), that's a maintainer `ngit sync --ref-name refs/heads/main --force` situation — **stop and ask the human**, don't run a forced sync unprompted. + +## Pushes are slow — run them in the background + +`git push "$NOSTR_REMOTE" …`, `ngit send`, and `ngit pr merge` fan out to relays + GRASP servers and **routinely exceed 2 minutes**. Run with `run_in_background: true` and poll (e.g. `git ls-remote "$NOSTR_REMOTE"` for the expected ref). A foreground call hits the 2-minute tool timeout even while the push is actually succeeding. + +## Identity + +`ngit account whoami` shows the signing key; `ngit send`/`ngit pr merge` sign with **that** key regardless of original author. The maintainer (`VitorPamplona`, `_@vitorpamplona.com`) revising/merging a contributor's proposal with their own key is expected. + +## List / view + +```bash +ngit pr list # open + draft +ngit pr list --status open,draft,closed,merged,applied +ngit pr view # FULL id, not the short prefix +``` + +In `git branch -r`, proposals show as `/pr/feat/()` — the `(...)` is an ngit annotation; the real ref is `pr/feat/`. Status `applied` == merged. + +## Create + +Push a `pr/`-prefixed branch (linear, off current `main`): + +```bash +git push -o 'title=My title' -o 'description=line1\n\nline2' -u "$NOSTR_REMOTE" pr/feat/ +``` + +Advanced (cover letter, labels): `ngit send` — see `ngit send --help`. + +## Revise (publish a new version) + +The revision must be a **linear series off the current `main`** (a merge commit is the wrong shape). + +```bash +# 1. align (gate above), then build the linear series: +git checkout -b main +git cherry-pick # existing PR commits +git cherry-pick # yours on top (or: git rebase main) + +# 2. verify it compiles + tests pass; tree == your intended change. + +# 3. publish as a new version, linked to the proposal: +ngit send --in-reply-to \ + --subject "" \ + --description "" \ + -d main # SINCE_OR_RANGE "main" → commits in main..HEAD +``` + +`--in-reply-to` threads it under the same proposal on gitworkshop. **No `--force`** once the base equals the nostr `main`. `proposal builds on a commit N ahead of 'origin/main'` ⇒ gate unmet — realign and re-rebase, don't force. Verify: `git ls-remote "$NOSTR_REMOTE" | grep ` shows `refs/pr//head` at your new tip. + +## Merge into main + +The merge is **local**; the merged-status event publishes on the subsequent push. + +```bash +# 1. ALIGN (mandatory) — all present mains equal. + +git checkout main +ngit pr merge -d # local merge commit; marks proposal "applied" + # --squash for a squash merge + +# 2. amend the generic merge message to something readable: +git commit --amend -F - <<'MSG' +Merge PR: + +Merges nostr proposal <short-id> into main: +- <commit summaries> + +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> +MSG + +# 3. sanity-check, then publish (background — slow): +[ -n "$GH_REMOTE" ] && { git merge-base --is-ancestor "$GH_REMOTE/main" HEAD && echo "clean FF push" || echo "github moved; realign"; } +git push "$NOSTR_REMOTE" main +``` + +Confirm: `ngit pr list --status applied` shows it `applied`, and (if you have it) `git fetch "$GH_REMOTE"` fast-forwards GitHub's main to your merge. + +## Cleanup + +Delete throwaway branches pushed to the nostr remote (e.g. a `merge/*` used before switching to the proper flow): `git push "$NOSTR_REMOTE" --delete <branch>` (the per-GRASP "non-existent ref" warnings are idempotent fan-out). Delete the local merged branches ngit creates (`pr/feat/<slug>(...)`) and your work branch. + +## Failure modes — quick reference + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `proposal builds on a commit N ahead of 'origin/main'` | base ≠ stale nostr `main` | realign `main`, rebase series, resend (no `--force`) | +| `! [remote rejected] main … out of sync with nostr` | GitHub main diverged from nostr | maintainer `ngit sync … --force` — **ask the human** | +| push "succeeds" but nothing on gitworkshop | pushed a plain branch, not a proposal | use `pr/`-prefix or `ngit send --in-reply-to` | +| `ngit pr view`/`merge` "failed to parse event id" | used the short prefix | pass the **full** hex id or `nevent` | +| push hangs / times out at 2 min | normal GRASP fan-out latency | run in background; verify via `git ls-remote` | diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index 373b14eb2b..c8c87c0cd7 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: nostr-expert -description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. +description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. --- # Nostr Protocol Expert (Quartz Implementation) @@ -313,26 +313,24 @@ class LocalSigner(private val privateKey: ByteArray) : ISigner { ### Encryption (NIP-44) ```kotlin -// Modern encryption (ChaCha20-Poly1305) -object Nip44v2 { - fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String - fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String +// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade +// (nip44Encryption/Nip44.kt — picks the current version, decrypts any) +object Nip44 { + fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo + fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String } // Usage -val encrypted = Nip44v2.encrypt( - plaintext = "Secret message", - privateKey = myPrivateKey, - pubKey = recipientPubKey -) +val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey) +val payload = encrypted.encodePayload() // base64 string for event content -val decrypted = Nip44v2.decrypt( - ciphertext = encrypted, - privateKey = myPrivateKey, - pubKey = senderPubKey -) +val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey) ``` +Most code should not call `Nip44` directly — go through +`signer.nip44Encrypt(plaintext, toPublicKey)` / `signer.nip44Decrypt(ciphertext, fromPublicKey)` +so remote/external signers keep working. + **Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD. ### NIP-04 (Deprecated) @@ -345,44 +343,34 @@ object Nip04 { } ``` -**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues. +**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues. ## Bech32 Encoding (NIP-19) +Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`); +TLV entities carry relay hints via `create()` helpers on the entity classes in +`nip19Bech32/entities/`. Decoding goes through `Nip19Parser`, whose +`uriToRoute()` returns a `ParseReturn?` wrapping the parsed `Entity`. + ```kotlin -object Nip19 { - // Encode - fun npubEncode(pubkey: HexKey): String // npub1... - fun nsecEncode(privateKey: ByteArray): String // nsec1... - fun noteEncode(eventId: HexKey): String // note1... - fun neventEncode(eventId: HexKey, relays: List<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") } ``` diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index 950f2cb1a1..a18731696f 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -1,4 +1,8 @@ -# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz +# NIP Catalog: Quartz NIP Packages + +As of 2026-06 Quartz has **87 standard `nip*` packages** plus **23 packages +under `experimental/`**. The categorized list below may lag behind — +`ls quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/` is ground truth. ## Standard NIPs by Category diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index ebc53886f7..29b41f2d65 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.11.0` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.12.6` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.11.0" +quartz = "1.12.6" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.11.0") + implementation("com.vitorpamplona.quartz:quartz:1.12.6") } ``` @@ -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")) ``` --- diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index 2e9a737a4a..4c3a55e054 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.11.0 +com.vitorpamplona.quartz:quartz:1.12.6 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.11.0" +quartz = "1.12.6" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.11.0") + implementation("com.vitorpamplona.quartz:quartz:1.12.6") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.11.0") + implementation("com.vitorpamplona.quartz:quartz:1.12.6") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md deleted file mode 100644 index 44c187e0be..0000000000 --- a/.claude/skills/quartz-kmp.md +++ /dev/null @@ -1,23 +0,0 @@ -# Quartz KMP (Legacy Skill — Migration Complete) - -> The KMP migration of Quartz is **complete**. This file is kept for historical reference. -> -> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead. -> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill. - -## What was migrated - -The Quartz library was successfully converted from Android-only to full KMP supporting: -- **commonMain** — All Nostr protocol logic, events, filters, tags -- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers -- **androidMain** — SQLite event store, NIP-55 Android signer -- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm) -- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`) - -## Current artifact - -``` -com.vitorpamplona.quartz:quartz:1.11.0 -``` - -See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index 8aaa258d05..0bdea82309 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -24,17 +24,23 @@ relayClient/ ├── assemblers/ # "Given these inputs, build this relay Filter" │ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys │ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids -│ └── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed +│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed +│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt ├── composeSubscriptionManagers/ │ ├── ComposeSubscriptionManager.kt # interface Subscribable<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>` diff --git a/.github/actions/assert-stable-release/action.yml b/.github/actions/assert-stable-release/action.yml index 9d754a4476..14ef6ad8a6 100644 --- a/.github/actions/assert-stable-release/action.yml +++ b/.github/actions/assert-stable-release/action.yml @@ -6,7 +6,7 @@ description: >- inputs: tag: - description: "Tag to validate (e.g. v1.08.0)" + description: "Tag to validate (e.g. vX.YY.Z)" required: true is_prerelease: description: "Whether the release is marked as prerelease (empty string treated as false)" diff --git a/.github/actions/import-macos-cert/action.yml b/.github/actions/import-macos-cert/action.yml new file mode 100644 index 0000000000..223d2c317a --- /dev/null +++ b/.github/actions/import-macos-cert/action.yml @@ -0,0 +1,90 @@ +name: Import macOS Developer ID certificate +description: > + Import a Developer ID Application certificate (base64-encoded .p12) into a + throwaway keychain so codesign/jpackage can find it during the job. Soft: + when no certificate is supplied it is a no-op and reports signing=false, so + callers build UNSIGNED artifacts exactly as before. + +inputs: + certificate-p12-base64: + description: Base64 of the Developer ID Application .p12 (cert + private key) + required: true + certificate-password: + description: Password used when the .p12 was exported + required: true + +outputs: + signing: + description: "'true' if a certificate was imported, else 'false'" + value: ${{ steps.import.outputs.signing }} + keychain: + description: > + Absolute path of the throwaway keychain holding the imported cert (empty + when signing=false). Pass to tools that need an explicit keychain — e.g. + Compose's macOS signing, whose certificate lookup doesn't resolve the + search list reliably on CI runners the way bare `codesign` does. + value: ${{ steps.import.outputs.keychain }} + identity: + description: > + The imported certificate's full "Developer ID Application: NAME (TEAMID)" + common name, resolved from the keychain (empty when signing=false or if it + could not be parsed). `codesign` accepts a SHA-1 hash or a CN substring, + but Compose's MacSigner runs `security find-certificate -c <identity>` + after prepending "Developer ID Application: " — so it only works with the + exact common name. Feed this to Compose's `signing.identity`. + value: ${{ steps.import.outputs.identity }} + +runs: + using: composite + steps: + - id: import + shell: bash + env: + CERT_P12: ${{ inputs.certificate-p12-base64 }} + CERT_PASSWORD: ${{ inputs.certificate-password }} + run: | + set -euo pipefail + if [[ -z "${CERT_P12:-}" ]]; then + echo "::notice::No macOS signing certificate configured — artifacts will be UNSIGNED." + echo "signing=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db" + KEYCHAIN_PWD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/developer_id.p12" + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + echo "$CERT_P12" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$CERT_PASSWORD" \ + -k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign + # Let codesign use the private key without an interactive UI prompt. + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null + # Prepend our keychain to the user search list so codesign sees it. + security list-keychains -d user -s "$KEYCHAIN" \ + $(security list-keychains -d user | sed -e 's/[\"[:space:]]//g') + # Also make it the default keychain. Bare `codesign` resolves identities + # via the search list, but some tools (Compose's MacSigner runs + # `security find-certificate` to map the identity to a cert) don't find + # it on the search list alone on these runners. Callers that need an + # explicit keychain can read the `keychain` output below. + security default-keychain -d user -s "$KEYCHAIN" + rm -f "$CERT_PATH" + # Resolve the certificate's exact common name. Compose's MacSigner maps + # its identity to a cert via `security find-certificate -c <name>` (after + # prepending "Developer ID Application: " when absent), so it needs the + # full CN — a SHA-1 hash or partial name in the secret signs fine with + # bare `codesign` but yields "Could not find certificate ...". Pull the + # canonical name straight from the keychain so the caller is independent + # of whatever form the MAC_SIGN_IDENTITY secret takes. + IDENTITY_NAME="$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | grep -o '"Developer ID Application:[^"]*"' | head -1 | tr -d '"' || true)" + echo "signing=true" >> "$GITHUB_OUTPUT" + echo "keychain=$KEYCHAIN" >> "$GITHUB_OUTPUT" + echo "identity=$IDENTITY_NAME" >> "$GITHUB_OUTPUT" + if [[ -n "$IDENTITY_NAME" ]]; then + echo "::notice::Resolved signing identity: $IDENTITY_NAME" + else + echo "::warning::Could not resolve a 'Developer ID Application' identity from the keychain; callers will fall back to the MAC_SIGN_IDENTITY secret." + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 092ea9e37d..ee372de840 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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) @@ -227,16 +151,22 @@ jobs: :quartz:compileTestKotlinIosArm64 # :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the - # iOS plan. Compile-only for now — actual UI / lifecycle wiring will - # land with the iosApp module in Phase 3. The container that runs Claude - # Code can't extract the Kotlin/Native LLVM toolchain (sandbox limit), - # so this is the first place commonMain Compose code is actually - # type-checked against an Apple Native frontend. - - name: Compile Commons for iOS + # iOS plan. Actual UI / lifecycle wiring will land with the iosApp module + # in Phase 3, but the shared commonMain + commonTest sources are already + # built here against an Apple Native frontend. Same two-task shape as + # quartz above: + # - iosSimulatorArm64Test compiles AND runs the shared commonTest suite + # on the simulator. commonTest is built for every target, so a test + # reaching for a JVM-only API (JUnit, javaClass, @JvmStatic, or a + # jvmAndroid-only symbol) breaks the Apple build even though + # :commons:jvmTest stays green — this is the job that catches it. + # - compileTestKotlinIosArm64 catches device-only compile drift + # (iosArm64 = aarch64-apple-ios) without needing a physical device. + - name: Test Commons on iOS run: | ./gradlew \ - :commons:compileKotlinIosSimulatorArm64 \ - :commons:compileKotlinIosArm64 + :commons:iosSimulatorArm64Test \ + :commons:compileTestKotlinIosArm64 - name: Upload iOS Test Reports uses: actions/upload-artifact@v7 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7c5cdf1b47..3a0bfdf9b5 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -11,7 +11,7 @@ on: type: boolean default: false test_tag: - description: 'Synthetic tag name for dry-run (e.g. v1.08.0-dryrun); ignored on tag push' + description: 'Synthetic tag name for dry-run (e.g. vX.YY.Z-dryrun); ignored on tag push' type: string default: 'v0.0.0-dryrun' @@ -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 @@ -101,8 +101,39 @@ jobs: fi chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + # macOS only: import the Developer ID Application cert into a throwaway + # keychain so jpackage's codesign pass can find it. Soft — if the + # MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are + # provisioned) the DMG is built UNSIGNED, exactly as before. notarytool + # runs as part of the gradle task when the identity env is exported below. + - name: Import Apple Developer ID certificate (macOS leg, if configured) + if: matrix.family == 'macos' + id: mac_keychain + uses: ./.github/actions/import-macos-cert + with: + certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + - name: Build desktop artifacts uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + env: + # Empty on non-macOS legs and on the macOS leg when no cert is + # configured — the gradle macOS{} block skips signing when the + # identity is blank. Prefer the full common name resolved from the + # keychain over the raw secret: Compose's signer maps the identity via + # `security find-certificate` and only matches the exact "Developer ID + # Application: …" CN, whereas the secret may be a hash or partial name + # (which bare codesign accepts but Compose does not). Fall back to the + # secret if resolution failed. + AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && (steps.mac_keychain.outputs.identity || secrets.MAC_SIGN_IDENTITY) || '' }} + # Explicit keychain for Compose's MacSigner. Its `security + # find-certificate` lookup doesn't resolve the imported cert via the + # search list on these runners ("Could not find certificate ... in + # keychain []"), so point it at the throwaway keychain directly. + AMETHYST_MAC_SIGN_KEYCHAIN: ${{ steps.mac_keychain.outputs.signing == 'true' && steps.mac_keychain.outputs.keychain || '' }} + AMETHYST_NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }} + AMETHYST_NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }} + AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} with: max_attempts: 2 timeout_minutes: 15 @@ -212,7 +243,7 @@ jobs: - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: run: shell: bash @@ -261,6 +292,96 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + # amy is headless: the Compose UI render stack (skiko + its native dylibs, + # foundation/material/material3/ui/animation) must never reach the CLI + # image. cli/build.gradle.kts excludes it from runtimeClasspath; this + # guards against a transitive dep silently dragging it back (size + macOS + # notarization-surface regression). compose.runtime is CLI-safe and stays. + - name: Assert no Compose UI in the amy image + run: | + set -euo pipefail + LIB="cli/build/install/amy/lib" + leak="$(ls "$LIB" | grep -iE 'skiko|foundation(-layout)?-desktop|material3?-desktop|material-ripple|ui-desktop|animation(-core)?-desktop' || true)" + if [ -n "$leak" ]; then + echo "::error::Compose UI render stack leaked into the amy CLI image:" + echo "$leak" | sed 's/^/ /' + echo "Exclude it in cli/build.gradle.kts (configurations.runtimeClasspath)." + exit 1 + fi + echo "OK: no skiko / Compose UI render jars in the amy image ($(du -sh "$LIB" | cut -f1))." + + # macOS only: import the Developer ID cert (no-op without the secret) so + # the next step can codesign the jlink image. The jvm bundle for + # Homebrew-core is NOT signed here — Homebrew strips quarantine itself. + - name: Import Apple Developer ID certificate (macOS leg, if configured) + if: matrix.family == 'macos' + id: mac_keychain + uses: ./.github/actions/import-macos-cert + with: + certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + + # Codesign + notarize the macOS jlink image (amy-<ver>-macos-arm64.tar.gz) + # for users who download it directly. A loose tarball can't be stapled + # (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization + # online on first run. Runs before "Collect" so the tarred image is signed. + - name: Sign + notarize amy image (macOS leg, if configured) + if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' + env: + SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }} + NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }} + NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }} + NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} + run: | + set -euo pipefail + IMG="cli/build/amy-image/amy" + ENTITLEMENTS="cli/packaging/macos/amy.entitlements" + # First sign the macOS Mach-O natives buried INSIDE the bundled jars + # (secp256k1/jna/sqlite/skiko/jkeychain/mediaplayer). The loose-file + # loop below can't see them, but Apple's notary recurses into jars and + # rejects any unsigned Mach-O — so this must run before notarize. + SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG" + # Sign every loose Mach-O binary in the bundled JRE. Each is signed + # independently (no enclosing .app seals them), so order is irrelevant. + # Executables get the hardened-runtime entitlements; dylibs don't. + while IFS= read -r f; do + case "$(file -b "$f")" in + *Mach-O*executable*) + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;; + *Mach-O*) + codesign --force --options runtime --timestamp \ + --sign "$SIGN_IDENTITY" "$f" ;; + esac + done < <(find "$IMG" -type f) + codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java" + # Notarize: zip the signed image, submit, wait for Apple's verdict. + # The notary service recursively inspects the lib/*.jar files; their + # embedded Mach-O natives are signed by sign-macos-jar-natives.sh + # above. Surface the per-file log on any non-Accepted verdict so a + # regression is diagnostic rather than a bare failure. + ZIP="$RUNNER_TEMP/amy-notarize.zip" + OUT="$RUNNER_TEMP/notary-submit.json" + ditto -c -k --keepParent "$IMG" "$ZIP" + if ! xcrun notarytool submit "$ZIP" \ + --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ + --team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then + echo "::warning::notarytool submit exited non-zero" + fi + cat "$OUT" + STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)" + SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)" + if [ "$STATUS" != "Accepted" ]; then + echo "::error::Notarization status: $STATUS" + if [ -n "$SUBMISSION_ID" ]; then + echo "----- notary log -----" + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ + --team-id "$NOTARY_TEAM_ID" || true + fi + exit 1 + fi + # jpackage pins libicu to the build host's version (libicu74 on # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. - name: Relax libicu dependency in .deb @@ -277,6 +398,23 @@ jobs: source scripts/asset-name.sh collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + # Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it + # cannot use the jlink tarball above (bundled runtime) nor build from + # source (its sandbox blocks Gradle's Maven downloads). installDist + # (bin/amy + lib/*.jar, no runtime/) is exactly that bundle. It is pure + # JVM bytecode, so one platform-independent asset serves every OS; we cut + # it on the linux leg only. amyImage depends on installDist, so the + # cli/build/install/amy tree already exists here. + - name: Package no-JRE jvm bundle for Homebrew (linux leg only) + if: matrix.family == 'linux' + run: | + set -euo pipefail + VER="${{ steps.ver.outputs.version }}" + SRC="cli/build/install/amy" + test -x "$SRC/bin/amy" + ( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib ) + echo "Collected: dist/amy-${VER}-jvm.tar.gz" + - name: Enforce CLI size budget (200 MB per asset) run: | set -euo pipefail @@ -434,6 +572,48 @@ jobs: "dist/amethyst-fdroid-${TAG}.aab" ls -la dist + # Accrescent does not accept AABs or monolithic APKs — it requires a signed + # APK set (.apks) of split APKs generated by bundletool from the AAB. We build + # it from the F-Droid flavor (no proprietary Google deps) and sign the splits + # with the same release keystore used above. Upload is still manual: drop this + # .apks into https://console.accrescent.app (no publish API/CLI exists yet). + - name: Build Accrescent APK set (F-Droid) + env: + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + BUNDLETOOL_VERSION="1.18.3" # must be >= 1.11.4 per Accrescent requirements + curl -fsSL -o bundletool.jar \ + "https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar" + + # Same base64 keystore secret consumed by the r0adkll signing steps above. + echo "$SIGNING_KEY" | base64 -d > release.keystore + + # --mode=default emits the split-APK set Accrescent wants (NOT --mode=universal, + # which produces a monolithic APK that Accrescent rejects). + java -jar bundletool.jar build-apks \ + --bundle="dist/amethyst-fdroid-${TAG}.aab" \ + --output="dist/amethyst-fdroid-${TAG}.apks" \ + --ks=release.keystore \ + --ks-key-alias="$KEY_ALIAS" \ + --ks-pass="pass:$KEY_STORE_PASSWORD" \ + --key-pass="pass:$KEY_PASSWORD" \ + --mode=default + + rm -f release.keystore bundletool.jar + + # Accrescent's automated check rejects an APK set larger than 128 MiB. + SIZE_BYTES=$(stat -c%s "dist/amethyst-fdroid-${TAG}.apks") + echo "Accrescent APK set size: $((SIZE_BYTES / 1024 / 1024)) MiB" + if [ "$SIZE_BYTES" -gt $((128 * 1024 * 1024)) ]; then + echo "::warning::amethyst-fdroid-${TAG}.apks exceeds Accrescent's 128 MiB limit; the console will reject this upload." + fi + ls -la dist + - name: Classify release id: classify run: | diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index e23a28596e..3712fed647 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -10,12 +10,21 @@ permissions: pull-requests: write jobs: + # Single job so the Crowdin translation sync and the translator-placeholder seed + # land in ONE pull request instead of two. The Crowdin action only downloads into + # the working tree (push_translations/create_pull_request disabled); the seed + # script then edits translators.json; finally one create-pull-request step opens a + # single PR with both sets of changes (and no-ops when there is no diff). synchronize-with-crowdin: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 + with: + # Need tags so scripts/translators.sh can resolve the last v* release tag + # for the "since last tag" window. + fetch-depth: 0 - name: crowdin action uses: crowdin/github-action@v2 @@ -23,12 +32,42 @@ jobs: upload_sources: true upload_translations: true download_translations: true - localization_branch_name: l10n_crowdin_translations - create_pull_request: true - pull_request_title: 'New Crowdin Translations' - pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' - pull_request_base_branch_name: 'main' + # Let the downloaded translations stay in the working tree; the single + # create-pull-request step below opens the combined PR. + push_translations: false + create_pull_request: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # Keep docs/changelog/translators.json seeded with everyone who has translated + # recently, so the per-release `## Translations` credits (scripts/translators.sh) + # can resolve them to npubs. Only adds rows when a genuinely new contributor + # appears. + - name: Seed translator placeholders from Crowdin + run: bash scripts/translators.sh --seed + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + - name: Open or update the combined Crowdin PR + # peter-evans/create-pull-request is MIT-licensed CI-only tooling (not + # linked into any shipped artifact). It no-ops when there is no diff. + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: l10n_crowdin_translations + add-paths: | + amethyst/src/main/res/**/strings.xml + docs/changelog/translators.json + commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders' + title: 'New Crowdin Translations' + body: | + New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action). + + Any new Crowdin contributors were added to `docs/changelog/translators.json` + with blank npubs. Fill in the npubs you have so the next release's + `## Translations` credits generate automatically via + `scripts/translators.sh --from <prev-tag> --to <this-tag>`. diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 540d8466b2..de8cc824b9 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -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 diff --git a/.gitignore b/.gitignore index 459dc6effa..45f5f1d139 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 56c25ee01f..5877ff2fad 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -8,6 +8,6 @@ </component> <component name="KotlinJpsPluginSettings"> <option name="externalSystemId" value="Gradle" /> - <option name="version" value="2.3.21" /> + <option name="version" value="2.4.0" /> </component> </project> \ No newline at end of file diff --git a/BUILDING.md b/BUILDING.md index 71c59e60aa..7a85691bdf 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -1,13 +1,22 @@ # Building Amethyst Desktop -This guide covers building Amethyst Desktop from source, the release pipeline, -and one-time bootstrap steps for distribution channels. +This guide has everything **any fork** needs to build Amethyst from source and +cut its own release: prerequisites, build commands, the CI release pipeline, the +secrets it needs, the distribution channels, and one-time bootstrap steps. + +> **Amethyst maintainers:** the account-specific checklist for shipping the +> official build (Play Console upload, Zapstore `zsp publish` with our nsec, +> secret ownership) lives in [`RELEASE_OPS.md`](RELEASE_OPS.md). This +> file stays fork-generic. - [Prerequisites](#prerequisites) - [Clone + first build](#clone--first-build) +- [Generated & vendored artifacts](#generated--vendored-artifacts) - [Per-format build commands](#per-format-build-commands) - [Asset naming contract](#asset-naming-contract) - [Release runbook](#release-runbook) +- [Secrets the CI needs](#secrets-the-ci-needs) +- [Distribution channels](#distribution-channels) - [Bootstrap runbook (one-time)](#bootstrap-runbook-one-time) - [Troubleshooting installs](#troubleshooting-installs) - [Uninstall + state paths](#uninstall--state-paths) @@ -68,6 +77,29 @@ cd amethyst --- +## Generated & vendored artifacts + +Two build inputs are **generated by tools but committed to the repo**, so a +normal build or release does **not** run either — Gradle just consumes the +checked-in output. You only regenerate them under the specific conditions below, +and each has its own guide: + +| Artifact | Committed at | Regenerate when | Guide | +|---|---|---|---| +| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` | +| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) | + +> **Material Symbols is mandatory after icon changes.** The bundled font is a +> ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at +> runtime. Regenerate and commit the `.ttf` alongside the `MaterialSymbols.kt` +> change. Reusing an existing codepoint needs no regeneration. + +Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a +Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they +are **not** required to build Amethyst from the committed sources. + +--- + ## Per-format build commands | Artifact | Command | Output | @@ -111,11 +143,11 @@ amethyst-desktop-<version>-<family>-<arch>.<ext> Where: -| Field | Values | -|---|---------------------------------------------------------| -| `<version>` | Tag stripped of leading `v` (e.g. `1.11.0`) | -| `<family>` | `macos`, `windows`, `linux` | -| `<arch>` | `x64`, `arm64` | +| Field | Values | +|---|-------------------------------------------------------| +| `<version>` | Tag stripped of leading `vX.YY.ZZ` | +| `<family>` | `macos`, `windows`, `linux` | +| `<arch>` | `x64`, `arm64` | | `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` | Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh). @@ -124,10 +156,60 @@ any change is a breaking contract. Examples: -- `amethyst-desktop-1.11.0-macos-x64.dmg` -- `amethyst-desktop-1.11.0-macos-arm64.dmg` -- `amethyst-desktop-1.11.0-windows-x64.msi` -- `amethyst-desktop-1.11.0-linux-x64.AppImage` +- `amethyst-desktop-1.12.1-macos-x64.dmg` +- `amethyst-desktop-1.12.1-macos-arm64.dmg` +- `amethyst-desktop-1.12.1-windows-x64.msi` +- `amethyst-desktop-1.12.1-linux-x64.AppImage` + +--- + +## Reproducible Android builds + +The release APKs are **bit-for-bit reproducible**: anyone can rebuild the exact +bytes we ship (minus the signature) from the tagged source and confirm the +artifact on F-Droid / Zapstore / GitHub was built from this code and nothing +else. What makes that hold: + +- **Pinned toolchain.** AGP, Kotlin, R8, and the Compose compiler are pinned in + `gradle/libs.versions.toml`; the build targets **JDK 21**. R8 is deterministic + for a fixed version + inputs, so the minified output is stable. Build with the + same JDK 21 you see in `BUILDING.md` / CI. +- **No build-time clock.** Nothing injects `System.currentTimeMillis()` / + build dates into `BuildConfig` (a Spotless rule bans the call in `quartz` and + `commons`), and AGP normalizes ZIP entry timestamps, so two builds an hour + apart are identical. +- **Deterministic version name.** `generateVersionName` only appends a branch + suffix off feature branches; a release tag builds in detached-`HEAD` (or from a + source tarball with no `.git`) resolve to the bare `app` version. +- **No dependency-metadata blob.** `dependenciesInfo { includeInApk = false; + includeInBundle = false }` in `amethyst/build.gradle.kts` stops AGP from + embedding the Google-encrypted dependency protobuf in the signing block — that + ciphertext is non-deterministic. +- **Reproducible native library.** The bundled Tor (Arti) `.so` is the one + binary we compile ourselves; it is built reproducibly from source (pinned Rust + toolchain, locked deps, canonical build path). See + [`tools/arti-build/README.md`](tools/arti-build/README.md) → "Reproducible + builds". All other native libs (`secp256k1`, `webrtc`) are version-pinned Maven + prebuilts and so are byte-identical by download. + +### Verify a release APK reproduces + +```bash +# 1. Check out the exact released tag and build the same variant unsigned. +git checkout v1.12.1 +./gradlew clean :amethyst:assembleFdroidRelease + +# 2. Diff your unsigned build against the published APK, ignoring only the +# signature (META-INF/*). apksigner + a zip-aware diff is the simplest check; +# diffoscope gives a human-readable breakdown of any remaining delta. +diffoscope \ + amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned.apk \ + amethyst-fdroid-arm64-v8a-1.12.1.apk +``` + +A clean run shows differences confined to `META-INF/` (the signing files). Any +diff in `classes*.dex`, `resources.arsc`, or native libs means something in the +toolchain drifted — file it before publishing. --- @@ -136,38 +218,37 @@ Examples: The release flow is driven by a tag push. Every cut ships Android + Desktop + Quartz library in one pipeline. -1. **Bump the app version** in `gradle/libs.versions.toml`: +1. **Bump the app version and Android `versionCode`** in + `gradle/libs.versions.toml` (`appCode` is a monotonic integer — it must + increment even when `app` is unchanged): ```toml [versions] app = "1.08.1" # new semver + appCode = "449" # Android versionCode ``` -2. **Bump Android `versionCode`** in `amethyst/build.gradle` (monotonic integer, - must increment even for same `versionName`): + `amethyst/build.gradle.kts` reads both from the catalog + (`versionCode = libs.versions.appCode.get().toInt()`), so there is nothing + else to edit. - ```groovy - versionCode = 447 - versionName = generateVersionName(libs.versions.app.get()) - ``` - -3. **Commit + tag + push**: +2. **Commit + tag + push**: ```bash - git commit -am "chore(release): 1.08.1" - git tag -s v1.08.1 -m "Release 1.08.1" + git commit -am "chore(release): 1.12.1" + git tag -s v1.12.1 -m "Release 1.12.1" git push && git push --tags ``` -4. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). +3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -5. **Verify**: +4. **Verify**: - GH Release contains 8 desktop assets + 12 Android assets - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - Intel + ARM DMGs both present - Android flow unchanged -6. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`, +5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`, or `-snapshot` is auto-classified as prerelease. Stable tags trigger the Homebrew + Winget bump workflows. @@ -203,19 +284,149 @@ uninstall before a new release. Leave it alone forever. --- +## Secrets the CI needs + +The `Create Release Assets` workflow reads these from GitHub repo secrets. A +fork must provide its **own** values — none are inherited. (`GITHUB_TOKEN` is +provided automatically; everything else you set yourself.) + +| Secret | What it is | Used for | +|---|---|---| +| `SIGNING_KEY` | Base64 of your **Android keystore** (`.jks`/`.keystore`) | Signs the Play + F-Droid **AAB and APK** | +| `KEY_ALIAS` | Keystore key alias | Same Android signing step | +| `KEY_STORE_PASSWORD` | Keystore password | Same | +| `KEY_PASSWORD` | Key password | Same | +| `SONATYPE_USERNAME` | Maven Central (Sonatype) user token name | Publishing the `quartz` library | +| `SONATYPE_PASSWORD` | Maven Central user token password | Same | +| `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) | +| `SIGNING_PASSWORD` | Passphrase for that GPG key | Same | +| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** and the macOS **amy** jlink tarball | +| `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain | +| `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with | +| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) | +| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same | +| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same | +| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) | +| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) | +| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) | + +Note the **three distinct signing identities** people often conflate: +`SIGNING_KEY` + `KEY_*` is the **Android keystore**; `SIGNING_PRIVATE_KEY` + +`SIGNING_PASSWORD` is the **GPG key** for Maven Central; `MAC_CERTIFICATE_*` + +`MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS +desktop DMG. They are unrelated — each comes from a different authority. + +The macOS signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is unset +the release workflow still builds the DMG **and** the macOS `amy` tarball, just +**unsigned** (the previous behavior). Provision all six to switch signing + +notarization on for both. Obtaining them requires Apple Developer Program +membership ($99/yr). The same one certificate signs both artifacts. + +The macOS `amy` tarball is the jlink image (bundled JRE), so signing it means +codesigning every Mach-O binary in that runtime with hardened-runtime +entitlements (`cli/packaging/macos/amy.entitlements` — needed so the JVM can +load the secp256k1 native library it extracts at runtime). A loose `.tar.gz` +cannot be **stapled** (Apple's `stapler` only handles `.app`/`.dmg`/`.pkg`), so +Gatekeeper verifies notarization **online** on first run — fine for a CLI. +Note the Homebrew-core jvm bundle (`amy-<version>-jvm.tar.gz`) is **not** signed: +Homebrew removes the quarantine attribute on its own downloads. + +> **Validated (Developer ID `D77MCV9NZ7`):** signing every Mach-O in the bundled +> JRE with hardened runtime + `amy.entitlements` lets `amy init` derive a key via +> secp256k1 with no library-validation crash. Dropping `disable-library-validation` +> reproduces `UnsatisfiedLinkError: … different Team IDs` on the runtime-extracted +> `libsecp256k1-jni.dylib` — so that entitlement is load-bearing, not decorative. +> +> **Open risk — embedded jar natives.** The notary service unpacks `lib/*.jar` +> recursively and checks every Mach-O for a signature + hardened runtime. Our +> sign loop only touches loose files, so 9 unsigned natives ride along inside +> jars on a macOS build: `secp256k1` (1, required at runtime), `jna` (2), +> `sqlite` (2), and `skiko` (4, dead weight — Compose UI the CLI never renders). +> Whether `notarytool` returns `Accepted` or `Invalid` on these is **unverified** +> (the local validation had no notary creds). **Decide it with one run:** set the +> six `MAC_*` secrets and trigger `create-release.yml` via `workflow_dispatch` +> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and +> now prints the per-file notary log on a non-`Accepted` verdict. If it comes +> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before +> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the +> `:commons` core/ui split the size budget already flags). The **desktop** app +> bundles the same jars through Compose/jpackage notarization, so run a desktop +> dry-run too; its in-jar handling differs and is likewise unverified. + +Generating the values: + +```bash +# Android keystore → base64 for SIGNING_KEY (one line, no wrapping) +keytool -genkey -v -keystore upload.jks -keyalg RSA -keysize 2048 \ + -validity 10000 -alias upload # creates the keystore (once) +base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY + +# GPG key → armored private key for SIGNING_PRIVATE_KEY +gpg --full-generate-key # create the key (once) +gpg --armor --export-secret-keys <KEY_ID> # paste output into SIGNING_PRIVATE_KEY + +# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12. +# In Keychain Access, export the "Developer ID Application: ..." cert (with its +# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD). +base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12 +security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string +# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com +# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login. +``` + +`SONATYPE_USERNAME`/`SONATYPE_PASSWORD` are a **user token** from +<https://central.sonatype.com> (Account → Generate User Token), not your login. +A fork that doesn't publish a library can drop the `Publish Quartz Lib` step and +the four Sonatype/GPG secrets. + +--- + +## Distribution channels + +One `v*` tag fans out to several channels. Which apply depends on where a fork +distributes; the official Amethyst rollout for each is in +[`RELEASE_OPS.md`](RELEASE_OPS.md). + +| Channel | How it ships | Push or pull | +|---|---|---| +| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) | +| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) | +| **Google Play** | Download the signed `amethyst-googleplay-<version>.aab` from the GH Release and upload it in Play Console | **Manual push** | +| **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** | +| **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** | +| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) | + +Two channels need the build to stay split into product flavors (see +`amethyst/build.gradle.kts` → `productFlavors`): + +- **`play`** carries Firebase / Google Play Services (push notifications, ML + Kit, etc.) → the Google Play AAB. +- **`fdroid`** swaps those for UnifiedPush and no-op/open-source + implementations (`amethyst/src/fdroid/…`) so the build is free of proprietary + dependencies → what F-Droid builds and what Zapstore distributes. + +**F-Droid is pull, not push.** We never upload to F-Droid; its server builds our +tagged source. Keeping the `fdroid` flavor proprietary-free and the +`fastlane/metadata/android/` descriptions current is all that's required. F-Droid +reads an optional per-release changelog from +`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`. + +--- + ## Bootstrap runbook (one-time) ### Secrets to provision in GitHub repo settings +The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs). +The two that need the most setup care are the package-manager PATs, because of +their token type and scope: + | Secret | Purpose | Scope | |---|---|---| | `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry | | `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | -All existing secrets (`SIGNING_KEY`, `SONATYPE_USERNAME`, etc.) remain -unchanged. - -Rotate both on a 90-day cadence. Owner: assigned via `docs/RELEASE_OPS.md` +Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md` or equivalent issue tracker. On rotation, paste new token and run `gh workflow run bump-homebrew.yml` on the most recent stable tag to verify. @@ -223,19 +434,64 @@ or equivalent issue tracker. On rotation, paste new token and run ```bash brew bump-cask-pr amethyst-nostr \ - --version 1.11.0 \ - --url "https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-macos-arm64.dmg" + --version 1.12.1 \ + --url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-macos-arm64.dmg" ``` The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a tiling window manager). After the first PR is merged, `bump-homebrew.yml` auto-submits new version bumps on each stable release. +> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the +> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in +> homebrew-**core**; both are "mainline." A private tap is only the *fallback* +> if Homebrew ever rejects the (now signed + notarized) cask. + +### Homebrew-core formula for the `amy` CLI (one-time initial PR) + +The CLI goes to **homebrew-core** (mainline formulae), not homebrew-cask — +casks are for GUI apps. homebrew-core builds in a **network-sandboxed** +environment, so a from-source Gradle build can't resolve its Maven +dependencies there. Instead the formula downloads the pre-built **no-JRE jar +bundle** `amy-<version>-jvm.tar.gz` (published by `create-release.yml`) and +`depends_on "openjdk"`. The reference formula lives at +[`cli/packaging/homebrew/amy.rb`](cli/packaging/homebrew/amy.rb). + +To submit: + +```bash +# 1. Grab the published asset's sha256 +curl -fsSL -o amy-jvm.tar.gz \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz +shasum -a 256 amy-jvm.tar.gz + +# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR +brew create --set-name amy --tap homebrew/core \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz +# (paste the reference formula body, run `brew audit --new amy`, +# `brew install --build-from-source amy`, `brew test amy`, then PR it.) +``` + +Caveats that the maintainer must weigh before submitting: + +- **Name collision.** `amy` may already exist in homebrew-core — check with + `brew search amy` first. If taken, fall back to `amethyst-cli`. +- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading + a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to + justify it (sandboxed Gradle can't fetch Maven deps). +- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks + Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons` + core/ui split) would shrink it and smooth review — tracked as a follow-up. + +After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot +auto-open version-bump PRs on each stable release — no token or workflow on our +side (unlike the cask/winget bumps). + ### Winget (one-time initial submission) ```bash wingetcreate new \ - https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-windows-x64.msi + https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-windows-x64.msi ``` Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is @@ -372,9 +628,17 @@ for the deprecation date. When it hits: Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask` on 2026-09-01. Before that date: -**Option A**: Commit budget to Apple Developer Program ($99/yr), add -`signing { sign.set(true) }` + `notarization {}` blocks to -`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI. +**Option A (wiring done — needs Apple creds)**: The `signing { sign.set(true) }` ++ `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on +the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of +`create-release.yml` imports a Developer ID cert into a throwaway keychain and +exports the signing/notary env. It all stays a **no-op until the six +`MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI +needs](#secrets-the-ci-needs)) — until then the DMG builds unsigned. To turn it +on: join the Apple Developer Program ($99/yr), create a *Developer ID +Application* certificate, generate an app-specific password, and set the six +secrets. The first signed+notarized DMG is best validated with a +`workflow_dispatch` dry-run before a real tag. **Option B**: Pivot to a private Homebrew tap: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4681157c81..c01c41ff89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7919 +1,10 @@ -<a id="v1.11.0"></a> NIP-52 Calendars, On-Chain Zap Polish +# Changelog -Highlights: -- NIP-52 Calendars — feed, day/week/month views, event/collection editors, RSVP, ICS export, "starting soon" notifications, share-as-nostr-link, gallery picker. -- On-Chain zaps polish — splits with dust gating, payment-targets modal redesign (QR + copy + pay), public-address chip + confirmation, on-chain handoff from custom-zap dialog, on-chain zappers in the reactions gallery. -- Privacy groundwork — I2P scaffolded as a parallel transport to Tor, route-aware HTTP stack, typed fail-closed `BlockedRouteException`. -- Notifications — dedicated settings screen, delivery vs display split, on-chain zaps in summary stats, push filters aligned with the Notifications feed. -- Inline nowhere-link cards in rich text. -- Wallet — clickable on-chain rows, tappable wallet card opens transaction history, reactive on-chain zap observation, time-windowed relay sub with burst coalescing. +The changelog has moved. Release notes now live in +[`docs/changelog/`](docs/changelog/), one file per version, with an index in +[`docs/changelog/README.md`](docs/changelog/README.md). -PRs: -- Restore Lightning Address + LNURL fields in Edit Profile -- Raise the default maximum hashtag limit from 5 to 8 -- Include on-chain zaps in notification summary stats -- Add electrum.nmc.ethicnology.com to default ElectrumX server set -- Translate call-permission and git-repo strings (cs, pt-BR, sv, de) + New Crowdin translations -- Subscribe and observe PaymentTargetsEvent for other users -- Only bridge to local cache when URL is BUD-01 layout; dedupe public-channel rows; normalize video/x-m4v to video/mp4 -- Enable Send when typed name resolves via NIP-05; on-chain Bit recipient -- Inline Namecoin resolution indicator + result row on on-chain zaps -- Inline Namecoin resolution indicator in global search bar -- Long-press to copy Nostr Address, Website, LN Address, identities, payment targets -- macOS VLC bundled discovery and video rendering -- Make RelayAuthenticator authStatus thread-safe -- Pad GitRepositoryOverview content to clear top/bottom bars -- Use LargeCache for RelayAuthenticator authStatus -- MIME-type normalization for MediaStore -- Split delivery vs display, add Categories section in notifications -- Accept floating-point dimensions in imeta so image space is reserved pre-load -- Explain hashtag-limit hide reason in HiddenNote -- Route media-upload signing through launchSigner -- Render nowhere links inline as branded cards -- Release MediaController after 30s in background; skip timer for PiP -- Clickable on-chain card opens transaction history; tappable on-chain rows + txid index in LocalCache -- Disable ProGuard optimization entirely on desktop (fixes kmp-tor crash); .deb launch CI smoke test -- Show on-chain zappers in expanded reactions gallery -- Convert hashtag-limit message to <plurals> and add cs/pt-BR/sv/de translations -- Restore long-press on root note + anchor popup to the card -- Keep filter chips visible on empty on-chain transactions list -- Make playback notification tap open the note on warm-pool resume -- Validate zap receipts against LNURL provider's nostrPubkey (NIP-57 Appendix F) -- Stable sort in ShortsFeedFilter to avoid TimSort contract crash -- Align push notifications with Notifications feed filter; resolve addressable events to their replaceable note; WakeUp bypass + lookup hoist -- Update on-chain-zap + headers-explorer plans for NIP-BC inline SPV tags -- Start zoomable dialog close animation from zoomed bounds -- Scaffold I2P as a parallel privacy transport to Tor; route-aware HTTP stack; BlockedRouteException -- Reorder feed filter popup sections (DVMs before Communities, interest sets next to hashtags) -- Stop spinning Around Me when Geocoder is unavailable -- NIP-52 calendars — feed, day/week/month views, RSVP, ICS export, reminders, edit, share-as-nostr-link, participant picker, multi-day bars -- Audit follow-ups on MLS reply paths; route reply button on MLS messages to the encrypted group -- Use LaunchedEffect instead of remember for VM reset -- Switch to Dispatchers.IO around the whole executeAsync call in relay-info -- Drop crossfade around the card feed in notifications -- Observe locale in CalendarDateTimePickerButton -- Modernize zap amount choice popup to match reactions popup style -- Catch ForegroundServiceStartNotAllowedException in PlaybackService -- Include crashing thread name in crash report -- Convert remaining .gradle files to Kotlin DSL -- Redesign payment targets modal with QR, copy and pay buttons -- Add Public chip to on-chain wallet card; clarify privacy guidance on on-chain wallet popup -- Require running material-symbols-subset.sh when adding new icons; add new information icon -- Re-add Nostrcheck.me Blossom server to defaults -- Surface on-chain zaps from the reactions zap button -- On-chain zap splits — drop sender from splits, merge duplicates, gate Send on dust -- On-chain option on the Zap the Devs button -- On-chain handoff from the custom-zap dialog -- Keep listening on default index+search relays for users with no kind 10002 -- Skip offline relays when fetching user metadata -- Include indexer and proxy relays in the trusted relay list to avoid Tor -- Confirm on-chain copy with public-address warning -- Swallow LegacyCursorAnchorInfo IllegalArgumentException -- Render ContactListEvent in NoteCompose with tap-through user list; add preview for DisplayContactList - -## Contributors - -- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z -- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c -- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k -- @npub1dn0tej4a5806qk9ts56j572sndvjk27l5qmsxf0z3mquknccve7s4k8tfp - -## Translations - -- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp -- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz -- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd -- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm -- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 -- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw -- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t -- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 -- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h -- Russian by Anton Zhao - -<a id="v1.10.0"></a> On-Chain zaps - -- ci: remove macos-13 x64 build legs by @vitorpamplona in #2939 -- Replace linuxdeploy with appimagetool for AppImage packaging by @vitorpamplona in #2940 -- Sync inner note highlight fade with parent background color by @vitorpamplona in #2941 -- Handle denied call permissions with settings dialog by @vitorpamplona in #2942 -- Fix gradient rendering with transparent background colors by @vitorpamplona in #2943 -- Add NIP-BC onchain Bitcoin zaps support (send, receive, display) by @vitorpamplona in #2944 - -<a id="v1.09.2"></a> Fixes - -- Fix Blossom blob detection to reject non-compliant filenames by @greenart7c3 in #2919 -- fix(desktop): add ProGuard keep rules so desktop builds actually launch by @mstrofnone in #2921 -- fix(desktop): mirror Android ProGuard strategy for release builds by @mstrofnone in #2922 -- fix(video): pause playback when app goes to background by @vitorpamplona in #2925 -- feat(desktop): wire NamecoinSettingsSection into Settings screen by @mstrofnone in #2923 -- feat(desktop): wire Import Follow List dialog into UI by @mstrofnone in #2924 -- Fix crash when toggling home tabs with persisted pager state by @vitorpamplona in #2930 -- Dedupe public channels in known chat list by channel id by @vitorpamplona in #2932 -- Route NIP-43 relay join/leave through launchSigner by @vitorpamplona in #2933 -- Migrate DNS cache from SharedPreferences to cacheDir by @vitorpamplona in #2935 -- Filter DNS poison (loopback / any-local) from non-loopback hosts by @vitorpamplona in #2936 -- perf(dns-cache): hand-rolled binary persistence format for SurgeDnsStore by @vitorpamplona in #2937 -- Ignore duplicated hashtags in different char cases when processing hashtag spam by @vitorpamplona -- New Crowdin translations - -<a id="v1.09.1"></a> Activates Nest, Git Screens and Fixes - -- Activates Nest option in the Left Drawer -- Rename onRefresh callback to avoid shadowing parameter by @vitorpamplona in #2912 -- Fix desktop app ProGuard build with Compose 1.11.0 by @vitorpamplona in #2914 -- Fix pull-to-refresh indicator positioning in DisappearingScaffold by @vitorpamplona in #2916 -- Remove collectAsStateWithLifecycle from BottomBarSettingsContent by @vitorpamplona in #2918 -- Add Git repository detail screen with issues/patches tabs by @vitorpamplona in #2917 - -<a id="v1.09.0"></a> -# [Release v1.09.0: We are going crazy](https://github.com/vitorpamplona/amethyst/releases/tag/v1.09.0) - 2026-05-14 - -What's New? -- Go live on audio spaces (Nests) -- Marmot Group chats (WhiteNoise) -- Voice and Video calls (Noscall) -- PDF reader -- Favorite algo feeds -- HLS Video Uploads -- Schedule posts for later -- Cast videos to your TV (Chromecast) -- Multiple accounts on Desktop -- Mute a whole conversation thread -- Tap any timestamp to see the exact date and time -- Pull Notification (internal Pokey) -- Local LLM helpers (Pixel 9+, Samsung 25+, Xiaomi 15+) -- `amy`, a command-line Nostr client - -## Features - -- Voice and Video Calls (NIP-AC) — one-on-one and group WebRTC calls. - - Full-screen incoming-call UI over the lock screen. - - PiP, ringtone and vibration. - - Proximity sensor support. - - Bluetooth headset routing. - - Camera switch. - - Network resilience and default TURN servers. - - Mid-call peer invites with a 30s timeout and per-peer status. - - Settings toggle to disable calls. -- Audio Rooms / Nests (NIP-53) — a full revamp. - - Live chat panel. - - Reactions overlay and picker. - - Listener counter. - - Presence with publishing/onstage tags. - - Hand-raise queue. - - Host actions: kick, promote, demote, edit, close room. - - Per-participant context sheet. - - Scheduled rooms with a TimePicker and SCHEDULED badge. - - "Listen to recording" CTA for closed rooms. - - Share a room as `naddr1`. - - Custom room themes and fonts (kind 30312). - - Home live-bubble row showing follows broadcasting. - - Host-leave confirmation and default-server prompt. - - In-app lobby with a chat composer, gating room re-entry. - - PiP that focuses active speakers. - - Feed bucketed into Live / Scheduled / Recently ended, with live status verified by current presence. - - Live audio-level speaker ring. - - Keeps the screen on while connected. - - Audio plays through the media volume stream. -- Marmot Encrypted Group Chats (MLS over Nostr / NIP-EE) - - Create, join and leave groups. - - Inline group rendering in Messages. - - Member management with user search. - - Admin grant/revoke. - - Group info screen with picture, member list and per-relay freshness. - - Auto-publish KeyPackage. - - Reset Marmot State safety valve in Settings. - - Full RFC 9420 compliance pass (P0/P1/P2). - - External Commit flow. - - Retained-epoch decryption for offline catch-up. - - Required-capabilities advertised on groups; interop fixes for other Marmot clients. - - Popup notifications for group messages (kind:445). -- Multi-account on Desktop - - Account switcher dropdown in the sidebar and single-pane layout. - - Add Account dialog and per-account logout. - - View-only (npub-only) accounts. - - Account removal switches to another account or logs out cleanly. - - Encrypted local account storage (AES-256-GCM). - - Display names and middle-truncated npubs. -- Schedule posts for later - - Date/time picker and toolbar toggle in the post composer. - - Dedicated screen and drawer entry to view, push or delete scheduled posts. - - Background worker that publishes at the scheduled time. - - Warning when scheduling without always-on notifications. -- Cast videos to your TV - - Chromecast casting (Google Play build only). - - Stop-from-phone button; the local player pauses while casting. - - Cast button backfilled for accounts that already had video settings. -- Mute a whole thread - - Mute thread entry in the long-press dropdown and quick-action sheet. - - Muted threads listed in Security Filters with an unmute action. - - Muted threads dropped from feeds, notifications and push delivery. -- Configurable home tabs - - Choose between New Threads, Conversations and Everything. - - Visibility toggles persist across restarts. -- Configurable bottom navigation bar - - Pick which screens appear in the bottom bar. - - Restore-default button in settings. -- Reply and Mention notifications (NIP-10 / NIP-22) - - Dedicated Mentions channel. - - Per-thread grouping. - - Inline reply. - - All content-event citations routed to Mentions. - - Opt-in Following / Everyone tab split. -- Filter the home feed in place by hashtag, community, geohash and relay (no navigation away) -- Hashtag and geohash top-nav filters on Pictures, Shorts, Articles, Polls and Products -- NIP-22 comments on external content (hashtags, geohashes, URLs) render a typed reply-context chip and land in the conversations feed -- Interest Sets (NIP-51, kind 30015) - - List, create, rename, delete and clone interest sets. - - Public/private hashtag toggle. - - TopNav filter chips. -- NIP-9A Community Rules - - Structured rules editor in the new-community flow. - - Post validation against community rules in the composer. - - Opt-in moderation feed filter. -- PDF reader - - Inline PDF previews in feeds. - - Double-tap to toggle zoom. - - Zoom-aware hi-res re-render for crisp pinch-zoom. - - Download and save PDFs to Downloads/Amethyst. -- Multi-wallet NWC - - Multiple wallets with a balance view. - - Default picker, rename and reorder. - - Dedicated Add Wallet screen with Connect Wallet / paste / QR scan. -- Favorite Algo Feeds filter in the Top Nav Bar -- Custom Post creation on Polls / Pictures / Shorts / Longs -- Custom Emoji Packs (NIP-30) - - Browse Emoji Sets screen for discovering kind 30030 packs. - - My Emoji List screen for managing your kind 10030 selection. - - Modernized pack metadata screen with hero image and inline emoji/cover upload. - - Public/private toggle when adding emoji. - - Decrypted private emojis surfaced end-to-end. -- Dedicated drawer screens for more content types. - - Standalone Articles, Products, Public Chats, Communities (NIP-72), Live Streams and Follow Packs screens. - - Products screen defaults to "Around Me". -- Richer live stream chat. - - Inline clips (kind 1313) and raids (kind 1312). - - Inline zap receipts. - - NIP-75 zap goal pinned at the top. - - Top zappers leaderboard. - - Stream clips surfaced in the profile gallery tab. -- Content warnings on media. - - Grid-level content warnings with distinct reasons. - - Warning overlaid on the blurhash at media size. -- YouTube-style video quality picker. - - Feed and PiP default to the lowest HLS resolution. - - Fullscreen defaults to auto. -- HLS Video uploads (NIP-71) - - Pick which renditions to upload. - - See which file is currently uploading. - - Optional cross-post as a kind-1 note. - - Generated poster JPEG. - - Blurhash and thumbhash on every video imeta. -- ThumbHash support alongside BlurHash - - Used across events, uploads and the UI. - - Forwarded when adding media to the gallery. - - Upload failures to generate a blurhash/thumbhash are now surfaced. -- NIP-A3 Payment Targets (kind 10133) - - Pay action on the note reactions row. - - Payment-targets button on the profile. - - Lightning address moved to the wallet setup screen. - - Alt-text on payment-target events. -- Search power tools - - Scope, source, follows and sort toggles. - - Paste an `npub1…`, `nprofile1…`, `nevent1…`, `naddr1…` or `note1…` to jump straight to it. -- Markdown renderer — improved typography, blockquote gutter, table styling -- Polls - - Single-screen creation with a poll-type selector. - - Open/Closed tabs. - - "View results" option (prevents voting after viewing). - - Dismiss button on active-poll cards. -- Badge support Redesigned — You can now create, grant, manage and add/remove badges from your profile. -- Settings revamp - - Modernized Settings screen. - - Dedicated Profile UI settings page. - - Compose Settings screen (auto-draft toggle). - - Security Filters split into a hub with per-category screens. -- Tap a timestamp to toggle between relative ("2h ago") and absolute date/time, driven by a single shared ticker -- Copy raw JSON of a note from the dropdown menu -- Stale-relay hint on replaceable events, using the NIP-66 relay cache -- Two-stage zap progress on the zap action -- Bulk-remove for blocked users and hidden words -- Jump-to-parent icon on replies in Full UI mode -- Configurable report-warning threshold -- `.f4a` audio playback - -## In AI-Ready phones (Pixel 9+, Samsung 25+, Xiaomi 15+): -- AI Writing Help — assistant in the new-post screen. - - Tone suggestions, precomputed in parallel. - - Auto language detection. - - On-device option in Application Preferences. -- AI Alt-Text for images — on-device image description / labeling. - - Suggestions appear in the upload sheet (Google Play build). - -## Desktop - -- Tor Support — full Tor support on the desktop app. - - kmp-tor daemon and settings UI. - - Per-relay routing. - - `.onion` badge. - - Restart-on-toggle. - - Image loading via Tor. -- Multi-account - - Sidebar account switcher. - - Add Account dialog and per-account logout. - - Encrypted account storage. -- Embedded local relay — an in-process relay with SQLite event persistence -- Custom feeds system - - Create, pin and inline-switch between custom feeds. - - Author search in the feed builder (relay NIP-50 + avatars). -- App Drawer with a categorized screen launcher -- Workspace management - - Save, switch and restore workspaces. - - Tabs, an editor and unified search. - - Pin/unpin syncs to the active workspace. -- Namecoin name resolution - - Namecoin lookups now resolve and surface in search. - - Follows the `import` field of name objects (ifa-0001). - - Added `relay.testls.bit` ElectrumX endpoints (clearnet TLS, Tor, bare IP). -- Native theming for macOS, GNOME, KDE and Windows (matches platform look and accent colors) -- Relay power tools - - Dashboard and config editors. - - Per-screen relay picker. - - Persistent configuration. - - Correct counts. -- Messages - - Draggable divider. - - Alignment polish and centered empty states. - - Typography hierarchy and refined dividers. -- macOS polish - - Dock / Cmd+Tab icon via the Taskbar API. - - Apple-HIG squircle margins. - - Transparent window icon. - - Light-mode primary contrast. - - Content extends correctly under the title bar. -- Reading layout — width-capped reading column with comfortable side margins for wide windows -- Compact UI - - Search/Chat/Profile inputs. - - Settings hierarchy normalized. - - Tabs-first headers across Home / Reads / Notifications. - - Whole-card hover on notes. -- Per-OS theming preview flag for testing macOS/GNOME/KDE/Windows looks locally -- Selectable error messages. -- Scrollable single-pane navigation rail. -- Fixes feed loading, repost rendering and Profile back-navigation visibility. - -## Amy (CLI) - -- New `amy`, a non-interactive CLI Nostr client. - - Drives the same Quartz + Commons engine as the apps. - - Available on macOS and Linux from the GitHub Release. -- Subcommands: - - `account` / `use`, `profile`, `post`, `feed`, `notes`. - - `dm send | list | await | send-file` (NIP-17, kind:14 + kind:15). - - `marmot …`. - - `store stat | sweep-expired | scrub | compact`. -- Cache-first reads from a local file-backed event store. - - `relays.json` is gone — `kind:10002 / 10050 / 10051` events in the store *are* the config. -- Secure key storage. - - Private keys move out of `identity.json` into the OS keychain or a NIP-49 encrypted file. - - On-disk data restricted to owner-only. -- Color, human-readable output by default; `--json` opts in. - -## Quartz - -- Adds NIP-AC — WebRTC call signaling events (offer / answer / ICE / hangup / reject / renegotiate) over EphemeralGiftWrap, multi-device, group calls -- Adds EphemeralGiftWrapEvent (kind 21059) — replaces 20s expiration GiftWraps for call signaling -- Adds NIP-A3 Payment Targets (kind 10133) -- Adds NIP-82 Software Applications (experimental) -- Adds the AdminCommandEvent for audio-room kick (kind 4312) -- Adds the NIP-9A community rules parser + validator (kind:34551) -- Expands NIP-34 git collaboration coverage. - - Repository State (kind 30618). - - Pull Requests and PR updates (kinds 1618 / 1619). - - Git Status events (open / closed / draft / applied). -- Adds the rest of NIP-51 list event kinds and full NIP-53 live-activity rendering -- Adds MLS / Marmot event types and a pure-Kotlin MLS engine with IETF RFC 9420 interop test vectors (no native deps) -- Adds an async SQLite event persistence layer. - - NIP-09 / NIP-50 / NIP-62 compliance. - - Room-style connection pool. -- Adds a file-backed event store. - - flock + transactions. - - scrub/compact. - - NIP-50 full-text search. - - NIP-62 Right-to-Vanish. - - NIP-01 tiebreaker. - - NIP-09 created_at window. - - Deletion-author check. -- Adds a reactive `ObservableEventStore` layer. - - A façade that wraps any event store — SQLite-backed, file-backed, or in-memory. - - Publishes a `StoreChange` on every accepted insert, delete and expiration sweep. - - Projections stay in sync without re-querying the store. - - Ephemeral events (kinds 20000-29999) emit without being persisted. - - `EventStoreProjection` turns the change stream into a cold `Flow` of sealed `ProjectionState`. - - Per-filter limits and per-projection NIP-62 vanish scoping. -- Promotes the relay toolkit into the new `geode` module — a real Nostr relay. - - Implements NIP-01 and NIP-45. - - NIP-77 negentropy reconciliation (strfry parity). - - NIP-86 management API. - - TOML config and graceful drain. - - Adaptive connection pooling for 10k+ connections. -- Adds an EventInterner so deserialized events share canonical instances, with an interning event store that interns on insert -- Adds Ktor KMP HTTP implementations alongside OkHttp -- Adds macOS (Apple Silicon), iOS and Linux native targets. - - Pure-Kotlin Ed25519 and X25519 for the MLS crypto on those platforms. - - `commonMain` now compiles for Kotlin/Native. - -## Crypto and Performance - -- Custom secp256k1 implementation, starting to replace `fr.acinq.secp256k1` - - Pure-Kotlin core for KMP native targets (macOS / iOS / Linux). - - C + inline-ASM accelerated path on Android via a JNI bridge. - - Hardware SHA-256 (SHA-NI on x86_64, ARMv8 CE on ARM64). - - Comb method for G multiplication → 3× faster sign/keygen. - - Same-pubkey batch Schnorr verify (5–6× throughput). - - `verifySchnorrFast` for Nostr (skips y-parity inversion). - - 4×64-bit limb representation with lazy field ops and ARM64 assembly. - - Standalone `libsecp256k1-nostr` / `libschnorr256k1` C project, with Android benchmarks. -- Concurrent caching DNS resolver (SurgeDns) - - Lock-free DNS cache. - - 24h positive TTL. - - Stale-while-revalidate. - - Persisted across process restarts. -- Smoother video playback - - Warm ExoPlayer pool and retained warm players. - - Tuned LoadControl. - - VideoCache warmup 10s → 1.5s. - - Stable controller-overlay tree. -- Faster icons — shared FontFamily and TextMeasurer across all Material Symbols -- Faster chat lists — stable list keys and reduced recomposition -- Faster note rendering — cached event-derived values, fewer per-item allocations during feed scroll -- Faster Quartz queries - - Direct-slot driver for replaceable + addressable lookups. - - Streaming k-way merge. - - Smallest-first FTS intersect. - - Parallel Schnorr verify in the ingest queue. - - Index-driven fan-out for cache observers. -- Faster rich-text translation -- Thumbnail disk cache for profile pictures; Coil disk-cache eviction moved off the write path to prevent scroll stalls -- Paginated GiftWrap loading for the DM chat list -- Bookmark events preloaded for faster access -- Lifecycle-aware screen subscriptions - - Feed/screen relay subscriptions pause on background and resume on foreground. - - 30s grace delay so brief app switches don't churn subscriptions. -- Adaptive video disk cache — sized to 20% of free disk (256 MB–4 GB) instead of a fixed 1 GB, with on-demand HLS videos cached in SimpleCache -- Tuned image/video OkHttp dispatcher and connection pool (16 in-flight per host) to de-serialize feed loading -- Streaming image hashing — computes image hashes without loading the whole file into memory; SHA-256 hasher moved off the thread pool -- GeoHash library rewritten from scratch for performance, dropping an external dependency - -## QUIC + nestsClient (foundation) - -- New pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI, no native deps). - - Powers the NIP-53 audio-rooms over MoQ-transport path. -- Full RFC coverage and stabilization: - - RFC 9002 loss recovery and retransmission. - - 0-RTT early data. - - 1-RTT key update. - - TLS 1.3 session resumption (PSK). - - ECN. - - Connection migration with path validation. - - Retry and Version Negotiation packet handling. - - Stateless-reset detection. - - Broad DoS-hardening / RFC-compliance stabilization sweep. -- Passes the quic-interop-runner test matrix against picoquic and quic-go. - - Covers handshake, transfer, multiplexing, retry, 0-RTT, key-update, ECN, http3. - - Includes qlog diagnostics. -- Multiple security and correctness audits. - - RFC 9001 test vectors. - - Live interop against aioquic and picoquic. -- `nestsClient` module - - MoQ-transport (IETF) reference implementation. - - Production moq-lite Lite-03/04 codec with version-aware ALPN negotiation. - - `catalog.json` publishing aligned with kixelated/hang. - - Opus + AudioRecord/AudioTrack. - - Reconnection policy with proactive JWT refresh. - - Cross-stack (Amethyst ↔ Rust ↔ browser) interop harness in CI. - -## Improvements and Fixes -- WakeUp Push Notification events — Starting to migrate to a better Push/Loading system -- Pinned notes moved to their own screen -- Left drawer reorganized into collapsible You / Feeds / Create / System sections, with clearer names -- Article writing redesign — banner, tags, slug -- Redesigned long-form article cards -- GIF support - - Playback controls and autoplay. - - GIF→MP4 upload conversion option in the upload screen. - - GIF / image keyboard support in the short post screen and in Marmot, DM and public-channel chat fields. -- Configurable video player buttons in Account Settings -- Autoplay Videos setting (Always / Never), separate from the video-loading toggle -- Drag-and-drop reordering for some relay list settings -- 3-dot options menu on video / picture / file feed cards -- Zoomable media grows from its source bounds, and loads the full-resolution source in the image dialog -- Favorite relays can now be added to the Global Feed -- Configurable max-hashtag spam filter -- Account setting to forward kind 0 events to a local relay -- Relay Sync UI replaced with visual indicators -- Account Settings - - Split broadcast tracker visibility from Complete UI mode. - - Hide payment-targets icon by default and place it after Zap. - - Float the broadcast banner as a rounded card. -- Danger Zone section in settings -- NIP-89 client tag - - Per-account toggle to disable it, synced via NIP-78 security settings. - - On by default and moved into Compose settings. -- Local Blossom cache — image and profile-picture fetches route through a local Blossom cache -- Mention preservation in compose: - - Survives keyboard auto-correction. - - Partial-overlap edits delete the whole mention. - - Cursor snaps to mention boundaries. -- Chat cursor jumping fixed -- Avatar zoom-in keeps aspect ratio during the animation -- Profile pictures center-cropped to prevent squashing -- HLS video fixes: - - Playback routed to the right MediaSource. - - Multi-rendition videos collapse to a single gallery tile. - - Render with artwork and a graceful fallback. -- Broken "Pause" action removed from the always-on background notification -- Hand-raise button in audio rooms now has a visible toggled state -- GiftWrap unwrapping for all writable accounts when always-on is enabled -- Search bar bech32 paste navigates instead of running a search -- Top and bottom bars stay visible on non-scrollable lists -- Rich-text translation: - - Bug, performance and jitter overhaul. - - `{N}` placeholders so URLs survive CJK translation. -- Swipe-to-dismiss containers fixed on newer Compose -- Right-to-Vanish settings observe toggles reactively, preserve prior behavior on upgrade -- Relay reconnection: - - Auto-reconnect after a server-initiated disconnect. - - Periodic keep-alive to revive relays stuck in long backoff. -- Account settings (profile, follow list, mutes, relay lists, KeyPackages) are republished to newly-selected relays so accounts aren't lost on fresh relays -- Broadcasting relays: - - Kept out of personal & channel sends. - - Always included in non-private sends. - - Fixed an infinite loop in the broadcast-relay computation. -- Tor now falls back to clearnet when bootstrap is stuck -- Android Arti reliability: stale Arti cache cleared on init with retry, SOCKS proxy default port moved with busy-port retry, relay-over-Tor connectivity fixes -- Chess game challenges filtered out of the home feed (ended games only); chess cards show user picture and name instead of hex pubkeys -- Expired polls re-evaluated and removed from notification cards -- NIP-39 external identity claims without a platform separator are rejected -- Dismissible cleanup banner across Pinned Notes, Bookmarks and Bookmark Sets, flagging author-deleted items with a "Remove from list" action -- Bogus Content-Type rejected when saving downloaded media, with URL-extension fallback validation -- NIP-46 bunker decrypt/encrypt response parsing fixed, with a longer timeout -- Hidden DMs no longer counted toward the unread message badge -- Profile header hides the `_@` prefix on NIP-05 names -- Foreground-service-not-allowed exception from the background handled gracefully -- Fixes Samsung crash on outgoing call -- Foreground service starts earlier to prevent call death on Android 14+ -- Stop ringtone and call notification when rejecting consecutive calls - -## UI Refresh - -- Migrates the icon set from Material Icons to Material Symbols (thin weight) for a more consistent, modern look across the app -- Drops unused legacy drawables -- Bottom-bar icon size bumped to compensate for Material Symbols padding - -## Build & Documentation - -- CI restructure: - - Splits Android into its own CI job. - - Adds Android Lint as the first step. - - Merges test+build to eliminate duplicate compilation. - - Drops `assembleDebug` APK uploads. -- Adds a `:nestsClient:test` step to the desktop CI leg -- Adds a quic-interop-runner CI workflow and a browser-side cross-stack interop workflow -- Broadens `libicu` Depends so the `.deb` installs across Debian and Ubuntu -- Adds `SECURITY.md` with private vulnerability reporting policy -- Moves desktop packaging / AppImage tooling into the `desktopApp` module -- AGP and dependencies bumped - -## Contributors - -- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z -- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c -- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k -- @npub1dn0tej4a5806qk9ts56j572sndvjk27l5qmsxf0z3mquknccve7s4k8tfp - -## Translations - -- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp -- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz -- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm -- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 -- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw -- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t -- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 -- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h -- Russian by Anton Zhao - -<a id="v1.08.0"></a> -# [Release v1.08.0: Arti](https://github.com/vitorpamplona/amethyst/releases/tag/v1.08.0) - 2026-04-01 - -- Migrates C Tor to Arti Tor (hopefully no more random crashes) -- Fixes highlight of users when composing and tagging - -<a id="v1.07.5"></a> -# [Release v1.07.5: Image upload fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.5) - 2026-03-31 - -- Fixes Image uploads crashing the app - -<a id="v1.07.4"></a> -# [Release v1.07.4: NWC fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.4) - 2026-03-31 - -- Fixes Nostr wallet connect receiving the secret. - -<a id="v1.07.3"></a> -# [Release v1.07.3: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.3) - 2026-03-31 - -- Migrates Shorts UI out of a paged design -- New edge-to-edge feed for Pictures only -- New edge-to-edge feed for Shorts only -- New edge-to-edge feed for long Videos only -- Migrates Badges to kind 10008 -- Migrates Bookmarks to kind 10003 -- Fixes AOSP keyboard auto-correction bug -- Fixes Poll's top nav filter -- Fixes loading of Bookmark sets -- Fixes sorting stability by including event ids -- Fixes cursor position in quote post field -- Fixes rendering of nostr: uris when composing notes -- Adds Basic support for reaction notifications -- Refactors NIP-58 Badges on Quartz -- Refactors LocalCache methods -- Improvements to the Chess engine (debug only) -- Add error handling to Tor service initialization and control - -<a id="v1.07.2"></a> -# [Release v1.07.2: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.2) - 2026-03-30 - -- Adds GIF uploads support from Keyboard -- Migrates text fields and @ modifiers to the new Jetpack Compose states -- Fixes Bug that wasn't openning Amber to sign -- Fixes Bug on rejections using old Ambers -- Replaces hex input with user search dialog in relay management (allow/ban user) - -<a id="v1.07.1"></a> -# [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30 - -- New translations -- Fixes BLE's module need for synchronized events. - -<a id="v1.07.0"></a> -# [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30 - -Amethyst: -- Adds support to Pin Notes -- Adds support to Polls feed screen -- Adds support for Requests to Vanish -- Adds support for Relay management (NIP-86) from Amethyst -- Adds support for Relay monitor assessments (NIP-66) in the Relay Info screen. -- Adds support for Relay member information (NIP-43) in the Relay Info screen -- Adds support for WebBookmarks -- Adds support for Zap Goals - -Quartz: -- Rewords the NostrClient API for simpler commands. -- Adds NIP-15: Nostr Marketplace protocol — product listings, stalls, merchant events (#2020) -- Adds NIP-24: Birthday field support added to UserMetadata (#1979) -- Adds NIP-29: Relay-based groups — group events, member management, moderation (#2021) -- Adds NIP-32: Labeling protocol — LabelEvent and tag parsing (#1975) -- Adds NIP-43: Relay access metadata and membership management with UI screens (#2010) -- Adds NIP-60: Cashu wallet and spending history event support (#2009) -- Adds NIP-61: Nutzaps protocol support (#2008) -- Adds NIP-62: Request to Vanish feature — data deletion with relay compliance testing (#1958) -- Adds NIP-66: Relay monitor discovery events — UI, filter assembler, subscription composable (#1968) -- Adds NIP-69: P2P Order Events (kind 38383) (#2019) -- Adds NIP-75: Zap Goals support with creation and rendering (#1965) -- Adds NIP-77: Negentropy set reconciliation protocol support (#1955) -- Adds NIP-7D: Thread events (kind 11) rendering (#2016) -- Adds NIP-85: Trusted Assertions — assertions for events and addressables (#1981) -- Adds NIP-86: Relay management UI and client implementation (#1954) -- Adds NIP-87: Ecash mint discoverability — Cashu and Fedimint event types (#2015) -- Adds NIP-89: Compliance fixes, PlatformLinkTag parsing, app handler extensions (#2030) -- Adds NIP-90: All DVM kind event classes from data-vending-machines spec, restructured packages (#2023, #2025) -- Adds NIP-A4: Add k tag to zap requests and enforce e tag prohibition (#1978) -- Adds NIP-BE: Bluetooth Low Energy mesh networking / Nostr BLE Communications Protocol (#2022) -- Adds NIP-C7: Chat messages (kind 9) implementation (#2018) -- Adds NIP-5A: Static website event rendering (#2017) -- Adds NIP-51, kind 10001: Pinned notes feature (#1956) - -Improvements and Fixes: -- Show toast instead of dialog on media download success -- Dynamically adjust preferred Blossom server when list changes -- Add relay discovery to node master rendering list -- Add scroll to settings page -- Solves crashing when multiple relays with the same url are included in the resulting list. -- Keep screen on during PiP playback and survive screen lock (#1999) -- Desktop feed loading — missing events, broken profile navigation (#2027) -- Use getOrCreateNote for reply linking to fix flaky thread test (#2027) -- Route ReadsScreen following-mode events through cache (#2027) -- Pin ElectrumX server certs for Samsung One UI 7 / Android 16 compatibility (#1937) -- Duplicate keys in relay management lazy column — sort pubkeys -- URL detector — fixes localhost:3030 strings, Japanese character URLs -- Web Bookmarks floating action button shape (circle) and open graph previews. -- NIP-86 requests now send Accept and Content-Type headers -- On DMs, activates decryption for all filetypes that match decryption url with the cipher info, not only binaries -- Adds a try/finally to subscriptions to make sure they close even in crashes. -- Protects against crashes when the signer sends an unverifiable payload back to Amethyst - -Desktop: -- Cache-centric architecture for desktop feeds (#1905) -- Render reposts and quoted notes in feed (#2027) - -Performance -- Faster startup procedures with less loading on the main thread. -- 20x Faster Rfc3986Normalizer and way less objects being created. -- Url Detector without using regex -- Parallelize preference file access at startup for faster launch -- Remove internal runBlocking calls -- Lazy loading the memory trimming service -- Speeding up DrawerContent rendering -- Eagerly delete intermediate temp files in upload pipeline - -Refactoring: -- Simplify NostrClient API for beginner-friendliness (#1986) -- Simplify relay API with AutoCloseable and serve() helper (#2031) -- URL detector performance and readability improvements (#2013) -- Restructure NIP-90 DVMs to match NIP-88 Polls pattern (#2023) -- Reorganize NIP-BE into subpackages -- Move public messages and trusted assertions out of experimental - -Migrations & Deprecation Fixes - -- Migrate `LocalClipboardManager` to `LocalClipboard` (#1995) -- Migrate `LocalAutofillTree` to semantics-based Autofill API (#1996) -- Migrate `TabRow`/`ScrollableTabRow` to Material 3 Secondary variants (#1994) -- Replace `ContextCompat.startActivity` with `Context.startActivity` (#1993) -- Suppress deprecation and unchecked cast warnings in Quartz internal code (#1985) - -Platform & Build - -- Add linuxX64 target and restructure native source sets -- Add explicit permissions to GitHub Actions workflows -- Stop Gradle daemons after build steps -- Increase Android CI build timeout to 45 minutes -- Update video compression library to latest -- Update dependencies, add local maven repo for easier library development -- Remove libsodium files -- Git hooks installation in worktrees (.git file vs directory) -- Fixes lack of proxy access to download VLC files in claude's web environment. -- Log level filtering — LogLevel enum, PlatformLog, lambda overloads for 194+ Log.d() calls (#2005) -- Parallelize BaseDBTest forEachDB using coroutines - -Documentation - -- Add CLIENT.md guide for building Nostr clients with Quartz -- Add RELAY.md guide for building relays with Ktor, NostrServer, SQLite - -Contributors - -- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z — Primary development -- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef — Log level filtering, video compression, toast downloads, CI fixes, git worktree fix -- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c — Desktop cache architecture, desktop bugfixes -- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 — CI workflow permissions -- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k — Remove libsodium files -- **mstrofnone** — ElectrumX cert pinning for Samsung One UI 7 - -Translations - -- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp -- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz -- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm -- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 -- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw -- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t -- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 -- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h -- Russian by Anton Zhao - -<a id="v1.06.3"></a> -# [Release v1.06.3: Anon Posting](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.3) - 2026-03-24 - -Improvements to Anon Posting and Reply navigation on DMs - -<a id="v1.06.2"></a> -# [Release v1.06.2: Multi-choice Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.2) - 2026-03-24 - -- Add poll type selector for single and multiple choice polls -- Add drag-to-seek support on the video progress indicator -- Hide video controls on playback start -- Recording indicator bar stops recording when tapped, not just the small stop icon. -- Fixes non http uris in the references tag -- Makes sure quote tags only happen once in events. -- Removes the clickable NIP-05 URL in the @ tagging of users -- Reduces the need to compute naddr to index articles in bookmarks -- Checks if p tags are user pubkeys when loading drafts. -- Scroll to the replied message when clicking reply preview in chat -- Add an anonymous reply with a throwaway keypair -- Fixes pool rendering when one label is large and the other is small -- Use uri-reference-kmp in commonMain. Remove platform-specific implementations. -- Avoids crashing the app in URLs with japanese chars -- Sorts followers into a set to avoid LazyColumn key conflicts. -- Clickable relay rows on the profile page -- Move cache lookups from NavHost route lambdas into screen composables -- Improvements to the status of attestations. Validity first, then Processing status. -- Fixes Tor Manager flow value and improves Tor binding lifecycle. -- Simplifies the report feed in the user profile -- Clears some inconsistencies in translated strings -- Fixes client tags -- Fixes routes for AppDefinition events. - -<a id="v1.06.1"></a> -# [Release v1.06.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.1) - 2026-03-23 - -- Improvements to the rendering of Polls. -- Solves some of the crashes of the concurrent modification exception -- Fixes URL parsers with Japanese chars -- Fixes Wallet import from Primal on Poco phones -- Improves the wording of the Last Seen -- Fixes for "Cannot disable reuse from root if it was caused by other groups" -- Fixes comparator to avoid Comparison method violates its general contract! -- Improves zap-store settings - -<a id="v1.06.0"></a> -# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2026-03-21 - -Polls: -- Adds support for creating and rendering NIP-85 -- Redesign of the poll and zap poll cards -- Adds special notification card while the poll is running - -Relay Feeds -- Adds support for rendering relay feeds -- Adds support for NIP-51 favorite relay feeds -- Shows favorite relays in the top navigation filter -- Clicking wss:// links shows the global feed for that relay. -- New user account adds nostr.wine to favorite relay feeds - -Media Player -- Redesigned player controls for videos, audios, and picture-in-picture. -- Adds our own buttons and indicators for the video playback -- Adds Music support with waveform animations -- Migrates to new Media3 content view frames -- Improved Picture in Picture actions -- Turn video controller creation into a flow to fix playback lifecycle issues -- Adds support for uploading audio - -NWC Wallets: -- Adds support for NIP-47 Wallets and compete NWC spec -- Adds views for Balance and Transactions -- Add transaction filtering and pagination to wallet screen -- Added several test cases from other repos to guarantee interoperability - -Calendar: -- Adds support for NIP-52 Calendar appointments -- Adds proper display of calendar time slot and date slot events in the note feed -- Refactored the early implementation on Quartz for easier use - -Code Snippets: -- Adds support for NIP-C0 Code Snippets -- Replies using NIP-22 - -NIPs on Nostr -- Adds support for event kind 30817 -- Replies using NIP-22 - -PayTo: -- Adds support for NIP-A3 Payment targets by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 - -Blossom BUD-10: -- Adds support for "Blossom:" URIs on the post -- Supports automatic discovery of blossom servers -- Renders/Previews images, audios, videos, and documents -- Includes support for encryption when using it in NIP-17 DMs. - -Expirations -- Adds enhanced support for custom expirations in any new post. -- Displays expirations on posts and DMs - -Relay Monitors: -- Adds support for NIP-66 Relay monitor and discovery support to Quartz - -Attestations: -- Adds support for rendering Attestations (https://attestr.xyz/) -- Recommendations, Requests and Attestor Declarations are also included. - -Chess: -- Adds basic support for Chess with Jester protocol -- Full chess game implemented -- Supports for game challenges and view external games -- Running on debug only for now - -DMs: -- Removes NIP-04 DMs -- Blocks DM sending if the receiver doesn't have NIP-17 relay lists. -- Removed incognito icon from the new post field. - -Push Notifications: -- Adds support for inline reply -- Adds support for notification grouping -- Adds support for Async image Loading -- Removed NIP-04 notifications - -Long Form: -- Adds support for writing Long Form/Markdown content -- Includes support for automatic Draft saving and editing -- Includes support for editing - -Uploads: -- Adds support to upload Documents to all new post screens. -- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Adds encrypted file upload fallback option for NIP-17 chats -- Removes support for NIP-96 and updates Blossom recommendations - -Content Warning: -- Adds an optional description field for sensitive content warnings in new posts. -- Displays additional information on warning composables - -Settings redesign: -- Consolidate drawer settings into a single Settings hub screen -- Redesigns Zap Amount and NWC setup screens -- Redesigns Custom zap amount screens -- Adds brand new Translation Settings screen -- Adds blockchain explorer settings page for OTS verification -- Adds reactions row settings (enable/disable, order, show/hide counters) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Tapping on Zap without any pre-configured amount opens the custom dialog - -Content parsers: -- URL/URI parser rewrite in Kotlin multiplatform (KMP) -- Fixes characters attached to URLs or nostr URLs without a space -- Massively increases parsing performance -- Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd -- Adds a parser for blossom: uris - -UI Improvements: -- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx -- New Material 3 UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- New Material 3 UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Draft Screen requests confirmation before deleting drafts on swipe -- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Adds support for rendering Zap events when quoted inside of posts. -- Adds a Broadcasting feedback pop-up in the Complete UI mode - -Relay Management: -- Adds relay search tooltip when adding relays -- Adds the list of keys using each relay to the relay information -- Adds active subscriptions and outbox event in the queue to relay information -- Adds a complete list of event kind names to the subscription card to relay information -- Tracks and displays connection success rate on relay settings -- Adds relay settings export functionality -- Adds NIP-45 count queries to show how many events each relay has. -- Adds Relay sync utility to help users move posts between relays. - -Search: -- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content. -- Fixes the need to start user searches with @ in user fields -- Fixes the stability of the search feed when the user navigates away and back. -- Replaces about me for NIP-05 in the user search results -- Adds relay URL search to the search page -- Forces returning one user when searching by nip-05 -- Removes outdated versions of addressables from the search results - -Profiles: -- Adds support for NIP-39 External Identities with kind 10011 -- Adds a profile picture upload button when the user has no picture -- Adds last seen to the user profile -- Adds nprofile and npub copy options to the profile -- Groups received zap amounts by sending the user in the profile tab -- Increases the limit of Zap downloads for profiles to 1000 -- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx -- Migrates profile galleries to display a thumbnail for videos -- Fixes profile galleries' aspect ratios -- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins. - -Onboarding -- Adds bulk follow screens to search for a user and to copy his/her follow list - -Voice message by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Adds voice anonymization -- Change from "hold to record" to "click to start, click to stop" -- Display kind 1 voice replies as an audio waveform -- Increases max voice record duration to 600 seconds -- Switches the public message event to use quoted posts for replies - -Fixes: -- Fixes "forked from" label rendering over the name -- Avoids crashing when the `k` tag cannot be parsed to a number -- Only use Voice Reply events when replying to voice notes. Others just receive a URL. -- Fixes the lack of update in the follow count on the UserProfile page -- Fixes out of memory when downloading large videos -- Fixes Jackson deserialization for empty Filters and add regression test by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Fixes NullPointerException when the filter contains tags -- Fixes download cancellations when screen components disappear -- Migrates to use "title" instead of "name" tags for NIP-51 lists -- Adds a longer crop for npubs so that we can see vanity keys better -- Fixes the need to have tags and kinds for inbox.nostr.wine to work -- Blocks the size of Relay Auth Status arrays from growing forever with auth messages -- Fixes crash when getting OpenGraph tags of invalid URLs -- Fixes NIP-44 key mutation in NIP-46 connect -- Location permission watcher moved outside screens to avoid recreation -- Solves the sorting contract crash on search by precaching all values before sorting users. -- Fixes lingering relay connections from loading follows outbox's settings. -- Enhance NIP-38 user status display with emoji support and metadata tags -- Fixes bug on Show More calculations for very long texts without spaces -- Fixing IO Dispatchers and coroutine scopes of choice -- Fixes anySync parallel operation that was returning the first result, not the first positive "any". -- Fixes Req onCannotConnect listeners to the relays that actually sent the req -- Fixes hanging subscriptions when exceptions happen during NostrClient utility methods - -Defaults: -- Switches wss://nostr.band to wss://antiprimal.net, wss://relay.ditto.pub on app defaults -- Adds wss://nostr.wine, wss://news.utxo.one as favorite relay feeds -- Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays -- Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers - -Quartz: -- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support -- Adds support for dynamic auth policies to the relay implementation. -- Migrates Quartz EventStore from Android-only to KMP -- Adds a reqUntilEoseAsFlow extension to the Nostr Client -- Adds a reqBypassingRelayLimits extension to the Nostr Client -- Adds comprehensive NIP-46 Bunker support -- Adds comprehensive support for NIP-47 non-payment methods. - -Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k -- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge. -- Provide implementation for LargeCache, using a CacheMap -- Provide implementation for fastFindURLs() -- Provide implementation for makeAbsoluteIfRelativeUrl() in ServerInfoParser.ios.kt -- Provide implementation for UrlEncoder -- Provide implementation for UnicodeNormalizer -- Provide implementation for GZip compression/decompression. Some small fixes in URLs.ios.kt -- Provide implementation for AESCBC -- Provide implementation for AESGCM -- Provide implementation for DigestInstance -- Provide implementation for LibSodium - -Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c -- Adds NIP-46 Bunker Login -- Adds Support for Chess -- Adds Thread Screens -- Adds advanced search with query engine and filter panel -- Adds encrypted DMs (NIP-04/NIP-17) -- Adds proper empty states with EOSE tracking -- Adds multi-column deck layout -- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox -- Adds advanced search with NIP-50, collapsible sections, and nav state preservation -- Clear stored credentials on logout -- Adds bunker heartbeat indicator -- Adds QR-based signer pairing -- Migrates lifecycle-viewmodel KMP dependencies to KMP/Commons -- Migrates drawReplyLevel modifier to KMP/Commons -- Migrates ThreadFilter to KMP/Commons -- Migrates Card interface and CardFeedState to KMP/Commons -- Migrates Channels (public chats, ephemeral channels, and live streams) Account modules to KMP/Commons -- Migrates private chatroom models to KMP/Commons -- Migrates reports states to KMP/Commons -- Migrates Emoji State to KMP/Commons -- Migrates lud06 to lud16 mapping to KMP/Quartz -- Migrates the new LocalCache observables to KMP/Commons -- Migrates rich text parser from JVM to KMP/Commons - -Code Quality -- Migrates to AGP 9.0 -- Adds Amethyst Desktop to CI/CD and Release builds -- Removes the in-app memory counter methods -- Refactors the old NIP-05 code on Quartz -- Migrates contact list management to addressable notes -- Creates new observable flows for LocalCache. -- Moves metadata methods from User to UserCache objects -- Separate Addressable vs Replaceable event class bases -- Avoid dependency on AccountSettings for NwcSignerState -- Finishes the transition to EventHint objects for building events. -- Lots of code review fixes by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Large accessibility review by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Moves Top Nav Filter markers from Strings to full objects. -- Removes support for feed definitions -- AccountState refactoring - -AI: -- Add SKILL.md for AI agent customization -- Add settings and hooks to setup Android Development for the agent - -Updated translations: -- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp -- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz -- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm -- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 -- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw -- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t -- Chinese by hypnotichemionus4 -- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 -- Russian by Anton Zhao - -<a id="v1.05.1"></a> -# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08 - -- Fixed mixed DMs between logged in users. -- Fixed draft screen click to edit post. - -<a id="v1.05.0"></a> -# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08 - -#Amethyst v1.05.0: Bookmark Lists, Voice Notes, and WoT Scores - -This release introduces Bookmark List management, a complete overhaul of Voice Notes/YakBaks, -and the debut of Web of Trust (WoT) scores for a safer social experience. - -This version adds support for creating, managing, deleting, and viewing multiple bookmark lists, -which include both public and private members. You will find an improved "Bookmarks" menu option in -the sidebar and extra bookmark options in the context menu of each post, allowing you to add posts -directly to one or more individual lists. - -The Voice Notes UI has been redesigned to allow recording directly within the new Post Screen and a -dedicated Voice Reply screen. Users can record a new voice message, preview it with waveform -visualization, re-record if needed, select a media server, and post the reply. You now have full control. - -Amethyst now supports Trusted Assertions. By connecting to a WoT provider, you can see trust scores -and verified follower counts directly on user pictures. This helps filter signal from noise, identifying -reputable accounts to follow, which DMs to open, and which notifications to prioritize. To activate -this, you will need to find a provider capable of computing these scores. While providers are -currently limited and resource-constrained, we hope more will bring their own algorithms to Nostr over time. - -Quartz received a significantly improved database engine capable of sub-microsecond queries using Android's -default SQLite database. The engine is optimized for mobile environments, using as little memory as -possible to avoid impacting other apps. - -In the background, we have begun building Amethyst Desktop. While much work remains, the goal is a -standalone, mouse-first application that moves away from mobile-centric UI layouts. - -New Features -- Trusted Assertions: Added support for trust scores displayed on user profile pictures -- WoT Followers: Displays verified follower counts in user profiles -- Bookmark Lists: Full support for custom lists by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k -- Relay Information: New UI with expanded NIP-11 feature support -- Voice Notes & Replies: Redesigned experience by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Profile Banner: New default banner by @npub1tx5ccpregnm9afq0xaj42hh93xl4qd3lfa7u74v5cdvyhwcnlanqplhd8g -- Native Links: Intercept njump, yakihonne, primal, iris.to, zap.stream, and shosho.live to open directly on Amethyst by @npub1lu4l4wh7482u8vq0u63g2yuj8yqjdq8ne06r432n92rnd22lspcq0cxa32 - -Improvements: -- New in-memory graph-based cache scheme; moved reports and WoT scores to this new system -- Disabled top bar reappearance to prevent feed shifting when navigating between pages -- Lenient Kotlin Serialization to prevent crashes from malformed JSON; -- Removed expired addressable events from cache -- Moves reports from the old caching system to the new Graph-based one. -- Reverted to a 500-post load limit for Profile screens to handle high-reply accounts -- Moved the QR Code screen from a Dialog to a full Route. -- Re-adds name as a tagging name to the profile edit page. - -Performance: -- Faster event id checker by serializing, sha256 hashing, and ID comparison without creating any intermediary buffers. -- Faster event JSON parsers by avoiding new variables and thus garbage collection calls -- Faster tag array Deserializer -- Manages the pool state without having to loop through relays, saving some milliseconds of processing. -- Adds a cache system for WoT scores -- Improved Compose stability for video UI - -BugFixes: -- Fixes JSON serialization of UTF-8 Emoji surrogates for compatibility with standard Nostr implementations -- Improves error message on zap configuration errors with detailed NWC URI by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Centers QR dialog content and reduce excessive top spacing by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw -- Closes subscriptions when ending them on NostrClient instead of waiting for them to finish -- Requires a relay to be an outbox/inbox relay to be able to NOTIFY a user of a payment -- Improves the speed of parsing of invalid kinds inside an address string -- Fixes count not working for LIMIT queries in the DB -- Fixes icon bug with incorrect resource id by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes missing updates to the feed when the top list is not yet available locally -- Fixes List of supported NIPs as Integers on NIP-11 by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes ConcurrentExceptions on event outboxes - -Desktop: -- Base Compose Multiplatform Desktop App with posts and global/following feeds by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c - -Web: -- New website by @npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424 - -Quartz: -- Adds support for Trust Provider lists and Contact Cards for NIP-85 -- Early support for Payment targets as per [NIP-A3](https://github.com/nostr-protocol/nips/pull/2119) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Initial support for NIP 46 by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Adds support for fast MurMur hash 3 64 bits -- Adds a nextLong secure random method -- Removing the generalist approach of ptag-mentions -- Removes deprecated fields in UserMetadata -- Removes compose bom from Quartz to avoid unnecessary dependencies. -- Removes datetime dependencies from Quartz -- Adds dependency on coroutines directly (instead of through compose runtime) -- Removes old secp256 target dependencies -- Adds Default scope for NostrClient and Relay Authenticator - -Quartz-Event Store: -- Moves from text tags to probabilistic 64-bit MurMur Hash3 integers for performance -- Moves from range index queries to kind,pubkey queries by default. -- Adds simpler SQL queries for specific simple Nostr filters -- Expose SQL query plans, vacuum, and analyse to lib users -- Implements AND Tag queries from [NIP-91](https://github.com/nostr-protocol/nips/pull/1365) -- Implements GiftWrap deletions by p-Tag with deletions and vanish requests -- Offers several indexing strategy options to users. -- Adds several test cases that verify not only the SQL but also the indexes used -- Exposes raw queries that return columns for relays that might not need the tag array -- Forces the use of the index on Addressables and Replaceables on triggers -- Fixes duplicated events being returned from the DB -- Fixes unused Or condition in the SQL builder -- Refine the structure of the module classes for the DB -- Removes the Statement cache since statements are not thread safe -- Creating interfaces for multiple EventStores - -Code Quality: -- Updates kotlin, compose, multiplatform, activity, serialization, media3, mockk, secp256, tor, androidxCamera, stdlib -- Adds a compose stability plugin to allow traces in debug -- Updates to the latest Zapstore config -- Updates quarts instructions in the ReadMe. - -Updated translations: -- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm -- Hungarian by @npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp -- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 -- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw -- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 -- Latvian by @npub1l60stxkwwmkts76kv02vdjppka9uy6y3paztck7paau7g64l687saaw6av -- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd -- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz and Alexis Magzalci -- Chinese by @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h - -<a id="v1.04.2"></a> -# [Release v1.04.2: Fix for Google Play](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.2) - 2025-11-15 - -Quick release for Google. - -<a id="v1.04.1"></a> -# [Release v1.04.1: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.1) - 2025-11-15 - -#Amethyst v1.04.1: Bug fixes - -- Fixes crashing when starting -- Fixes hashtag unfollowing for mixed case tags -- Fixes release Id for the zap the devs button -- Fixes quartz version for a release to Maven -- Fixes disappearing of the Zap the Devs Manual Payment screen -- Fixes back button of the Zap the Devs Manual Payment screen staying behind the status bar - -Translations: -- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm -- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp - -Download: http://amethyst.social - -<a id="v1.04.0"></a> -# [Release v1.04.0: List Management](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.0) - 2025-11-15 - -#Amethyst v1.04.0: List Management - -This version adds support for creating, managing, deleting, and viewing follow lists, which include -both public and private members. We've also added similar UI to support for creating, managing, -deleting, and viewing follow packs from following.space, allowing you to assemble following lists -not only for yourself, but also as a starter packs for other users. You will find a new "My Lists" -menu option in the side bar and Follow buttons when extended behavior to add users directly into -lists or packs. - -This version also introduces significant improvements to the quality of video compression and hash -checks during media upload/download, along with new codecs and a new image gallery for displaying -sequences of images in posts. The startup loading of outbox relay lists and user metadata for large -follow lists (> 1000 people) has been significantly improved with new mechanisms to search for missing -outbox relay lists. - -The Top Bar filter "All Follows" now merges all follow lists, follow packs, following hashtags, -following geotags, and following communities into a single feed. Two new options were also added: -"All User Follows," which includes only the main and other follow lists, and "Default Follow List," -which is the standard follow list used by every client. - -Finally, we completed our Quartz migration to Kotlin Multiplatform, added significant -performance improvements when processing events and running cryptographic procedures, as well -as new ease-of-use extensions. - -New Features: -- People List creation and management by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k -- Follow Pack creation, management and feed view -- Image gallery in posts by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Finishes migration of Quartz to Kotlin Multiplatform -- New Filters in the top nav bar -- Performance improvements across all features - -Improvements: -- Adds support to rejection replies from NIP-55 signers by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Adds live/offline indicator to live bubbles by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Refines the video compression procedure by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Replaces MP4 parser libraries with native MediaMuxer / MediaCodec by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Adds a H.265/HEVC codec to reduce file size by up to 50% while maintaining the same quality by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Optimizes video file structure by moving metadata to the beginning, so videos start playing more quickly by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Adds "All User Follows" feed filter in the top nav bar that removes hashtags, communities and geotag follows -- Adds "Default Follow List" feed filter in the top nav bar that contains only Kind 3 follows -- Shows a dialog to select a signer when using multiple signers are present by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Saves bandwidth by avoiding constant REQ updates before EOSEs come back in a subscription -- Changes the following icon on top of user's pictures to include follows in all lists and follow sets -- Removes non-outbox relays from the outbox lists -- Adds support bigger, up to 4GB, payloads in NIP-44 -- Restructures the default REQ limits from each relay in all feeds -- Adds a guarantee incoming message order to OkHttp websocket implementations to avoid EOSE mismatch -- Only downloads DMs and Drafts if the account is writeable / nsec is present -- Adds support for copying/cloning follow lists in the UI. -- Adds a default relay list for indexers in new accounts with local backup -- Smoothes the disappearing effect for the Top and Bottom navigation. -- Adds support for NWC deep links and removes hardcoded Alby integration -- Adds a missing outbox relay list popup on posting new notes -- Adds a missing inbox relay list check on notification screens -- Migrates to njump.to to disperse shareable links - -Quartz: -- Migrates Quartz from Android to CommonMain (Kotlin Multiplatform) -- Adds a maven publishing to ship quartz -- Fully converts OpenTimestamp Java codebase to Kotlin, migrating the sync and async HTTP call interfaces to OkHttp and coroutines -- Redesigns parsing of relay commands, messages and filters for performance in Jackson. -- Uses KotlinX Serialization when speed is not a requirement -- Migrates all Jackson field annotations to Kotlin Serialization -- Migrates Regex use in Quarts to Kotlin's Regex class -- Migrates Base64 library from Android to Kotlin -- Migrates UUID library from Android/Java to Kotlin -- Migrates LRUCache usage from Android/Java to Kotlin collections -- Migrates all String to bytearray conversions to Kotlin methods -- Migrates all System.arraycopy calls to kotlin native ones. -- Separates parsing code from the data classes in Companion objects -- Exposes Rfc3986 normalizations to each platform. -- Exposes URI parsing classes to each platform. -- Exposes URL Encoders to each platform. -- Exposes BigDecimal to each platform. -- Exposes the Url Detector to each platform. -- Exposes Mac instances to each platform -- Exposes Diggest instances to each platform. -- Exposes BitSet to each platform. -- Exposes GZip to each platform. -- Exposes Secp256k1 to each platform. -- Exposes SecureRandom to each platform. -- Exposes Time in seconds to each platform. -- Exposes the LargeCache to each platform. -- Exposes AES CBC and AES GCM encryption/decryption to each platform -- Migrate test assertions to Kotlin Tests -- Exposes Address class to each platform because of the Parceleable requirement in Android -- Creates our own ByteArrayOutputStream. -- Removes threadsafe features inside our Bloomfilters because we don't need that consistency -- Migrates UserMetadata parser from Jackson to Kotlin serialization -- Removes @Static methods in each tag. -- Adds an EventTemplate serializer -- Removes the need for KotlinX Datetime -- Adds support for LibSodium in the JVM desktop platform -- Creates a shared test build for iOS targets -- Increases the Bloom filter space to better use hints in the app. -- Removes support for iOS in x86 -- Creates a performant JacksonMapper just for NIP-55, which stays in the Android build only. -- Keeps the event store in the android build due to the SQL Lite dependency -- Removes @syncronized tags in favor of Mutexes. -- Improved sendAndWaitForResponse NostrClient method to properly account for returns from each relay. -- Removes the need for GlobalScope and async calls -- Removes the dependency on Jackson's error classes across the codebase. -- Moves the hint to quote tag extension methods to their own packages. -- Migrates NIP-06 and Blossom uploads to use Kotlin Serialization -- Adds ease of use functions for the downloadFirst nostr client extension method -- Refactors error logging in BasicRelayClient -- Starts NostrClient in active instead of waiting for a connect() call -- Adds initial test cases for NostrClient and extensions -- Adds an option to ignore failed reconnection delays that should be used when the network settings change. -- Adds a build template option for NIP-42 AUTHs -- Moves quartz to Java 21 due to binary inconsistencies between the multiple builds of KMP (tests conflicting with main) -- Adds support for COUNT relay messages -- Treat COUNT as query only, not subscriptions in the NostrClient -- Moves statistics collection out of the inner classes to be an external option for app developers instead. -- Restructuring relay classes to maintain order of incoming messages for relay listeners -- Defers all processing of incoming messages to coroutines via channels, freeing OkHttp's thread as soon as possible. -- Simplifies the main relay class by using attached listener modules for each function of the relay client. -- Migrate defaultOnConnect calls to become listener based and moved to NostrClients -- Coordinates REQs so that if an update is required to be sent but the server has not finished processing events, waits for it to finish and sends it later as soon as EOSE or Close arrives -- Correctly maintain the local and server state of each Req. -- Avoid subsequent REQ updates before EOSE or CLOSE calls. -- Refactors NostrClient authenticators to do complete operation as an optional module -- Breaks down Relay Client modules (Auth, Reqs, Counts, Event submissions) in the Relay Pool class. -- Creates listeners just for REQ subscriptions -- Move statistics to outside the base relay class as a listener -- Move logs to outside the base relay class as a listener -- Better structures a Standalone Relay client -- More appropriately communicate errors to the listeners -- Remove relay states on listeners, move each to its own method -- Removes the hardcoded Dispatchers on Quartz -- Adds streaming hash utility function, follow the existing pool/worker design -- Adds fast search for events and addresses in the Deletion Event -- Adds an update method for to create a new event template from an event. - -Fixes and UI Improvements: -- Changes the DVM feed to sort by follows that liked or zapped the DVM -- Changes the icon of account preferences to translation for now -- Improves click and long press interactions with the relay list item and status bar items -- Fixes the visual references to communities and hashtags in the top right of the post -- Removes disappearing top and bottom bars from settings screens -- Fixes tall top bars on Ephemeral and Public Chat rooms -- Fixes lack of live stream name on the top bar -- Fixes animations to navigate from and to list screens -- Fixes cursor behind the keyboard when typing long texts. -- Fixes line wrap in the relay info top nav bar title -- Moves message button to Profile Actions -- Fixes User profile banner being off place in short images -- Also fixes spacing of the Follow button -- Adds an option to render a user gallery from hex keys instead of full User objects -- Adds context to the highlights -- Increases the contrast of placeholder text -- Refines performance of the QuickAction menu bar -- Fixes hidden words not being applied to NIP-04 DMs on notifications -- Fixes not loading some event kinds in notifications -- Fixes crash when updating a metadata with null name -- Fixes crash when attempting to share an image that is still loading by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes disappearing stats on relay screen by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes proper switch between single and two-pane layouts on rotation by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes sendAndWaitForResponse never receiving a response by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Fixes NWC URI parsing bug by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes bookmark removal from Private/Public removing from both by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes OutOfMemoryError for large file uploads by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes DecryptZapRequest CommandType by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Fixes location being added to note even after deselecting it by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Fixes crash when trying to parse unparseable NIP-11s -- Prevents resource leaks with file streams by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Removes any relay url that has a percent-encoded null byte, regardless of size -- Forces streaming url online check when the stream is offline but the user enters the chat -- Fixes duplicated loading of NIP-11 relay info by different parts of the screen -- Fixes the new Video events as non replaceable, while keeping the old ones in the replaceable -- Fixes crash on starting when the contentResolver is not ready yet. -- Fixes addressable deletions deleting not only the past when updating feeds -- Fixes long form previews when missing the author's picture -- Fixes feed filter update when changing the top nav filter -- Fixes crash when sorting with the same createdAt in the discovery feeds -- Fixes reply routes when clicking in the Conversations tab when the event is a PublicChat, LiveStream or Ephemeral Chat -- Fixes livestream chats appearing on home bubbles after the live stream is finished -- Fixes not sending the live stream events anywhere when the stream doesn't have a relay set declared -- Fixes animations when selecting Tor options in the privacy screen -- Fixes animations jumping when loading privacy screen -- Fixes the use of index relays and search relays to load users and events as well as become the default for global feeds. -- Fixes mark as read when drafts are the latest message in the chat -- Fixes showing blog posts in the future in the Discovery reads -- Fixes crashing when comparing int and long in Live Events comparator -- Fixes recompositions of subject add-on to text on rendering -- Fixes crash on trying to change dont translate from options -- Fixes NPE on the ln invoice callback with errors parser -- Fixes parsing encrypted NIP-28 chats -- Fixes disappearing relay stats (larger LRU cache) -- Fixes sendAndWait nostr client coroutine that was waiting forever -- Fixes lack of req by forcing an updateFilter at the start of new subs with the client. -- Fixes bug that skipped loading follow's metadata on startup -- Fixes issues when unfollowing a hashtag from old contact lists -- Fixes relay list flickering bug -- Fixes crash when checking and creating new users from `p` tags. -- Fixes the author of the highlight -- Fixes scope lifecycle of media uploads to avoid cancellation while uploading -- Fixes parser for null and default values from NIP-55 using Jackson -- Fixes relay icons not showing up when sending DMs -- Fixes imeta parsing with multiple urls -- Fixes relay-specific global feed matching incoming note checks -- Fixes video progressive download by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fixes several usages of Reflection when serializing classes -- Fixes a bug on loading event test database for the HintBloom filter test -- Forces relay status updates when connecting and disconnecting -- Only shows live stream bubbles that are not playing 24/7 with active follows in the past 15 minutes. -- Opens Follow Packs on following.space - -Performance: -- Restructures the parser and serialization of the relay messages and commands for performance -- Speeds up the generation of Bech32 addresses -- Migrates memory counters from Long to Int -- Avoids using JSON parsers with DataStore to speed up loading time (loading the parser itself takes ~300ms) -- Adds new benchmarks for NIP-44 operations -- Reduces memory consumption for NIP-44 operations, avoiding GC -- Faster Hkdf functions with less array copying and allocations (which can be impactful if the ciphertext is large) -- Faster Mac calculations by avoiding array assemblies before calling the function -- Faster Hash check calculations avoiding the creation of a separate bytearray to compare ids -- About 30-40% event hashing performance boost by building the json by hand and skipping string encoders when not needed. -- 30% Faster isHex for strings with precisely 32 bytes. -- Loads the main account in parallel faster before the screen needs it -- Merge expanding and checking HMac functions to avoid re-creating the Mac instance. -- Separates EOSE cache for drafts alone. -- Speeds up the loading of users in follow lists on start up -- Speeds up OkHttp startup -- Adds submap index queries to the addressable large cache -- Performance improvement for the address serializer. -- Adds a default cut off for notifications from random relays to 1 week ago. -- Increases local video disk cache to 1GB -- Defers the initialization of disk caches for videos and images to a few seconds after loading -- Only listens to notification feed changes when the UI is visible -- Separates a relay failure tracker to a module -- Caches the User object for each Account to avoid being deleted by the soft reference on Local Cache -- Moves all Dispatchers.Default to the IO threadpool because of the amount of Synchronized code in dependencies blocking heavy threads -- Only download reports from the author's outbox relay if we have it (don't use our default bootstrapping relays for reports) -- Keep a local cache of following geoshahes and hashtags in lowercase. -- Don't intern signatures, content and stringified tags since they are not usually duplicated -- Unifies the parser for the tag array across the app -- Adds simplified extensions to create Requests from NostrClient -- Adds a Start request callback to the request listener -- Adds a req that returns the list of events in order of arrival -- Adds a flatten straight into a set utility -- Adds a decrypted people and follow list cache on the account class and updates TopNav to use the new caching -- Creates slim ListItem composables -- Marks address, user states, edits and channel metadata view models as Compose Stable -- Removes unnecessary list of icons drawer rows, which affected Compose stability -- Improves the composition of NIP-05 lines -- Adds a pre-parser to find image galleries and video links before rendering. -- Adds the user outbox relays when loading addressables by that user. -- Avoids sending CLOSE to subs that are already closed. -- Rejects additional urls with %20 and fixes "Wss" ones. - -Code Quality -- Reduce http max requests when in emulator to avoid crashing by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Resolve intermittent CI build failures by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Fix benchmark apk location by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Change from runBlocking to runTest where appropriate by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Reduce errors in log: concurrent modification exception by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Moves the precision of mills to seconds on the isOnline Check -- Migrates to use suspending routines for OTS, statuses and edit flows -- Unify outbox calculation in the RelayOutbox loader -- Refactors zap the devs card view -- Creates an interface for NostrClient strategies -- Adds an isMobileOrFalse flow in connectivity to speed UI updates -- Removes one of the Application dependencies in viewmodel -- Changes the Theme class to only take the preferred theme directly. -- Adds dependency on NWC to Account to avoid linking directly with the Application class -- Migrates Account management to an Application lifecycle to prepare for multi-account screens -- Establishes a scope for each account loaded so that flows can be killed on sign off -- Moves the event processor to the Account class -- Creates an interface for the DualHttpClientManager to allow IDE previews -- Removing the dependency on an application class from AccountViewModel -- Removes dependency on the viewModel on AccountFeedStates -- Moves account saving from StateViewModel to Account -- Removes dependency on the application class from DecryptAndIndexProcessor -- Changes the name and singleton of the nav to avoid confusing the auto import -- Switches account cache to a flow to allow observers -- Removes dependency in the Amethyst class from Playback calculator -- Removes the dependency on windowSizeClass and displayFeatures to be passed in the global preferences of the app (which is activity independent) -- Moves the OTS processor from Account's decrypt process to Application since it doesn't need the account information. -- Separates Application dependencies into an AppModules class to create only after the OnCreate event. -- Switches TorSettings to be per Application and not per Account anymore -- Since TorSettings is now global, moves the okHttpClient determinations out of the Account-based classes into the Application. -- Since TorSettings is now global, set's up Coil's image loader only once when creating the Application -- Moves UISettings state to App Modules instead of viewmodel -- Migrates TorSettings and UISettings to DataStore -- New tor evaluator service for relay connections now uses all account's trusted relays and dm relays at the same time. -- Migrates composable-state-based UISettings to Flow-based UI settings, while observing connectivity status -- Removes the displayFeatures and windowSizeClass from the shared model -- Fixes not requesting Notification Permissions for APIs older than Tiramisu in the FDroid flavor -- Moves the NIP-11 document cache from singleton to the App Modules -- Avoids using AccountViewModel to check NIP-11 Relay documents -- Moves the UI Settings usage in composables to functions that do not observe the state since they don't need to refresh the screen when changed. -- Refactors UI Settings screen to separate components and remove the sharedViewModel -- Only starts Internal Tor if that option is selected in the TorSettings. -- Turn TorSettings into a data class to observe changes to it -- Drops the SharedPreferences ViewModel to use UISettingsFlow directly from App Modules -- Reorganizes OTS Events after simplification of the OkHttp based on TorSettings. -- Applies memory trimming service to all logged-in accounts at the same time -- Adds a test for native vs libsodium chacha20 -- Added test for emulator to set maxRequests to 128 to prevent crashing emulator by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Dumps the number of relay hints urls in the DB -- Creates our own Coil Logger to minimize trash stacks in the logs -- Adds a declared outbox lists for follows state -- Removes the deprecated hacks to store communities, hashtags and geohashes on Contact Lists -- Updates deprecated Clipboard manager -- Updates emoji and user autocomplete state to use Account and avoid linking AccountViewModel -- Refactors many viewModels to avoid using callback lambdas -- Correctly marks EOSE for filters that are aligned with the Req State from NostrClient -- Changes User loading features in a tentative to make them faster since they are used by all functions in the app. -- Creates an Account follow list per Relay state that only includes shared relays as a better source of functioning relays - -Deprecations: -- Removes fmt.wiz.biz from bootstrap relays -- Removes void.cat from default servers by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Removes filestr share links -- removes zapstr.live share links - -Updated translations: -- Czech, German, Swedish and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 -- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw -- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm -- Hungarian by @npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp -- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 -- Latvian by @npub1l60stxkwwmkts76kv02vdjppka9uy6y3paztck7paau7g64l687saaw6av -- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd -- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz -- Chinese by @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h -- Thai by @npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e -- Persian by @npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk - -Download: http://amethyst.social - -<a id="v1.03.0"></a> -# [Release v1.03.0: Built-in video recording](https://github.com/vitorpamplona/amethyst/releases/tag/v1.03.0) - 2025-09-02 - -#Amethyst v1.03.0: Built-in video recording - -New Features: -- Adds "record and post video" button to new Post Screens by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Updates the User Profile's Relay List to an outbox version -- Activates live streams to the top feed bubble -- Enables the use of custom TextToSpeech Engines - -Improvements: -- Improves the list of visible authors on live stream bubbles -- Adds the error status to the URL normalizer to avoid checking it again -- Adds a follow list state from kind 3 to keep following users in memory. -- Moves from RelationshipStatus to ContactCard as per NIP update - -Fixes: -- Removes crashing relay URIs with null-encoded bytes (%00) -- Reverts to using androidLibrary plugin for Quartz until KMP publishing guidance is updated. by [@npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k](https://github.com/npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k) -- Fixes product title when in a quoted post -- Fixes live flag for streaming when the URL is not available anymore. -- Fixes bug on opening the Relay Settings page with duplicated Trusted and Blocked relays. -- Avoids NPEs in the maxOfOrNull iterator with concurrent lists -- Assemble NIP-17 Crash Report properties as a table and crash as a code block -- Improves the design of the Crash Report permission screen with a cancel button. -- Adds basic support for expirations in DMs - -Updated Translations - -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- Hungarian by [@npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp](https://github.com/npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp) - -Download: http://amethyst.social - -[Changes][v1.03.0] - - -<a id="v1.02.1"></a> -# [Release v1.02.1](https://github.com/vitorpamplona/amethyst/releases/tag/v1.02.1) - 2025-08-29 - -- Upgrade AGP to 8.12.2 -- Sets Quartz to Java 1_8 -- Creates a bootstrap relay list specifically for inbox relays -- Register lists as Account fields to avoid GC of user settings. -- Fixes inability to delete relays from certain relay lists. - -[Changes][v1.02.1] - - -<a id="v1.02.0"></a> -# [Release v1.02.0 NIP-17 Crash report DMs](https://github.com/vitorpamplona/amethyst/releases/tag/v1.02.0) - 2025-08-28 - -#Amethyst v1.02.0: NIP-17 Crash report DMs - -- Adds a crash interceptor and offers to send crash reports via NIP-17 -- Fixes startup crash on Android 12 or earlier -- Removes old DB migration -- Fixes crash when pasting an invalid NIP-47 URI into the zap settings -- Fixes crash when NWC doesn't return an invoice preimage -- Fixes crash when loading a user with a null name -- Fixes readonly crash on DVM requests -- Speeds up first-time logins by not waiting for Tor -- Fixes a parser benchmark -- Moves test assets to resources to make it work with kmp -- Migrates Quartz to a KMP project -- Converts OpenTimestamps code from Java to Kotlin -- Moves OTS OkHttp setup to Quartz -- Migrates NIP-49 from Java to Kotlin -- Removes nostr.bg from bootstrap relays -- Updates translations - -Download: http://amethyst.social - -[Changes][v1.02.0] - - -<a id="v1.01.0"></a> -# [Release v1.01.0: New Community Implementation](https://github.com/vitorpamplona/amethyst/releases/tag/v1.01.0) - 2025-08-27 - -Features: -- Upgrades NIP-72 communities to use NIP-22 comments as root posts -- Adds moderation queue feed -- Adds in-app approval procedures -- Adds drafts for community posts -- Redesigns the "about us" section of communities. - -Fixes: -- Fixes accessibility TalkBack issues -- Fixes the edit draft button in the long-press menu -- Fixes the inability to delete relays from some lists. -- Fixes unwanted NOTIFY requests from other people's relay lists -- Fixes some DM push notification issues -- Fixes notification registration for first-time logins -- Avoids sending expired events to the relays -- Fixes draft deletes staying in some feeds -- Intercepts backhandler to save drafts -- Moves draft deletion calculations out of the UI thread - - -[Changes][v1.01.0] - - -<a id="v1.00.5"></a> -# [Release v1.00.5](https://github.com/vitorpamplona/amethyst/releases/tag/v1.00.5) - 2025-08-23 - -- Fixes a crash from multiple relays in the same line when following two specific users -- Updates translations - -[Changes][v1.00.5] - - -<a id="v1.00.4"></a> -# [Release v1.00.4](https://github.com/vitorpamplona/amethyst/releases/tag/v1.00.4) - 2025-08-22 - -- Fixes the new post screen not closing after posting and rejecting drafts. -- Fixes the disappearance of drafts. -- Improving loading speeds by using a separate OkHttp threadpool for DM relays and another for media. -- In case a user does not have an outbox list, it defaults to all hints seen for that user. - -[Changes][v1.00.4] - - -<a id="v1.00.3"></a> -# [Release v1.00.3: More fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.00.3) - 2025-08-22 - -- Fixes the lack of feed update for those who didn't follow any community. -- Avoids parsing AI-bad NIP-28 objects -- Don't try to decrypt appData unless it is a writeable account -- streamlines function calls on AccountViewModel -- Removes the HEAD from the release build name - -[Changes][v1.00.3] - - -<a id="v1.00.2"></a> -# [Release v1.00.2: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.00.2) - 2025-08-21 - -Fixes follow/unfollow from hashtags and geohashes -Migrates top nav list to hashtag, geohash and community lists -Fixes community join/leave crash -Fixes branch name -Avoids crash when lacking google services in the play version. -Fixes crash when typing a new post without permissions to do a draft post. - -[Changes][v1.00.2] - - -<a id="v1.00.1"></a> -# [Release v1.00.1: Fix app name](https://github.com/vitorpamplona/amethyst/releases/tag/v1.00.1) - 2025-08-21 - -fix app name - -[Changes][v1.00.1] - - -<a id="v1.00.0"></a> -# [Release v1.00.0: Full Outbox](https://github.com/vitorpamplona/amethyst/releases/tag/v1.00.0) - 2025-08-21 - -#Amethyst v1.00.0: Full Outbox - -This version completes our migration to the outbox model, where the app dynamically manages the relay list used to pull posts from your follows' own relay lists. By default, the app will connect to relays that aren't in your lists through our embedded Tor. Normal usage will connect to hundreds of relays. Many of them will fail, and that's ok. Nostr has baked-in redundancy; these failures won't affect your experience. - -New relay lists were added to the UI to help you manage how the app works. Specifically, you can now block relays and add trusted relays. Trusted relays will connect outside of Tor, which is faster, but allows those relays to see your IP. You should only add relays there if you trust the relay operator. Proxy relays (like filter.nostr.wine) can be added to the proxy list. After that, the app will only use those relays to download the content for your feeds, disabling the outbox model. DMs and other non-outbox functionality will still use their own relays. Broadcasting relays can be added to push your events out there. Every new event from the app will be sent to all broadcasting relays. Finally, the new Indexer list allows you to choose which relays to use to find users, like purplepages.es. - -For users of our Quartz library, we have finished all of the work to change the library's mindset from a fixed list to a dynamic pool of relays. Now, each NIP has its own dedicated folder and defines its own tags and caching structures. This expansion allows us to add diverse functionalities such as relay clients, relay servers, event builders, Nostr filter builders, caching systems, deletion and event hint indexers, helper functions, and more—all specifically tailored to each individual NIP. This modular approach creates the space to develop each NIP independently and integrate them into Amethyst as distinct modules, while still sharing Amethyst's main relay and cache engine when necessary. We expect fewer breaking changes as we move forward with it. At some point, Quartz will move to its own repository and be converted to a Kotlin Multiplatform project for each NIP/module. This will allow us to build demo/testing applications for each NIP in the same repo. - -This version adds support for: -- YakBak Voice Messages -- Picture-in-Picture pop-ups -- Public Messages -- Coolr.chat's Ephemeral Chats -- Follow packs -- Reads feed in the discovery tab -- Hidden cashu tokens in emojis - -Features: -- Reengineered relay, relay pool, and nostr client to manage dynamic pools -- Reengineered note cache for a garbage collector-friendly version -- Reengineered media pre-loading and caching to minimize layout changes -- Reengineered decryption cache, now per account -- Reengineered chat channels cache -- Reengineered the indexing of Addresses to data classes -- Reengineered EOSE cache and managers -- Migrates to a Flow-based design for all account information and services -- Migrates to a Compose subscription model for relay filters -- Adds 90-day expiration to all drafts -- Deprecate stringified JSON in favor of tags on user metadata kind 0 events -- Adds support for live events at the top of the feed. -- Migrates Video events to non-replaceable kinds -- Migrates NIP-51 to use NIP-44 encryptions -- Migrates Chat, Community, Location, and Hashtag follows to their own lists -- Migrates to reply with NIP-22 for everything but kind 1s. -- Massively improves relay hint selections -- Removes relay picker when sending new posts -- Removes general relay list (kind3) -- Adds new relay lists: Trusted, Blocked, Proxy, and Broadcasting -- Moves most of the Dialogs to full-screen routes -- Breaks NewPostScreen and ViewModels into Screens and ViewModels for each supporting NIP -- Adds support for creating and replying to NIP-22 geo scope posts -- Performance Improvements by not re-verifying duplicated events -- Adds Content Sensitivity setting to the Security filter screen -- Adds Translation setting to a new screen. -- Extends AsyncImage to correctly use pre-loaded aspect ratio and avoid jitter -- Adds imeta tags for images and urls inside the content of the Classifieds -- Adds new default banner for empty profiles -- Finishes the migration from LiveData to Flow -- Restructures the old static datasource model into dynamic filter assemblers. -- Moves filter assemblers, viewModels and DAL classes to their own packages. -- Creates Composable observers for Users and Notes -- Unifies all Filter Assembler lifecycle watchers to a few classes -- Moves relay authentication to a coordinator class for all accounts in all relays. -- Moves the relay NOTIFY parser to its own coordinator class for all accounts -- Moves the connection between filters and event cache to its own coordinator class -- Adds support for Tor in push notifications -- Isolated Connectivity services, from Compose to Flow -- Isolated Tor services, from Compose to TorService -- Isolated Memory trimming services, from Compose to Flow -- Isolated Image Caching services, from Compose to Flow -- Isolated Video Caching services -- Isolated Logging services -- Isolated NIP-95 Caching services -- Isolated Pokey receiver services -- Isolated OkHttpClient-building services as flows -- Hold off on all DM attachments until the message is sent. -- Adds previews for any number of urls, events, and media uploads on new post screens. -- Adds zap split, zap raiser, and geolocation symbols for DMs and channel messages -- Adds picture upload for NIP-28 metadata -- Adds support for community relays on NIP-28 -- Adds a pool of ExoPlayers when multiple videos are playing -- Moves DVM's last announcement restriction from 90 days to 365 days - -Quartz: -- Adds a NostrClient with filter and event outbox cache -- Adds a Basic RelayClient and parsers for all relay commands -- Migrates signers from callback to suspending functions -- Migrates event create functions to builders with templates -- Migrates the filter design to a filter per relay -- Migrates hardcoded tag filters in events to the Tag's parser and assembly functions. -- Normalizes all relay URLs -- Formalizes relay hint providers per kind -- Event store support with SQLite -- Reengineered NIP-55 Android signer and its cache -- Reengineered exception handling for signer errors -- Adds support for the Request to Vanish NIP - 62 -- Migrates all NIP-51 lists to the new event-tag structure. -- Migrates Drafts and NIP-04 and NIP-17 DMs to the new structure -- Migrates Bookmarks to the new structure -- Migrates NIP-56 to the newest tag structure -- Adds support for nip70 Protected Tags -- Adds full support for nip73 External Content IDs -- Adds support for NIP-48 proxy tags -- Removes the old "datasource" model -- Adds a Bloom-based hint indexer with MurMur hash -- Adds a PoW miner -- Restructures thread helpers for NIP-10 -- Migrates Zap splits, zapraisers, subject, alts, and content warning to their own packages. - -Dev Team: -- nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpz9mhxue69uhkummnw3ezuamfdejj7qgswaehxw309ahx7um5wghx6mmd9uq3wamnwvaz7tmkd96x7u3wdehhxarjxyhxxmmd9ukfdvuv -- nostr:nprofile1qqsfnw64j8y3zesqlpz3qlf3lx6eutmu0cy6rluq96z0r4pa54tu5eqpz9mhxue69uhkummnw3ezuamfdejj7qg0waehxw309ajxzmt4wvhxjme0hynkd5 -- nostr:nprofile1qqs827g8dkd07zjvlhh60csytujgd3l9mz7x807xk3fewge7rwlukxgpz9mhxue69uhkummnw3ezumrpdejz772u5wm -- nostr:nprofile1qqswc4nrhvp4lrjct0ayy0ps8f2hvj8e2guucp63dwcx6m6e8pka9fqpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhszrnhwden5te0dehhxtnvdakz7qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap00me8jy -- nostr:nprofile1qqsv4zwtz8cuwh2mvc3zdrl5853g365t9j6mn25edlul7uz0eyzt0zcpzamhxue69uhhyetvv9ujumn0wd68ytnzv9hxgtcpzpmhxue69uhkummnw3ezumt0d5hscc6wyt - -Translations: -- Czech, German, Swedish, and Portuguese by nostr:nprofile1qqsv4zwtz8cuwh2mvc3zdrl5853g365t9j6mn25edlul7uz0eyzt0zcpzamhxue69uhhyetvv9ujumn0wd68ytnzv9hxgtcpzpmhxue69uhkummnw3ezumt0d5hscc6wyt -- Dutch by nostr:nprofile1qqs82l74z7g3x8j3avpn2wrjrwn855nyvmpxa4v5pftfvtv5lrvrc5cpz9mhxue69uhkummnw3ezuamfdejj7tk0drp -- French by nostr:nprofile1qqs8av5uzf4nv2q80chrmp3mj9a9dd6zjw4fmz56hsn2gzar72rxhtcppemhxue69uhkummn9ekx7mp0qyg8wumn8ghj7mn0wd68ytnddakj7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcuvd26f -- Polish by nostr:nprofile1qqsdyfz0ewdhmgp3a4r3pxvezx5r8yalrgvjn38v2ml5qrusnv7yywgpzamhxue69uhhyetvv9ujumn0wd68ytnzv9hxgtcpz9mhxue69uhkwmn0wd68ytnrdakj7qgkwaehxw309ahx7um5wghxx7npwvh8qmr4wvhsrpff27 -- Chinese by nostr:nprofile1qqsyxnuhnymz0u0xru2watmqe25vlh8vzzje9jhlsfgvsff9942gc9gpr9mhxue69uhhyetvv9ujumt0d4hhxarj9ecxjmnt9uq3vamnwvaz7tmjv4kxz7fwd4hhxarj9ec82c30qy28wumn8ghj7atn9ehx7um5wgh8w6twv5hsung0qr -- Slovenian by nostr:nprofile1qqsqqqqqqp0fmkspg7w8d305ln96a0jw0ptwqtuwskkm5pddv2kkjfcpyfmhxue69uhk6atvw35hqmr90pjhytngw4eh5mmwv4nhjtnhdaexcep0qythwumn8ghj7un9d3shjtnswf5k6ctv9ehx2ap0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj72epxz8 -- Thai by nostr:nprofile1qqsxdhmq2cke8xk6scfyxeyfj3dyancavg6xk0v50r023gec7vsrceqppemhxue69uhkummn9ekx7mp0qyghwumn8ghj7mn0wd68ytnhd9hx2tcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhskamkgc -- Bengali by nostr:nprofile1qqsgs9hgjw87vz36jf2r83m5zree2q87zvs8s7kty9jljdz7wprytyspremhxue69uhkummnw3ezu6m0de5kueedv3jkwunpv9nzumnv9uq3kamnwvaz7tm5d4cz6un9d3shjtnrv4ekxtn5wfskgef0qy28wumn8ghj7mn0wd68yt3k8quzummjvuhsg63aw2 -- Hindi by nostr:nprofile1qqs88dt78wgnzvaph6fcstfvsd98xc2qs8eg8tllwv2zlutu8ehec2cpzamhxue69uhhyetvv9ujumn0wd68ytnzv9hxgtcy8dumx -- Spanish by nostr:nprofile1qqs07tjpyvvlq9ugdpax8h3jfrpwn7kr72k3tc7ky83tggn4et9eangprpmhxue69uhkv6tvw3jhytnwdaehgu3wwa5kuef0qyghwumn8ghj7mn0wd68ytnhd9hx2tcpzemhxue69uhkumm5d9n8jtnyv9kh2uewd9hj7cn2zey -- Hungarian by nostr:nprofile1qqs88rmfrp9wmfn4qq4kslly0j8futmmrgn86mu3gkc3jvcjl97p3mcpzamhxue69uhhyetvv9ujumn0wd68ytnzv9hxgtcpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ejxzmt4wvhxjme0m7mtdy and nostr:nprofile1qqsxekg0s8kxpcrka8ccwztv2m73kz6jy0ur8f7jc04rwr5p44w5acsk5trt8 -- Persian by nostr:nprofile1qqsvq73w5j9kw573rtff6c3fyh953w45328n3625apdwc3548gr49gsppemhxue69uhkummn9ekx7mp0q6fpv8 - -Download: http://amethyst.social - -[Changes][v1.00.0] - - -<a id="v0.94.3"></a> -# [Release v0.94.3: Adds iMeta tags to GIF urls](https://github.com/vitorpamplona/amethyst/releases/tag/v0.94.3) - 2025-01-05 - -- Adds iMeta tags to GIF urls to optimize GIF previews -- Fixes the extra empty kind 20 post when uploading videos on the media tab -- Fix: Only close the upload screen if the video upload is a success on the Media tab -- Maintains note reaction visibility when scrolling by [@npub1xcl47srtwh4klqd892s6fzwtdfm4y03wzjfl78scmmmxg8wzsf4qftd0en](https://github.com/npub1xcl47srtwh4klqd892s6fzwtdfm4y03wzjfl78scmmmxg8wzsf4qftd0en) - -Download: http://amethyst.social - -[Changes][v0.94.3] - - -<a id="v0.94.2"></a> -# [Release v0.94.2: Fixes Tor leaks](https://github.com/vitorpamplona/amethyst/releases/tag/v0.94.2) - 2025-01-04 - -This release fixes two Tor leaks introduced during the migration of two APIs in v0.94.0. The new relay API contained a bug that bypassed the user's Tor preference for relays and the migration to Coil3 implemented a one-time cache for the Tor preference, preventing it from reflecting subsequent changes for image loading. - -Thanks to nostr:npub17lmqmq680446scdgvv58snglr3h2phe00thqfe0twa3l8q5mzmusj6c60g for the Tor audit - -Download: http://amethyst.social - -[Changes][v0.94.2] - - -<a id="v0.94.1"></a> -# [Release v0.94.1 GIFs and Custom Emoji inputs](https://github.com/vitorpamplona/amethyst/releases/tag/v0.94.1) - 2025-01-04 - -This version adds a `:` command to link custom emojis on new posts and chats. Similar to the @ for user search, just start typing to find your custom emojis. When you see the list of emojis, click on the emoji to add it as an inline emoji OR click on the right button to add as a regular URL in the post. - -Create your GIF and rection libraries on emojito.meme . Make sure to bookmark your and/or other people's emoji packs to add them to Amethyst's `:` list. - -Happy shitposting. - -Download: http://amethyst.social - -[Changes][v0.94.1] - - -<a id="v0.94.0"></a> -# [Release v0.94.0 Encrypted Media on DMs](https://github.com/vitorpamplona/amethyst/releases/tag/v0.94.0) - 2025-01-03 - -Now every upload on DM chats will be encrypted to the destination's pubkey following the same spec 0xChat uses. This offers a massive update in privacy from the common "hidden link" design. The encrypted blobs are sent to NIP-96 and Blossom servers. Make sure your server accepts encrypted blobs. Sattelite and void.cat do accept. We redesigned our upload screens to allow multiple images/videos at the same time on new posts, stories and chats encryption. Error handling was also improved with the screens now allowing you to try again to a different server. - -Features: -- Adds support for encrypted media uploads on NIP-17 DMs -- Integrates with Pokey's Broadcast receiver. -- Expands the Around me filter to 50km -- Shows NIP-22 replies in the replies tab of the user profile -- New upload screen for chats -- When uploads fail, the screen stays live to allow changing the server and trying again. -- Improves the padding in the layout of the gallery -- Allows multi-images posts to be displayed in the Profile gallery -- Refactors zap error message screen to allow sending messages directly to each split receiver with their error -- Adds support for multiple media uploads at the same time. -- Adds support to display PictureEvents with multiple images at the same time -- Adds QR code private key export dialog by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Added Gamestr custom hashtag icon by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Switches to the right account from push notification's click -- Adds new picture and video events to the user profile gallery -- Adds basic support for RelationshipStatus to Quartz - -Fixes: -- Fixes bug that resets localhost relay settings. -- Moves to using cancellable coroutines to avoid cancelling the parent thread all together. -- Fixes the navigation padding on Samsung devices for the Shorts tab. -- Fixes the chat's input field behind Samsung's app bar on tablets -- Fixes notes appearing of replies for some recipes -- Removes the swipe to delete draft from right to left. Only left to right remains available. -- Solves crashing when a p-tag contains only "ffff" -- Fixes edge to edge issues when the keyboard is shown on the Media Server settings. -- Fixes keyboard overriding the relay settings screen -- Fixes double quotes on NIP-28 Channel messages -- Fixes cosine caching on Blurhash -- Fixes download and loading image icon not showing on posts when text overflow by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes lack of notification when a new account is logged into the app and before the app restarts. -- Fixes displaying an old result when coming back to a DVM screen -- Fixes the bugs from migrating video events to imeta tags -- Removes pull to refresh from gallery - -Performance: -- Increases the number of possible active video playbacks on the screen to 20 -- 10x better performance on Blurhash generation -- Improves search by npubs to use all relays. - -UI Improvements: -- Avoids cutting off some of the users in the Pay intent screen -- Adds toast message when the video/image starts downloading by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Remove crossfades and double composition on image loading/success -- Improves Uploading feedback for the NewPost screen -- Optimizes user search to account for names that start with the typed prefix -- Several accessibility improvements by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Simple refactoring to newer versions of the clickable text -- Migrates Video events to imeta tags -- Removes youtu.be links from the video feed. -- Checks for video file types in uppercase as well as lowercase - -Code quality: -- Move relay dialog to a route by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Refactors user tagging lists to generalize them -- Updates zoomable, vico, mockk, kotlin, compose Bom, firebase, navigation compose, android camera libs and adaptive acompanist -- Refactors Ammolite to remove the dependency on OkHttp to prepare for KTor and multiplatform settings. -- Reduces the singleton coupling between Client and RelayPool. -- Removes troublesome dependency on blurhash encoder library -- Restructures contentScale for Images and Video dialogs -- Refactors Media Uploaders to improve code reuse -- Refactors iMeta usage on Quartz to move away from NIP-94 -- Removes the use of nostr: uri for notifications -- Enables a new screen to be routed when new logins happen -- Removes contract of the old image picker -- Simplifies the Gallery stack -- Separates event class that manages general lists and abstracts another intermediary class that manages private tag arrays in its content. - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- Chinese by [@npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h](https://github.com/npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h) -- Slovenian by [@npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw](https://github.com/npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw) -- Thai by [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) -- Bengali by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) -- Hindi by [@npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6](https://github.com/npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6) -- Spanish by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) and [@npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp](https://github.com/npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp) -- Persian by [@npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk](https://github.com/npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk) - -Download: http://amethyst.social - -[Changes][v0.94.0] - - -<a id="v0.93.1"></a> -# [Release v0.93.1 Fixes Satellite's blossom upload](https://github.com/vitorpamplona/amethyst/releases/tag/v0.93.1) - 2024-12-05 - -- Moves to NIP-22 to reply to Interactive Stories. -- Adds amount and personalization labels to the DVM feed -- Fixes Satellite's blossom upload -- Fixes incorrect reply order when the direct reply is also included as a quote. -- Fixes image upload tests -- Fixes the bug of not having the video feed at the top when loading the app from scratch. -- Fixes screen mispositioning when rotating the phone on full-screen video/image dialogs. -- Fixes images on DVM profiles -- Fixes badge crash -- Fixes missing reactions on video feeds -- Improves performance of the Hex encoder. -- Improves the layout of the discovery feed items -- Updates Jackson, secp256k1, and AGP - -Download: http://amethyst.social - -[Changes][v0.93.1] - - -<a id="v0.93.0"></a> -# [Release v0.93.0: Blossom, Olas, Around Me feeds and Interactive Stories.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.93.0) - 2024-11-27 - -Adds support for displaying NIP-63 Interactive Stories -Adds support for Blossom media servers -Adds support for Olas' Image feeds -Adds support for Around Me feed with posts that only show up in that location - -New Features: -- New Android Image/Video Picker -- Adds support for pronouns on profile -- Migrates Video uploads from NIP-94 to NIP-71 Video events -- Migrates Picture uploads from NIP-94 to NIP-68 Picture events -- Adds support for BUD-01, BUD-02, and BUD-03 -- Adds support for NIP-22 Comments -- Adds nip05 field to the hidden words filtering by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Adds support for q tags with addresses -- Adds search.nos.today to bootstrapping relays for search -- Adds DM and Search default buttons to the relay screens -- Adds hidden words filter to search, hashtag and geotag feeds -- Applies hidden words even to hashtags that were not included in the content of the event. -- Adds support for saving reactions, zaps, translations user preferences on private outbox relays - -UI Improvements: -- Adds animations to the zap, reaction and boost popups by [@npub1xcl47srtwh4klqd892s6fzwtdfm4y03wzjfl78scmmmxg8wzsf4qftd0en](https://github.com/npub1xcl47srtwh4klqd892s6fzwtdfm4y03wzjfl78scmmmxg8wzsf4qftd0en) -- Lighter chat bubbles -- Date separators on chats -- Adds unfollow to note dropdown by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Improves (Show More) presence to only when it actually makes a difference. -- Adds UI for when the location permission is rejected. -- Improves error message for the signup page when the display name is blank -- Adds extra padding for the zap setup screen -- Pre-process search to avoid showing and hiding posts after hidden words were processed by the UI. -- Rotate on full screen video if the device orientation is not locked by [@npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k](https://github.com/npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k) - -BugFixes: -- Fixes account creation that follows itself -- Fixes translations of http urls -- Fixes search bug that mixed geohashes and hashtags -- Fixes issue with the order of multiple same-author events in a thread -- Fixes drafts appearing for other logged in accounts by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes jumping of scroll when the thread updates -- Fixes app hanging when switching to accounts due to waiting to decrypt the blocked user list that might not exist -- Fixes initial decryption of mutelists when using amber by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes crash on empty p-tags on new replies [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes translations preferences changes running on the main thread by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes some images being saved as videos [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes missing notifications when multiple accounts tried to decrypt it -- Fixes lack of zap progress updates when there is a private zap the user cannot decrypt or when a nwc payment fails. -- Fixes saving the scrolling position when navigating between multiple threads. -- Fixes rendering cache of notes in thread view which kept replies in the wrong thread level -- Fixes reply level calculation caching -- Fixes poll's background rendering with the right percentages -- Fixes "null" strings on profile fields. - -Code Quality Improvements: -- Refactors FeedStructures to prepare for custom feeds -- Updates Account architecture to operate feeds with location -- Custom Empty Feed Screen for Notifications -- Fully Deprecates note1, removing the last usages of the standard -- Removes unused encryption fields from NIP-94 -- Moves the parallel processing amber calls to a utils class -- Refactors location to operate as a flow -- Unifies location Flows and geoHash Flows into one -- Make location flows react to changing location permissions on the fly -- Moves NIP-44 test model classes to a new file -- Improves GitHub actions to prepare debug apks in every commit -- Upgrades to Coil 3 -- Updates AGP, kotlin, runtime, compose, camera, corektx, media3, firebase, fragment, navigation, jna, jackson, accompanist, kotlin serialization, mockk, coroutines-test and kotlin collections - -Performance: -- Improves thread preloading -- Adds a cache for reply levels when viewing threads. - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- Chinese by [@npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h](https://github.com/npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h) -- Slovenian by [@npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw](https://github.com/npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw) -- Thai by [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) -- Bengali by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) -- Hindi by [@npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6](https://github.com/npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6) -- Spanish by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) and [@npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp](https://github.com/npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp) -- Persian by [@npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk](https://github.com/npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk) - - -[Changes][v0.93.0] - - -<a id="v0.92.7"></a> -# [Release v0.92.7: Mute List fix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.7) - 2024-10-19 - -- [Fixes empty mute lists and mute words](https://github.com/vitorpamplona/amethyst/commit/aae2ffebd71d38917fd5f0df96ca2d1fc11c3ee1) - -[Changes][v0.92.7] - - -<a id="v0.92.6"></a> -# [Release v0.92.6: More UI Fixes for Android 15](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.6) - 2024-10-17 - -- [If the Share target intent opens with the new post screen already active, just updates the post instead of creating a new one.](https://github.com/vitorpamplona/amethyst/commit/c0ba6f5b0071b59f06f63d0264ec9c4aaf2fcc79) -- [Fixes zapraiser progress bar](https://github.com/vitorpamplona/amethyst/commit/cff6ee457d02b6fe4d5a76f61ee9858bc7cea74f) -- [Fixes poll rendering](https://github.com/vitorpamplona/amethyst/commit/25c35326371678c8734691c9eb666cb091e6bd20) -- [Reduces the font size for the translation label](https://github.com/vitorpamplona/amethyst/commit/24b29396cb795bd18fdad92e8a2974e6479be159) -- [Fixes margin of poll options with translation](https://github.com/vitorpamplona/amethyst/commit/67fb3bf488cc24e1bf2bc4a1c74f9523d9540d05) -- [Fixes array too big because strfry blocks more than 20 filters](https://github.com/vitorpamplona/amethyst/commit/26ba75c7c97283a77453462018ebf5bfc7d37df2) - -[Changes][v0.92.6] - - -<a id="v0.92.5"></a> -# [Release v0.92.5: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.5) - 2024-10-16 - -- [Updates compose, benchmark, navigation, and activity](https://github.com/vitorpamplona/amethyst/commit/724bea6bbb475bc074844c380834e0de200f300e) -- [Fixes recreating the new post screen from intent when switching accounts.](https://github.com/vitorpamplona/amethyst/commit/c5dd2c5651a24d2da7464cd4c683b4b7219e9963) -- [Prevent clicks outside boost popup](https://github.com/vitorpamplona/amethyst/commit/6826db13a4988bee32507500d138595dd3d596fa) - -[Changes][v0.92.5] - - -<a id="v0.92.4"></a> -# [Release v0.92.4: Text and Media Share target for new posts.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.4) - 2024-10-16 - -- [Makes Amethyst a share target for texts, images and videos.](https://github.com/vitorpamplona/amethyst/commit/13e2546d362794f20f104a1c424f78850054da67) -- [Fixes new media post dialog for the edge to edge borders](https://github.com/vitorpamplona/amethyst/commit/549b9f53e557e50a06f167b01432538bb02003b2) -- [Fixes padding of the new new post screen](https://github.com/vitorpamplona/amethyst/commit/974c022aedabe786aa57bab8871913c613230852) -- [Changes the new post screen to use the non-disappearing version of the scaffold.](https://github.com/vitorpamplona/amethyst/commit/a57566dd8455e69abe5f49b5ce9f9541bfa7e021) -- [Correctly maps the write status of the outbox relays.](https://github.com/vitorpamplona/amethyst/commit/b8112ff1aa7dd40cf437167803fc581173fb23bb) - -[Changes][v0.92.4] - - -<a id="v0.92.3"></a> -# [Release v0.92.3: Shorts fling behavior bugfix.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.3) - 2024-10-15 - -- [Uses the same Reaction animation to also do Boosts and speeds up animation](https://github.com/vitorpamplona/amethyst/commit/d7c18341cdf98e9def71df58a686188a243a2015) -- [Reverts hack for fling behavior on the Vertical Pager](https://github.com/vitorpamplona/amethyst/commit/d78b55d134f108251e0ec7b62b65ec25c68fc9a2) -- [Updates to AGP 8.7.1 and SDK 35](https://github.com/vitorpamplona/amethyst/commit/5b516f410156be4128d43b4e605fc2aaeeb7c948) -- [Do not bring the keyboard when the search field reappears.](https://github.com/vitorpamplona/amethyst/commit/1fc390c215d85308592372aa3228f1987186e748) -- [Fixes Thread view when loading a reply of a thread that is not cached yet.](https://github.com/vitorpamplona/amethyst/commit/2bb09f583ba1b9ff90c22c9c9fa221ae99435471) - -[Changes][v0.92.3] - - -<a id="v0.92.2"></a> -# [Release v0.92.2](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.2) - 2024-10-11 - -- [Moves the API with amber from `signature` to `result`](https://github.com/vitorpamplona/amethyst/commit/5ef3c8f00ec06e156ab06c1c8605b2a5cefccae1) -- [Fixes the ability to see muted lists in Shorts](https://github.com/vitorpamplona/amethyst/commit/d832351bcd39a43d0b80fb2e00743732bbe5403c) -- [Adds new fields on vision prescriptions](https://github.com/vitorpamplona/amethyst/commit/9ae611831f4777732bc65e7f5ff9cbdda118980f) -- [Prioritise search results that start with the search term](https://github.com/vitorpamplona/amethyst/commit/99641e1a12b483ae2f0e66cf8563b510980eb732) -- [Adds some test cases for video compressions](https://github.com/vitorpamplona/amethyst/commit/76a66e7bd3b2bbcf2b51c56badfc5722aded40d7) -- [Adds Unknown media type test](https://github.com/vitorpamplona/amethyst/commit/a66417ec82040938805dd2b5cb3d7f9f114c78a8) -- [Use "use" blocks to close resources automatically](https://github.com/vitorpamplona/amethyst/commit/fbecc8b6d1585dce3d7d4688aef744e69cc2ea53) -- [Fixes long term issue with the video in the Shorts feed not aligning with the padding of the screen](https://github.com/vitorpamplona/amethyst/commit/d320b951ae02d536eaafeeb483763810fbcdda3d) -- [Fixes intent reopening the Zap Setup screen when coming back from Alby](https://github.com/vitorpamplona/amethyst/commit/c66fa1073bcdf1bd0ffd9084e288ac494c82b5ff) -- [Faster logout processing without closing the account switcher dialog.](https://github.com/vitorpamplona/amethyst/commit/67ea0dcf7fd791d970eaeb64b248d06ea2a587e3) -- [Add animation to notification chart](https://github.com/vitorpamplona/amethyst/commit/a3166f4ff3341e097271570ad68ef9bd4429e144) -- [Add animation to FABs](https://github.com/vitorpamplona/amethyst/commit/9340c440f5f45f68484dc9b6e4d82a27260d2e11) -- [Add animation to zap and reaction popups](https://github.com/vitorpamplona/amethyst/commit/a7343d5b5477ae16dd38f5c49cbfe042b8244b9d) -- [Updates AGP and compose, fragment, navigation, benchmarking and firebase libraries.](https://github.com/vitorpamplona/amethyst/commit/0551b82bd91b720cfc108def82215132f35c9782) -- [Prevent clicks outside reaction and zap popups](https://github.com/vitorpamplona/amethyst/pull/1125) -- [Support for login with hex key when using amber](https://github.com/vitorpamplona/amethyst/commit/baa9ee05626a645d1ea060e78e9231c2624bd666) -- [Video Rotation: actually rotate if the device orientation is not locked.](https://github.com/vitorpamplona/amethyst/commit/7326d6be93ffd8c197d42347ec2311368505ac52) - -[Changes][v0.92.2] - - -<a id="v0.92.1"></a> -# [Release v0.92.1](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.1) - 2024-09-27 - -- Fixes Access to local Citrine when tor is enabled -- Fixes zap splits on a first time connection to the NWC relay. - -[Changes][v0.92.1] - - -<a id="v0.92.0"></a> -# [Release v0.92.0: Tor and Transient Accounts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.92.0) - 2024-09-27 - -This version ships with a Tor service enabled by default to access .onion urls and untrusted relays from the Outbox model. It also offers NFC-hosted transient accounts: accounts that log off as soon as the app goes to the background, deleting all traces of the account from the phone. - -Write your ncryptsec to an NFC tag and hide it in your clothing. When you need to use Amethyst, tap the tag, insert your password and login. Lock the screen to delete everything. If you are an activist and if your phone is confiscated, they will never find anything on the phone. Not even your public key. Ncryptsec is a NIP-49-based password-encrypted nsec. If you need, you can destroy and dispose the NFC tag. - -Features: -- Adds tor node -- Adds multiple settings for the use of Tor -- Adds privacy presets to simplify Tor choices -- Adds support for NFC-hosted transient accounts -- Adds button to take and add pictures from camera by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds Uncompressed option when uploading media by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Adds support for Bloom filters -- Adds zapstore yaml setup -- Adds mempool api to verify OTS via Tor - -Bug Fixes -- Fixes the zap options available for the Zap the Devs button -- Fixes edit draft not working when using the quick action menu by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes opengraph url preview tags from substack by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes the parsing of d-tags with colons in them -- Fixes back button not working after opening a nostr link from the web -- Fixes push notifications when using amber by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes NIP-47 implementation to force relay for the NWC connection. -- Fixes OTS web calls via Tor using mempool's api -- Fixes the loading of localhost urls using Tor -- Fixes .onion urls not using tor even if available -- Fixes show buffering animation when loading videos -- Fixes folowing icon position on chat user pictures - -Performance -- Starts to build all OkHttp clients from a main root client to keep the same thread pool -- Caches OTS web calls to avoid pinging the server repeatedly for the same event. - -Code Quality Improvements: -- Updates navigation compose, lifecycle, fragment, activity, composeBoms and AGP to 8.6.1 -- Improves OTS Verification error messages - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- Chinese by [@npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h](https://github.com/npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h) -- Slovenian by [@npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw](https://github.com/npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw) -- Thai by [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) -- Bengali by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) -- Hindi by [@npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6](https://github.com/npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6) -- Spanish by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) - -Download via [Obtainium](http://amethyst.social) or [Zap.Store](https://zap.store/) - -[Changes][v0.92.0] - - -<a id="v0.91.0"></a> -# [Release v0.91.0 Edge to edge feeds](https://github.com/vitorpamplona/amethyst/releases/tag/v0.91.0) - 2024-09-06 - -Hidden words now filter by the user's fields as well. You can hide by name, profile picture, banner, lightning and nip-05 addresses and about me fields. - -Features: -- Finishes Edge to Edge transition for Android 15 -- Adds compression settings to the media uploading screen by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Adds sliding animations in all inner screens -- Adds copy stack to clipboard for error messages that have an exception -- Enables the use of hidden words for all visible properties of the user - -Bug Fixes -- Fixes blank alt field when no alt text is provided on NIP-96 -- Fixes missing Private Home/Outbox relay list after loading from backup -- Fixes keyboard padding issues when using physical keyboards on the message screens. -- Fixes token sanitization when using gcompatup with unified push by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes moving top buttons on the full screen dialog for images and videos. -- Fixes weird padding of the key backup screen -- Fixes using npub instead of pubkey in hex when communicating with Amber -- Fixes blinking on crossfades when the system's light/dark theme is different than the app's theme -- Fixes a mix of languages after changing language in Settings -- Fixes disables saving m3u8 files locally (streaming can't be saved) -- Fixes Community tab not loading up with Global filter -- Fixes size of default banner when the profile is not loaded yet. - -Code Quality Improvements: -- Inverts Layouts to place Navigation on top of Scaffold and allow custom scaffolds per route -- Refactors navigation to improve clarity -- Restructures screens into their own packages -- Restructures navigation functions as a single object -- Refactors all TopBars to use default material 3 ones -- Simplifies the "and 2 more" translations for the relay recommendation user lists -- Removes unnecessary observers from the transition in the bottom nav layouts. -- Normalizes cache directories in the Application class -- Fixes text field recompositions because of new keyboard actions -- Moves the profile zap to threads -- Updates to AGP 8.6.0 -- Updates zoomable and kotlin serialization - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Thai by [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Bengali by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Chinese by [@npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h](https://github.com/npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h) - -Download via [Obtainium](http://amethyst.social) or [Zap.Store](https://zap.store/) - -[Changes][v0.91.0] - - -<a id="v0.90.6"></a> -# [Release v0.90.6: Zap Amount fix on Profile Zaps](https://github.com/vitorpamplona/amethyst/releases/tag/v0.90.6) - 2024-09-02 - -[Long press to copy relay url](https://github.com/vitorpamplona/amethyst/commit/53acbd860660dd1f1ba5098a8d1f2819e2806e4a) -[Rename amount to amountInMillisats to give visual clue to calling classes that millisats should be used](https://github.com/vitorpamplona/amethyst/commit/a4a753e7b623da776afeb47d293551c04ece994e) -[long press to copy relay url in the relay icons and relay row](https://github.com/vitorpamplona/amethyst/commit/bba7c257da6d6b978432358e0c738b2ab0455a2c) - -[Changes][v0.90.6] - - -<a id="v0.90.5"></a> -# [Release v0.90.5: Orbot fix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.90.5) - 2024-09-01 - -- Fixes proxy connection not starting after killing the app - -[Changes][v0.90.5] - - -<a id="v0.90.4"></a> -# [Release v0.90.4: Fixes relative urls on NIP-96 + Basic NIP46 objects](https://github.com/vitorpamplona/amethyst/releases/tag/v0.90.4) - 2024-08-30 - -[- Fixes caching bug of NIP-96 servers](https://github.com/vitorpamplona/amethyst/commit/aeaddf722bf42cc7140f07e36964dcd447a0a1e0) --- Allow NIP-96 setups with relative URLs --- Displays error messages if the server has sent in the body --- Adds a test case for both -[Fixes ZapTheDevs card not disappearing after the donation.](https://github.com/vitorpamplona/amethyst/commit/3ebaf2bf1e49f75aa08b7fe047f18743f7389ff4) -[Fixes push registration with empty token ids](https://github.com/vitorpamplona/amethyst/commit/1407850e81b0ee58c32b9b5ad3be5ac5548555cc) -[Show relay ping with the relay icon](https://github.com/vitorpamplona/amethyst/commit/efde14a3483a467ac03fd08ec00111da36d36847) -[Enables decryption by nip04 and nip44 on NostrWalletConnect objects, NIP-51 lists and NIP-04 messages](https://github.com/vitorpamplona/amethyst/commit/cb4a73bb9c6138d960c543e9b54330be32a4b85f) -[Fix relay icons when using complete ui](https://github.com/vitorpamplona/amethyst/commit/06f37ab81d1428da37cf73b8b642faf2d5523326) -[Adds basic support for NIP-46 events.](https://github.com/vitorpamplona/amethyst/commit/b5709f95271e5b8fed25986da7f109dc8e2b8d43) -[Protects against empty nip04 content](https://github.com/vitorpamplona/amethyst/commit/8be74649ceda39a3bce8e9c32dbed0dabdcc3bb5) -[Basic code for dynamic limits](https://github.com/vitorpamplona/amethyst/commit/255f46404605136f6dd07d6bbf4f98c330683e33) -[Speeds up the filter for NWC zap payments.](https://github.com/vitorpamplona/amethyst/commit/388ccdbe750cba19d1ce579e72a29339d943d29f) -[Typo Fix on Lnaddress TextField](https://github.com/vitorpamplona/amethyst/commit/93e9e2373034c18cdcf6abc4968bb0322c93816d) - -[Changes][v0.90.4] - - -<a id="v0.90.3"></a> -# [Release v0.90.3: Fixes Unified Push Notifications](https://github.com/vitorpamplona/amethyst/releases/tag/v0.90.3) - 2024-08-27 - -- Fixes Unified Push Notifications -- Fixes Lack of notifications for non-primary accounts -- [Adds a small border between the author and the message of a chat bubble](https://github.com/vitorpamplona/amethyst/commit/4665c488d5c340b68708b90d7416802ba0daac03) - -[Changes][v0.90.3] - - -<a id="v0.90.2"></a> -# [Release v0.90.2: Fixes conversation feed.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.90.2) - 2024-08-27 - - - -[Changes][v0.90.2] - - -<a id="v0.90.1"></a> -# [Release v0.90.1: Fixes error messages when zapping devs](https://github.com/vitorpamplona/amethyst/releases/tag/v0.90.1) - 2024-08-27 - - - -[Changes][v0.90.1] - - -<a id="v0.90.0"></a> -# [Release v0.90.0: Torrents and Outbox refactorings](https://github.com/vitorpamplona/amethyst/releases/tag/v0.90.0) - 2024-08-27 - -This version adds support for NIP-35 Torrent files (dtan.xyz), adds significant memory pruning for encrypted payloads and large events (DMs, Notifications, Zaps, etc), and completely restructures the way most of our caching works to facilitate immediate subscription updates using the outbox model on custom feeds by NIP-51 lists. This version also offers new defaults for NIP-65 and DM relay lists for new users and fixes several bugs and inconsistencies. - -Features: -- Adds support for NIP-35 torrents and their comments -- Adds a simplified sync Signer to the Quartz library -- Adds Default lists for NIP-65 inbox and outbox relays -- Adds Default lists for Search relays -- Adds local backup for UserMetadata objects -- Adds local backup for Mute lists -- Adds local backup for NIP-65 relays -- Adds local backup for DM Relays -- Adds local backup for private home relays -- Improves caching of encrypted DMs -- Updates Twitter verification to X -- Improves the rendering of QR Codes -- Adds support to Delete All Drafts - -Code Quality Improvements: -- Separates Account actions from Account state in two objects -- Changes Startup procedures to start with Account state and not the full account object -- Moves scope for flows in Account from an Application-wide scope to ViewModel scope -- Removes all LiveData objects from Account in favor of flows from the state object -- Migrates settings saving logic to flows -- Migrates PushNotification services to work without Account and only Account Settings. -- Migrates the spam filter from LiveData to Flows -- Rewrites state flows initializers to avoid inconsistent startups -- Finishes the migration of the service manager to the Application class -- Moves to hold the all feeds in stateflows -- Updates benchmark, composeBOM and firebaseBOM to the latest versions -- Moves the default zap type to a state flow and avoids passing on to the screen when using the default value -- Removing unecessary livedata objects for translation services -- Moves lastread routes to mutableStateFlow -- Migrating livedata to flow for contact list updates of the user. -- Adds a destroy method to FollowList state for consistency -- Moves follow list states to the AccountViewModel -- Migrates Notification Summary to the new state model -- Moves the notification screen to the new state model instead of viewModels -- Refactoring Moving feed status from ViewModel to State objects - -Interface Improvements: -- Show only 3 users in the recommended relays section -- Creates links to njump when events can't be found on Amethyst -- Adds support for MOD reports -- Displays commitment PoW if present -- Changes relay set kind to be NIP-51 consistent. -- Adds more information to when error messages are not available in the relay stats. -- Adds context to highlight events -- Adds previews to test markdown rendering -- Improves the look of inlinde code in markdown -- Improves badge display - -Performance Improvements: -- Adds pruning for giftwrapped messages -- Fixes clearing of flows and live data object pools before removing notes from the local cache -- Improves stability of composables -- Migrates caching of decrypted value outside of the Event class -- Removes encrypted parts of NIP-17 from the cache -- Removes old NIP-04 messages from the cache -- Avoids deleting new NIP-17 plain text chats from memory -- Avoids bottom nav recompositions -- Simplifies nav bar selected calculations -- Avoids remembering edit lists that will never exist. -- Improves speed of chatlist rendering - -Bug Fixes: -- Fixes lingering cache and threads still active after killing the app -- Fixes crash when opening the Relay screen with empty urls as relays. -- Fixes horizontal padding of the chat messages -- Fixes the download of 1000s of NIP-65 relay lists because some relays consider empty lists as null and return everything. -- Fixes fdroid push registration to re-register even if the saved distributor was already selected. -- Fixes crash when the relay url of a user is duplicated. -- Fixes padding of short quotes -- Fixes slow down when the last message on chat is a base64 image. -- Fixes the centralization of the "and more" part of relay recommendations -- Fixes miscaching flows of the relay lists from follows. -- Fixes miscache of hashtag following button -- Fixes heading sizes on markdown -- Changes Delete all events to use maximum chunks of 200 elements to avoid the 65KB stringified JSON limit of many relays. - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Arabic, Bengali by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) -- Thai by [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) and [@npub1tr66yvqghfdgwv9yxhmg7xx6pvgvu5uvdc42tgdhsys8vvzdt8msev06fl](https://github.com/npub1tr66yvqghfdgwv9yxhmg7xx6pvgvu5uvdc42tgdhsys8vvzdt8msev06fl) -- Hindi by [@npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6](https://github.com/npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6) -- Spanish by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Chinese by [@npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h](https://github.com/npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h) and [@npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7](https://github.com/npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) - -Download via [Obtainium](http://amethyst.social) or [Zap.Store](https://zap.store/) - -[Changes][v0.90.0] - - -<a id="v0.89.10"></a> -# [Release v0.89.10: Smaller memory footprint](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.10) - 2024-08-09 - -- [Improved filter for notifications](https://github.com/vitorpamplona/amethyst/commit/fad8b9df0fe28240da967db7b316d92050321e8d) -- [Moves service manager to the Application class](https://github.com/vitorpamplona/amethyst/commit/cc526816353a2919fc95bf459fba8570da2ff70a) -- [Adds protections against filters with empty arrays because some relays consider that null as opposed to empty.](https://github.com/vitorpamplona/amethyst/commit/939c8e95b0a130afaf7818ce601a2b4892f881ac) -- [Delete All Drafts now requires maximum chunks of 200 elements to avoid the 65KB stringified JSON limit of many relays.](https://github.com/vitorpamplona/amethyst/commit/89638ff261c7fccb9c409c49d6efb40fc1c4c922) -- [Updates translate dependencies](https://github.com/vitorpamplona/amethyst/commit/02f7b546613e8d371c2902611b92ba5c26e34c06) -- [Reducing the amount of CPU memory used for images to the default.](https://github.com/vitorpamplona/amethyst/commit/68cad97819d554f672a963cc61604f4a515fe3aa) -- [Improves wording on the name of relay types](https://github.com/vitorpamplona/amethyst/commit/2e7b7c5c000ef9a9ab95b7303d58981c24be68b0) -- [Marks username as deprecated](https://github.com/vitorpamplona/amethyst/commit/8ed78ea38fe1d02d21b33e9a350f21633d9891ff) -- [Adds zap amount cache for the memory space calculations](https://github.com/vitorpamplona/amethyst/commit/282d4614c0b000c82806105dd54c68a89ac4b4ec) -- [Allows users to select and copy the notice from the relay on the relay list dialog](https://github.com/vitorpamplona/amethyst/commit/7eaa553ebe1c12f413b0180f4bc13725146926b2) -- [Fixes the clickable regions to open additional relay info on the relay list dialog.](https://github.com/vitorpamplona/amethyst/commit/27d2e2309a1dbcb657e69c29d8ccc58666d42f40) -- [AGP to 8.5.2](https://github.com/vitorpamplona/amethyst/commit/312501e527a9ce126e9e346994e9ecc4c6e49914) - -[Changes][v0.89.10] - - -<a id="v0.89.9"></a> -# [Release v0.89.9 Outbox model to filter large follow lists.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.9) - 2024-08-07 - -- [Fixes the order of bookmarks (keeps the order of the event, instead of the created at)](https://github.com/vitorpamplona/amethyst/commit/ed0676a5f53e199fe85f4691f98946fd161fbded) -- [Improves the async rendering of Base64 content](https://github.com/vitorpamplona/amethyst/commit/f731c654b019e4a2f1395f44218e33d62f8e8759) -- [Moves discovery and video lists to Outbox when Follows or relay lists are selected](https://github.com/vitorpamplona/amethyst/commit/b026bffe4a57e8d53cc4b5385dc323caafcdb4bb) -- [Adds support for selecting authors based on their Outbox relays when searching for notes authored by them](https://github.com/vitorpamplona/amethyst/commit/a1aaec0b7d6cc14f5022462a54800e14a76ab396) -- [Aligns default note comparator to NIP-01's created at descending and then by id ascending](https://github.com/vitorpamplona/amethyst/commit/6f59097ac0612d3d236dee4f056e9a2ecb6259ec) -- [Keep them public to allow testing in these particular functions](https://github.com/vitorpamplona/amethyst/commit/e8574c10bbf357e221987c8dc94480ab972ceca3) -- [Refactors to use native contains instead of custom lambdas on Ammolite's Filter](https://github.com/vitorpamplona/amethyst/commit/7cb87ea9c5a115770c402656a10a176f75e1a3ac) -- [Refactors Ammolite Filters to be regular ones and creates a PerRelayFilter for the use on Amethyst.](https://github.com/vitorpamplona/amethyst/commit/07e5132943bf4c93337dd9e36223d7b95ed23669) -- [Renames the MinimumRelayList to RecommendationProcessor](https://github.com/vitorpamplona/amethyst/commit/2b1e3cfc93319c47bd66a728b47003e7aec1b060) -- [Adds haptic feedback to draft deletion swipe](https://github.com/vitorpamplona/amethyst/commit/e88d1d0d002430acf5641a23eb4f24377ec5e0df) -- [Moves the ContactList cache lists to AccountViewModel, where it can be disposed more efficiently.](https://github.com/vitorpamplona/amethyst/commit/5fdff97cf8e8e855e16dbaa0a1e60045887a1812) -- [Improves the accuracy of the Event memory counter.](https://github.com/vitorpamplona/amethyst/commit/e5328d79756dd45ea14605a4fbe606f47553d7e9) -- [Adds event factory performance test](https://github.com/vitorpamplona/amethyst/commit/71b45b96fbfbdcbbe2153812132723febc7d2924) -- [Adds extension possibility to Quartz's event factory](https://github.com/vitorpamplona/amethyst/commit/971c92b27a210c4cbebd1d1a5f23c7e06b0cd1c9) -- [Moves DataSource dispatcher from IO to Default](https://github.com/vitorpamplona/amethyst/commit/4c70065843af72d3963fd063bf3eee0e5d9f5f69) -- [Makes stringRes Stable for compose](https://github.com/vitorpamplona/amethyst/commit/ab772eb65e039f302b9e4d386ef99af2cd307540) -- [Removes Mutiny NWC button :(](https://github.com/vitorpamplona/amethyst/commit/84df86f4b6262870b837f927d60ab502093e4ae7) -- [Moves Relay viewModels to Default thread](https://github.com/vitorpamplona/amethyst/commit/a0289a0cb455846c177a039c6a663450d95b0dbd) - -[Changes][v0.89.9] - - -<a id="v0.89.8"></a> -# [Release v0.89.8: Delete all drafts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.8) - 2024-08-02 - -- [Add delete all drafts button](https://github.com/vitorpamplona/amethyst/commit/609ccaa63579f3936af8f8d9a71310f5d78fd122) -- [Enables crossfading between image states](https://github.com/vitorpamplona/amethyst/commit/bca941e04595f2e853c8375ab07fa66b1127eae3) -- [Signs for just one auth event to register with the push notification service instead of the dozens of events, one per relay.](https://github.com/vitorpamplona/amethyst/commit/38ba456a223a5197caae1b78b561f91c9d53281f) -- [Adds the highlight quote to the base URL of a highlight event so that when the user opens the link, it highlights on the page.](https://github.com/vitorpamplona/amethyst/commit/b440661e0de387b44035b6728567c2da333ef504) -- [Fix order of Share ImageAction](https://github.com/vitorpamplona/amethyst/commit/cb02955ac601a862016f1db2d7ef7f0afc349364) -- [Fix copying to clipboard edited notes](https://github.com/vitorpamplona/amethyst/commit/25823b1f397f9269e64c8fd0f401fc42a834a30e) -- [Adds tests for `02` and `03` compressed keys to make sure they can encrypt and decrypt from and to each other.](https://github.com/vitorpamplona/amethyst/commit/143c3a14440ee164f09519a27522d1521464417a) -- [Updates depedencies](https://github.com/vitorpamplona/amethyst/commit/a03a11c4cc35473ee2a206b2887f5b8d13c9bedb) -- [Fix Flow Release if not subscribed anymore.](https://github.com/vitorpamplona/amethyst/commit/0258c5aac23bd285fba9a93c8bac2db44ff0a9a5) -- [Adds the mint information to each cashu preview](https://github.com/vitorpamplona/amethyst/commit/bfbcf11fa3a3e7dc0760febb0ed8b1490e8a3082) -- [Fixes Unable to add to media gallery after pressing Show More](https://github.com/vitorpamplona/amethyst/commit/231af1d3d84cc2fc8c2445d7974b9fc1da5ba8db) [#993](https://github.com/vitorpamplona/amethyst/issues/993) -- [Fixes duplicated parsing of content for uncited hashtags](https://github.com/vitorpamplona/amethyst/commit/2b2f04f724397a2f5bfe612a685c378333e0a39b) -- [Better rendering for live streams that are planned but already finished.](https://github.com/vitorpamplona/amethyst/commit/d1278a44771b3bf29ef359881b9b183a2758eee7) -- [Fixes the need for main thread note loading for galleries](https://github.com/vitorpamplona/amethyst/commit/0af0f745bf591818873d28137d2c0e1b77b9b2c1) -- [Simplifies the drawing of gallery entires without e tags.](https://github.com/vitorpamplona/amethyst/commit/3bbb780d2bacbe16a46206f4e468df59012568b5) -- [Adds support for CashuB tokens using CBOR](https://github.com/vitorpamplona/amethyst/commit/fc98442f8b6eb5af817f562f89997a27fa8a2871) -- [Minimizes relay querying when rotating status events for each user.](https://github.com/vitorpamplona/amethyst/commit/63e036a9bbae5de18002989a65f530cd2a72943c) -- [Fixes Client notifications of relay failures after marking them as closed.](https://github.com/vitorpamplona/amethyst/commit/fd06193e938653ef47e667856a9f95df06795d5a) - -[Changes][v0.89.8] - - -<a id="v0.89.7"></a> -# [Release v0.89.7: minor fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.7) - 2024-07-22 - -- Add sendAndWaitForResponse to Client.kt -- [Avoids recomposition of the entire line when changing user status.](https://github.com/vitorpamplona/amethyst/commit/231ca44e1cd5ccae95e6263fbea1a7d0665cde53) -- Debug: [Prints memory use in descending order by size](https://github.com/vitorpamplona/amethyst/commit/ca00c87a6e3ef597df99fe1be1ce844391cd766d) -- Fix: [updates the EOSE of users even if no user is found in the relay.](https://github.com/vitorpamplona/amethyst/commit/4def80c91433433e7146178340af4e94f1caac1c) -- Fix: Filter renewing [Sorts keys before making the filter.](https://github.com/vitorpamplona/amethyst/commit/3c1f57fac0bea8f2c351936d7c94ecae5d7a0b85) -- Fix: [Remove focus from status text field when closing the drawer](https://github.com/vitorpamplona/amethyst/commit/e166c36731fc94f1b653321a01497d53ac471b32) -- [updates coil and jackson libs](https://github.com/vitorpamplona/amethyst/commit/a636f4a786d745bce3e45a5bc390b428d92250c7) - -[Changes][v0.89.7] - - -<a id="v0.89.6"></a> -# [Release v0.89.6: Fixes stringResource crash](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.6) - 2024-07-21 - -[Runs faster animations (200ms instead of 400ms)](https://github.com/vitorpamplona/amethyst/commit/52fef33662fd8e0e1e014b4359fab1790ee85e46) -[Adds the key to avoid applying to a separate navcontroller](https://github.com/vitorpamplona/amethyst/commit/2ef2f0d1a585f08e269194b5387b0c8aa7eb443c) -[Fixes stringResource cache](https://github.com/vitorpamplona/amethyst/commit/5a60c3a595869fc9b85ccc6655e3222b30cdfd19) - -[Changes][v0.89.6] - - -<a id="v0.89.5"></a> -# [Release v0.89.5: Fixes the drawing of screen below the nav bar](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.5) - 2024-07-20 - -- [Fixes screen rendering below the 3-button navigation](https://github.com/vitorpamplona/amethyst/commit/8ed3f84cb77e637b8a8d832ae36925c08e12a34d) -- [increases disk cache to 1GB and adds a memory cache policy.](https://github.com/vitorpamplona/amethyst/commit/fa06aefbf19fab24f0124c84893d08becd5b9e7b) -- [Adds more memory stat dumping for production apps](https://github.com/vitorpamplona/amethyst/commit/b80008d695e27caf900c748ac82c72edf2430218) - -[Changes][v0.89.5] - - -<a id="v0.89.4"></a> -# [Release v0.89.4: Migrates to edge to edge in preparation for Android 15](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.4) - 2024-07-19 - -- Fixes Darkmode Action bar crash -- Fixes empty relay list not connecting to anything (uses default relays instead) -- Fixes alt text of NIP-96 server lists -- Fixes auth infinite loop with nostr.wine -- Fixes the keyboard overriding parts of the screen -- Migrating to edge to edge in preparation for Android 15 - -[Changes][v0.89.4] - - -<a id="v0.89.2"></a> -# [Release v0.89.2: Bugfixes for TopNav Lists](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.2) - 2024-07-17 - - - -[Changes][v0.89.2] - - -<a id="v0.89.1"></a> -# [Release v0.89.1: Bugfix for invalid id crashes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.1) - 2024-07-16 - - - -[Changes][v0.89.1] - - -<a id="v0.89.0"></a> -# [Release v0.89.0: Profile Galleries, nip96 server setup and relay recommendations](https://github.com/vitorpamplona/amethyst/releases/tag/v0.89.0) - 2024-07-16 - -This version adds Profile Galleries, flexible NIP-96 image server settings and relay recommendations based on the outbox model. To add an image to your Gallery, click on the Share button and hit Add Media to Galery. This works on posts from you or from other people. - -Features: -- Renders Base64 images and gifs by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds NIP-96 image server settings by [@npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k](https://github.com/npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k) -- Adds Profile Gallery by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Adds outbox cache in order to resend events after relay authentication -- Force-updates relays that are sending old versions of replaceables or events that have been already deleted -- Adds follow-list based relay recommendations to the relay settings. -- Adds Malware Report type - -Performance Improvements: -- Reduces interruptions to the main thread -- Adds performance monitors for framedrops in benchmark mode - -BugFixes: -- Several improvements in the Push Notification API to never miss a Zap -- Fixes lack of text update when switching edited versions -- Fixes poll rendering behavior after deleting an option by [@npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k](https://github.com/npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k) -- Fixes discovery top nav list watcher staying active when the app goes to the background -- Fixes scoping issues with flattenMerge freezing Top Nav List updates -- Fixes Top Nav lists after a deletion event has been received -- Fixes a bug on clicking the user profile but loading the wrong one -- Fixes the post button disappeering when the Relay Settings top label is too large -- Fixes text cut off for very long posts by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes double auth triggering NOTIFY from inbox.nostr.wine - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Chinese by [@npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7](https://github.com/npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7) -- Arabic, Bengali by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) -- Thai by [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) and [@npub1tr66yvqghfdgwv9yxhmg7xx6pvgvu5uvdc42tgdhsys8vvzdt8msev06fl](https://github.com/npub1tr66yvqghfdgwv9yxhmg7xx6pvgvu5uvdc42tgdhsys8vvzdt8msev06fl) -- Hindi by [@npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6](https://github.com/npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6) -- Spanish by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) - -Code Quality Improvements: -- Removes decryption for DVM responses since it doesn't encrypt statuses -- Upgrades lifecycle to 2.8.3, Kotlin to 2.0.0, Lint to 1.3.1 and AGP to 8.5.1 -- Migrates sdk requirements to the version catalog -- Removes unnecessary dependencies for Ammolite -- Fixes several memory issues on CI due to Kotlin 2.0 -- Removes the bugfix for reproducible builds since it has been fixed -- Solves build slowdown on spotless dependencies - - -Download via [Obtainium](http://amethyst.social) or [Zap.Store](https://zap.store/) - -[Changes][v0.89.0] - - -<a id="v0.88.7"></a> -# [Release v0.88.7: Solves Push Error when using 2+ accounts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.7) - 2024-06-29 - - - -[Changes][v0.88.7] - - -<a id="v0.88.6"></a> -# [Release v0.88.6: Fixes double auth triggering NOTIFY from inbox.nostr.wine](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.6) - 2024-06-28 - - - -[Changes][v0.88.6] - - -<a id="v0.88.5"></a> -# [Release v0.88.5 Swipe-to-Delete Drafts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.5) - 2024-06-28 - -#Amethyst v0.88.5: Swipe-to-Delete Drafts - -Features: -- Creates Ammolite, a library to host Relay access for other Nostr Clients by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds author picture when writing posts and replies. -- Adds a Swipe to delete action on the Drafts screen. -- Moves to non-deterministic signatures -- Renders relay lists (NIP-65, NIP-17, and Search kinds) as notes in the feed. -- Adds auth.nostr1.com as a recommendation for private inbox relays -- Adds uploading error messages for common HTTP status codes when uploading images/videos - -Performance Improvements: -- Saves a copy of the NIP65 and NIP17 relay lists locally - -BugFixes: -- Fixes not showing relay icons when sending chat messages with Amber. -- Adjusts the size of the reply button on chats -- Fixes the rendering of highlights when no user is present and includes options to render by e tags -- Fixes the position of the hash verification icon on NIP-95 images. -- Avoids using SSL on localhost relays by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes alignment of the close button on Chat's reply preview -- Adjusts default zap amounts for the Zap the Devs button -- Fixes decryption error on the PrivateOutboxRelayList event for an account that is not currently active. -- Fixes extra } when rendering hashtags from Markdown -- Fixes empty filters when the logged-in accounts only include the current account. -- Fixes bug on string resources showing the same Zap amount and message for different Zap notifications. - -Updated translations: -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Chinese by [@npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7](https://github.com/npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7) -- Arabic, Bengali by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) - -Code Quality Improvements: -- Logs an error message in the Relay Dialog when the relay does not accept a new event -- Updates dependencies -- Changes "app" directory to "amethyst" due to the amount of libraries we now have in the same repo -- Renames JsonFilter to just Filter and adds a matching function -- Rearranges Quartz's crypto package into separate nips and reduces the amount of circular dependencies. -- Removes old highlight rendering functions -- Refactors PlaybackService - -Download via [Obtainium](http://amethyst.social) or [Zap.Store](https://zap.store/) - -[Changes][v0.88.5] - - -<a id="v0.88.4"></a> -# [Release v0.88.4: Bigger reaction panel](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.4) - 2024-06-22 - - - -[Changes][v0.88.4] - - -<a id="v0.88.3"></a> -# [Release v0.88.3: New Reactions Popup](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.3) - 2024-06-21 - - - -[Changes][v0.88.3] - - -<a id="v0.88.2"></a> -# [Release v0.88.2: Reverting old account structure and migration](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.2) - 2024-06-20 - - - -[Changes][v0.88.2] - - -<a id="v0.88.1"></a> -# [Release v0.88.1: Solves Inbox.Nostr.wine notification](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.1) - 2024-06-20 - - - -[Changes][v0.88.1] - - -<a id="v0.88.0"></a> -# [Release v0.88.0: Performance Mode](https://github.com/vitorpamplona/amethyst/releases/tag/v0.88.0) - 2024-06-19 - -#Amethyst v0.88.0: Performance Mode and Performance improvements - -This version adds several performance improvements and includes a new UI mode in Settings that is designed for older phones. On that mode, all CPU-based animations are disabled, the use of transparency is minimized and the individually-generated robots are replaced by a static image. We also recommend disabling the Immersive Scrolling when using older phones. - -Features: -- Adds performance mode on Settings -- Adds login with NIP-05 address -- Adds outbox relays to zap request: sender, receiver and author relays. -- Adds the NIP-65 relay to zap split tag instead of kind3 relays. -- Adds support for AVIF images -- Adds flare.pub videos to the media tab -- Replaces the post view count for a Share icon in the main feed. - -Performance Improvements: -- Centralizes stringResource calls to cache them and avoid disk use -- Removes several unecessary UI states created during rendering -- Moves uncited hashtag parsing to a thread -- Replaces InputButton for ClickableBox to avoid loading colors during rendering -- Switches Social Icons mip-mapped PNGs to faster SVG versions -- Faster calculation of uncited hashtags in content -- Improves the speed of Robohash rendering -- Moves chatroom user group away from immutable sets -- Speeds up long-press Quick Action menus -- Optimizes NIP-11 fetch and avoids requesting twice in the same minute -- Redesigns the UI Components of the relay icons better performance -- Creates a relay flow cache to speed up the relay layout -- Combines hidden and reporting flows in a new cache -- Removes post reports Live data -- Refactors Full Bleed design of the master note -- Unifies Hidden and Report checks between the Video Feed, the Full Bleed Design and the Card layout. -- Adds a hashcode cache to speed up O(1) requests of spam and blocked user public keys -- Minimizes memory alloc by adding a native forEach and Map functions in the event's tag array -- Reduces double launch of co-routines -- Improves memory use of updates to the subscription after EOSE events -- Simplifies intrinsic size calculations for Image Previews and Videos -- Improves rendering time of chat messages - -BugFixes: -- Fixes the inconsistency of button animation size in the reaction row -- Fixes the inconsistency of the Zap button graphics -- Fixes the Giftwraps query by EOSE date mismatch -- Fixes the keyboard's Go Button action on new user screen -- Stops redirecting when resolving nip05 addresses by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fix hidden notes when hidden words is empty by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes Url Preview crop when the image is too small or to big for the preview card. -- Better error messages when NIP-11 queries fail -- Fixes use navigateUp instead of popBackStack to avoid closing the app on double clicks on the back button -- Fixes the centered url on videos without active playback -- Fixes the padding of the reaction row on quotes -- Solves notification dots appearing on the navigation bar due to a hidden post -- Increases the download limit for reactions/zaps to events from 100 to 1000 -- Fixes zap split rounding precision -- Fixes padding of the zap raiser -- Avoids showing error message if devs have removed their lnadress on the Zap the Devs card. -- Fixes padding and border of the zap split section on the master note -- Removes gray border in image urls that couldn't be loaded. -- Fixes alignment of reactions -- Fixes not centered Blank Notes -- Fixes scrollable drawer for all screen sizes -- Fixes search limits for profiles from 100 to 1000 events -- Re-normalizes all relays urls before connecting to reduce duplications -- Fixes the jittering from resizing Videos and Images during loading. -- Fixes landscape video centralization -- Forces relay URLs to be single line. - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Hindi by [@npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6](https://github.com/npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Spanish by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Chinese by [@npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7](https://github.com/npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) - -Code Quality Improvements: -- Removes old Robohash bitmap-based generation -- Upgrades Compose, Lifecycle and Fragments - -Download via [Obtainium](http://amethyst.social) or [Zap.Store](https://zap.store/) - -[Changes][v0.88.0] - - -<a id="v0.87.7"></a> -# [Release v0.87.7: Revert Save button](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.7) - 2024-06-11 - -- Reverts Image dialogs to have a separate save button -- Adds save button to video player. -- [Fixes not centered Blank Notes](https://github.com/vitorpamplona/amethyst/commit/165c3bb852ece90a5fcad676f6710fcd5b356097) -- [Fixes scrollable drawer for all screen sizes](https://github.com/vitorpamplona/amethyst/commit/2e9683491fce9197245f4aef0c1dd6f5534d2bfd) - -[Changes][v0.87.7] - - -<a id="v0.87.6"></a> -# [Release v0.87.6: AOSP-based keyboard Fix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.6) - 2024-06-08 - -- Fixes relay drops and difficulty in connecting when switching WIFI to Mobile -- Fixes the scrolling of the followed hashtags feed on User Profile -- Fixes crashing when tagging users with old AOSP-based keyboards -- Adds query by hashtag in reposted notes -- Fixes inbox.nostr.wine Pay Notification when already paid -- Performance: Moves OTS attestations local cache to a flow instead of live data -- Restructures video and image views and dialogs to avoid some buttons getting on top of one another -- Fixes videos re-starting from 0 when clicking in the Playing Video notification card -- Performance: Moves the video release to a threadsafe procedure -- Fixes fiatjaf search: Adds larger search limits for profiles - -[Changes][v0.87.6] - - -<a id="v0.87.5"></a> -# [Release v0.87.5: Re-normalizes all relays](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.5) - 2024-06-04 - -- Re-normalizes all relays urls before connecting to reduce duplications ([`d10b4c6bde`](https://github.com/vitorpamplona/amethyst/commit/d10b4c6bdee0f57f6f855b9ec9032059e08d89ba)) -- Normalizes all new relays in the edit screens. -- [Adds flare.pub videos to the media tab on Amethyst](https://github.com/vitorpamplona/amethyst/commit/f1e516662c53c2f72bbed087e0ede168a28ee841) -- Performance Improvements: Simplifies intrinsic size calculations for Image Previews and Videos -- Improves rendering of chat messages -- Fixes the jittering from resizing Videos and Images during loading. -- Fixes video cut off when loading -- Fixes blurhash covering videos in landscape -- Fixes landscape video centralization -- Fixes chat using Two Pane layout for landscape and regular for portrait -- [Improving the detection of image types on the Media Feed.](https://github.com/vitorpamplona/amethyst/commit/1bf81656417ce41e619155394f27ec930fade859) - -[Changes][v0.87.5] - - -<a id="v0.87.4"></a> -# [Release v0.87.4: Fixes Live Streaming getting stuck](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.4) - 2024-06-03 - - - -[Changes][v0.87.4] - - -<a id="v0.87.3"></a> -# [Release v0.87.3: Fixes for using the wrong relays for DVMs.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.3) - 2024-06-03 - - - -[Changes][v0.87.3] - - -<a id="v0.87.2"></a> -# [Release v0.87.2: Relay connection bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.2) - 2024-06-01 - - - -[Changes][v0.87.2] - - -<a id="v0.87.1"></a> -# [Release v0.87.1: BugFix missing notification dot](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.1) - 2024-06-01 - - - -[Changes][v0.87.1] - - -<a id="v0.87.0"></a> -# [Release v0.87.0: DVMs and Gossip](https://github.com/vitorpamplona/amethyst/releases/tag/v0.87.0) - 2024-06-01 - -#Amethyst v0.87.0: Data Vending Machines (DVMs) and Gossip Model - -This version adds support for Data Vending Machines for content discovery. You can request a job by simply navigating to the 4th tab and choosing one of the DVMs. It also starts our support for the Inbox/Gossip model. The relay setup screen has been rewritten to support the many types of relays Amethyst will start to use. Please add your relays as you see fit. - -Our video caching system has been improved. Most of the high-bandwidth use of the app in the last month has been due to a faulty caching system for some video types. The app was just downloading them at every playback. In a similar way, the caching system for encrypted events has been massively improved. The app should feel visibly lighter at this point. - -If you generated your keys from NIP-06 seed words you can now type them on the login screen to start the app. We are not generating seeds yet, but this will be available in the upcoming versions. - -We are also moving to deprecate and remove most of the NIP-04 usage in the app. In the future, users won't be able to send new NIP-04 DMs but the history of past DMs will be available for as long as we can support it. - -Features: -- Adds support for NIP-90, data vending machines by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Adds support for discovery content DVMs in the discovery tab by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Adds support for paid DVMs by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Adds support for NIP-06 seed word key derivation (bip32 and bip39) when logging in -- Adds support for NIP-65 relay lists -- Adds support for NIP-17 private DM relay lists -- Adds support for private relay lists to save Draft events -- Adds support for local relays as a separate relay set, saving locally only. -- Adds message + dialog to setup Search relays when searching -- Adds message + dialog to setup DM relays when messaging -- Adds signString method for Amber by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Prefers NIP-65 relays for zap request relay tags -- Prepares for NIP-96 server list integration -- Adds paste from clipboard button to NWC screen by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Moves video compressing quality to medium instead of low -- Adds recommended amounts for the Zap the Devs -- Reduces default zap amounts due to the change of Bitcoin price -- Allows the new post's relay selection dialog to pick any relays (instead of just writing relays) by [@npub10npj3gydmv40m70ehemmal6vsdyfl7tewgvz043g54p0x23y0s8qzztl5h](https://github.com/npub10npj3gydmv40m70ehemmal6vsdyfl7tewgvz043g54p0x23y0s8qzztl5h) -- Improves Zap efficiency for large zap splits -- Adds a queue of commands while the relay connects -- Makes sure only one connection per URL is made when doing splits -- Removes unnecessary Amber calls when decrypting private zaps -- Improves Zap error messages to include the lnaddress of the error -- Displays Zap Split error messages in sequence instead of in multiple popups. - -Bug Fixes: -- Waits 500ms before restarting all connections when saving new relays -- Automatically deactivate writes for search relays (they are read-only) -- Overrides pubkey to avoid impersonators on seals. -- Fixes the rendering of replies on wikipages. -- Fixes duplicated imeta tags when uploading the same image twice -- Removes reposts from the Dot Notification in the home's bottom bar icon -- Fixes a white space when including an image url after a new line -- Fixes alignment of the like icons after the like event -- Fixes wrong display of original and forked notes -- Improves the rendering of Channels and Communities when quoted -- Slightly better rendering Drafts in the thread -- DecimalFormats are not thread safe, moving them to thread objects -- Block error messages from closing the Zap split payment screen -- Better formats zap amounts (don't show .0 if the previous numbers are large) -- Fixes the offset position of the payment amounts on the Zap the Devs message -- Fixes Copy Text function of DraftEvents -- Fixes top bar lists not updating when following communities and hashtags. -- Show toast error if unable to hide words by [@npub10ug9xs24ay5339agakaqk556t6zvq9qn5vm0vlhc4pu25cx0l32qxhrm9e](https://github.com/npub10ug9xs24ay5339agakaqk556t6zvq9qn5vm0vlhc4pu25cx0l32qxhrm9e) -- Adds Autofocus when entering the search screen by [@npub10ug9xs24ay5339agakaqk556t6zvq9qn5vm0vlhc4pu25cx0l32qxhrm9e](https://github.com/npub10ug9xs24ay5339agakaqk556t6zvq9qn5vm0vlhc4pu25cx0l32qxhrm9e) -- Fixes the use of Global-active relays in the Global Feed -- Fixes special chars on URL previews by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes the lack of refresh when adding hidden words in the Security filters -- Makes sure only one buffering action is run for each video view -- Increases timeout to Tor connections -- Fixes a bug with `signature-null` in the sig of events from Amber - -Updated translations: -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Polish by [@npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm](https://github.com/npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Swahili by [@npub1q6ps7m94jfdastx2tx76sj8sq4nxdhlsgmzns2tr4xt6ydx6grzspm0kxr](https://github.com/npub1q6ps7m94jfdastx2tx76sj8sq4nxdhlsgmzns2tr4xt6ydx6grzspm0kxr) -- Thai by [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) -- Hindi by [@npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6](https://github.com/npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6) -- Spanish by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) - -Performance Improvements: -- Optimizes Blurhash generation (4x gains) -- Speeds up the URL finder in the new post edit text (8x gains). -- Reduces the use of background colors to improve rendering speeds (15% gains). -- Refactors Giftwrap caching to delete encrypted text and reload the wrap if necessary (20x savings in memory use). -- Changes message wrap host to a host stub to reduce memory use -- Only download GiftWraps form 2 days past the last EOSE -- Moves the thread formatter and calculator out of Note to avoid memory use -- Slight improvement on the thread view for badges -- Unifies NIP01 Serialization with SHA-256 procedures to reduce the creation of several byte arrays at every verification -- Minimizes costs of keeping track of the number of events received per subscription -- Moves ClientController executor to a thread -- Speeds up ID calculations for Amber's Intent call - -Code Quality Improvements: -- Major refactoring of the Relay List screens -- Refactors Relay URL formatter to Quartz -- Adds new observer structure for the LocalCache -- Moves Blurhash code to the commons module -- Updates UnifiedPush by [@npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k](https://github.com/npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k) -- Migrates to the latest Kotlin, Compose, and AGP 8.4.1 and several other dependencies - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.87.0/amethyst-googleplay-universal-v0.87.0.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.87.0/amethyst-fdroid-universal-v0.87.0.apk ) - -[Changes][v0.87.0] - - -<a id="v0.86.5"></a> -# [Release v0.86.5](https://github.com/vitorpamplona/amethyst/releases/tag/v0.86.5) - 2024-04-11 - -[Enables Mutiny Wallet NWC](https://github.com/vitorpamplona/amethyst/commit/b90a57220d11e84f068641896bdaf5da941102ea) -[Removes the use of DM relays to find events due to private inbox settings](https://github.com/vitorpamplona/amethyst/commit/a538b66db328229931fd513269f4c323a27b64fe) -[Adds vertical scrolling on the Zap page for collaborators.](https://github.com/vitorpamplona/amethyst/commit/f04631b0dd74df82f4204276ad99d85068b50223) -[Avoids decrypting existing Nostr events just to add the relay into the relay list.](https://github.com/vitorpamplona/amethyst/commit/6e31cff99c179c52cfe586ee64061691035f2850) -[Calculates hash in the IO thread from Compose's scope.](https://github.com/vitorpamplona/amethyst/commit/1553640c1846979a9100c2507e95f36a623b7f7e) -[New Crowdin translations by GitHub Action](https://github.com/vitorpamplona/amethyst/commit/f6cce42028a834037eb1e5cf1538d88c746e6cc4) - -[Changes][v0.86.5] - - -<a id="v0.86.4"></a> -# [Release v0.86.4: Start up bug fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.86.4) - 2024-04-11 - -[Updates AGP](https://github.com/vitorpamplona/amethyst/commit/fa7aa3cf241fcc993cf864b57dbbf840d2e9fbbe) -[Updates compose BOM](https://github.com/vitorpamplona/amethyst/commit/d38b57025cd6a2ff3c3d493e7e5e7f80b1cb9db2) -[Updates Kotlin version](https://github.com/vitorpamplona/amethyst/commit/eca5b47ab0a1a302cf4ca29bdbdec802ae0fa2fd) -[Fixes missing Zaps and some DMs on startup](https://github.com/vitorpamplona/amethyst/commit/4722b2a6172a8d706d0f8aafc9af6344520a8608) -[Do not use tor proxy when localhost, fix proxy not being used inside ImageDownloader.kt](https://github.com/vitorpamplona/amethyst/commit/31516964c86e51f2840864314a2a9255680ea376) -[Fixes image downloader not going through Orbot's Proxy](https://github.com/vitorpamplona/amethyst/commit/c4250ccd352d0f059b70e879fb125803142abd2c) - -[Changes][v0.86.4] - - -<a id="v0.86.3"></a> -# [Release v0.86.3: New Markdown Parser](https://github.com/vitorpamplona/amethyst/releases/tag/v0.86.3) - 2024-04-10 - -- Migrates to the new, faster Markdown Parser -- Adds Note previews on Markdown -- Adds Custom hashtag icons to Markdown -- Adds URL preview boxes to Markdown -- Fixes Missing notifications -- Fixes clickable route not showing the user's npub before loading their name -- Fixes max width of hidden notes making them off-centered -- Moves parsing and saving an embed event to the IO thread -- Improves the secondary button design of the encrypted nsec backup page - -[Changes][v0.86.3] - - -<a id="v0.86.2"></a> -# [Release v0.86.2: Fixes Draft Deletion in Threads bug](https://github.com/vitorpamplona/amethyst/releases/tag/v0.86.2) - 2024-04-05 - -#Amethyst v0.86.2: Draft BugFixes - -- Fixes Draft Event not being deleted in threads -- Fixes draft decryption error -- Adds ws:// if not present in .onion relay urls -- Fixes double notifications - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.86.2/amethyst-googleplay-universal-v0.86.2.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.86.2/amethyst-fdroid-universal-v0.86.2.apk ) - -[Changes][v0.86.2] - - -<a id="v0.86.1"></a> -# [Release v0.86.1: Fixes Draft into Draft infinite loop](https://github.com/vitorpamplona/amethyst/releases/tag/v0.86.1) - 2024-04-04 - - - -[Changes][v0.86.1] - - -<a id="v0.86.0"></a> -# [Release v0.86.0 Draft Support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.86.0) - 2024-04-04 - -This version adds support for draft notes autosaved on your relays, a new simplified UI choice on Settings, changes the Discover tab algorithm to show the latest of chats and communities and much more. - -Features: -- Draft notes for feeds, replies, live streams, public chats, NIP-04 DMs, GiftWrap DMs, polls and classifieds by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Adds autosave for Drafts by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Adds edit draft in the dropdown menu and the long press popup by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Adds a Draft feed screen for all posts -- Adds new algorithm to parse OpenGraph tags by nostr:npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc -- Adds a Simplified UI setting to both feeds and chats -- Moves the username play button to the profile page. -- Adds link to the version notes when clicking in the version in the drawer -- Brings new git Issues and Patches to the Notification -- Filters out too many reposts of the same note when on the main feed -- Updates the bootstrap relay list -- Adds missing classes to support WebServer connections in the Video Playback -- Slightly reduces line height for improved readability -- Reduces the space between chat bubbles. -- Migrates shareable links from habla.news to njump.me -- Restructures the Discover Tab to show the Chats and Communities with the most recent new content. -- Adds a bot field to the user info -- Adds k-tag to the Deletion events -- Adds button to load Zap Splits from the cited users in the text -- Several accessibility improvements by nostr:npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef - -Bug Fixes: -- Fixes the post cut-off when the post has massive string chars without spaces or new lines -- Fixes missing Fhir Classes on Release -- Don't show the button to edit the post if the author of the original post is not the logged in user -- Fixes crash parsing multiple results from Amber by nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 -- Fixes the load of edits on a new edit proposal -- Fixes forking information kept from a previous post -- Fixes search on binary content -- Fixes space after clickable user display -- Centers Blank Note when post was hidden by the user. -- Accepts JSON events with escaping errors -- Fixes the parsing of user metadata events several times due to large coroutine backlog -- Fixes Scheduled Tag in LiveStreams -- Fixes the isOnlineCheck for nostr nests. -- Fixes sorting contract issues when follow status and user names are the same between keys -- Fixes tickmarks on dropdowns -- Checks if a Classified is wellformed before rendering -- Fixes size and alignment of the text when the live stream video is not present. -- Fixes some imports for benchmarks -- Fixes infinite Quotation issue (3 quotes are allowed). -- Fixes crashing with too many videos in quoted posts. -- Fixes double Show More when the user has hidden a post and ALSO the user's followers have reported the post. -- Only shows OTS to the respective Edit -- Fixes a bug with the latest version of jackson -- Avoids showing machine-code errors when paying for zaps on external wallets -- Fixes too strict timing constraints for new posts (accepts up to a minute in the future) -- Fixes following of geotags -- Fixes lack of zap amount refresh after zapping a note. -- Fixes videos not being able to seekTo the zero position. -- Fixes layout issues of Blog Post summaries when images are not present. -- Doesn't show edits of blog posts in the User's Profile -- Fixes Amber's deep sleep: Adds a lifeCycleOwner listener to register external signers on resume -- Fixes missing context in some replies to blog posts. -- Adds a space after the Channel header in the reply rows -- Centralizes the counter after the list of participants in a live event. -- Fixes double mention to Community headers when seeing a reply to a community post. -- Fixes Chat preview images when no image has been set. -- Fixes the display or Zap Events when All Follows is selected in Notifications -- Fixes the reply event finder for the reply row of text notes -- Makes hidden cards full width on the discovery feed -- Fixes the width of muted messages on chat feeds. -- Fixes the feed updates after list selections on the Discovery pages. -- Realigns the reaction icons and texts between main feed and video feed. -- Fixes garbled URL preview for non-UTF-8 HTML by nostr:npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc -- Adjusts icon sizes on the galleries -- Avoids publishing with two equal `t` hashtags when the user already writes them in lowercase -- Limits the size of image previews from opengraph from being too big -- Fixes NPE with the cached state. -- Increases the push notification max delay to 15 minutes -- Fixes controller comparison for keep playing -- Fixes tag markers for replies in DMs -- Fixes layout of the reply row in chats -- Fixes lack of blurhash preview in some videos -- Fixes the lack of following mark on user pictures in chats -- Fixes the UI spacing for channels -- Fixes the use of filters that didn't discriminate the relay type setup -- Holds the state of Show More when switching edits and translations -- Renders DMs and live chats in the feed if they show up there -- Fixes contract violation when sorting users - -Updated translations: -- Spanish by nostr:npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 -- French by nostr:npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz -- Dutch by nostr:npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd -- Swahili by nostr:npub1q6ps7m94jfdastx2tx76sj8sq4nxdhlsgmzns2tr4xt6ydx6grzspm0kxr -- Chinese by nostr:npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7 -- Bengali by nostr:npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t -- Hungarian by nostr:npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp -- Czech, German, Swedish and Portuguese by nostr:npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef -- Arabic by nostr:npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t -- Thai by nostr:npub1tr66yvqghfdgwv9yxhmg7xx6pvgvu5uvdc42tgdhsys8vvzdt8msev06fl and nostr:npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e - -Performance Improvements: -- Switches Robohash to Precompiled SVGs in order to reduce the memory burned of creating Strings with SVGs on the fly -- Restructures Data Access filters and LocalCache to use a ConcurrentSkipList instead of ConcurrentHashMap -- Only download video, image and audio files in NIP-94 -- Updated hashtag icons for performance -- Avoids checks if a filter has changed before generating JSon strings -- Cleans up User Metadata upon receipt instead of in every rendering -- Simpler/Faster UserDisplay renderings -- Reduces video cache from 10 to 4 videos -- Removes coroutine use for Hashtags -- Brings the ZapForwarding icon to Compose -- Simplifies the algorithm to check if chatroom sender intersects with the follow list -- Avoids serializing temporary fields on Quartz -- Refactors views to the video and chat feeds -- Restructures NoteCompose for performance -- Restructures markAsRead to minimize threading cost -- Adds a large benchmark test for duplicate events -- Optimizing the performance of Highlight rendering -- After memory cleanup, only trigger liveData when it actually changes -- Minimizes memory use to calculate zaps -- Avoids triggering an error when decoding invalid hexes -- Reduces the amount of co-routines being launched in each LiveData update -- Migrates channel list and channel notes to LargeCache -- Removes the use of data classes to speed up comparisons -- Improves Nostr filter to bring public chat messages and avoid public chat creation and metadata updates -- Removes jsoup from dependencies -- Removes the need to observe author changes to event after loaded -- Turns hashtag icons into programmable vectors -- Moves the Following Vector to a native composable -- Removes unnecessary modifier layouts from the top bar -- Uses the cached thread pool instead of the scheduled thread pool for translation services -- Avoids launching coroutines that were just launched -- Makes a cache for Media Items -- Only changes the keep playing status if different -- Reduces recompositions after video/image hash verification -- Avoiding feed jitter when pressing the notification button twice - -Code Quality Improvements: -- Breaks massive NoteCompose down into each event type -- Removes the release drafter plugin on actions. Too buggy -- Removes dependency of the Robohash from CryptoUtils -- Improves Preview helper classes -- Updates secp256k1KmpJniAndroid, compose, zoomable, media3, jackson and firebase libs -- Updates AGP to 8.3.1 -- Deletes the old Settings local db -- Refactors some of the old display name structure -- Refactors Relay classes. -- Isolates the LargeCache forEach method to allow quick time measurements on filters -- Reorganizes classes in the commons lib -- Fixes test cases for nip96 uploaders -- Removes unused addMargin param -- Refactoring caching systems for the Compose layer -- Aligns the BOM between implementation and tests -- Refactors the use of dividers out of components -- Refactors composables to load events, check hidden and check report -- Fix Kotlin encryptDecryptTest to decrypt with swapped private and public keys to follow NIP-44 documentation by nostr:npub1yaul8k059377u9lsu67de7y637w4jtgeuwcmh5n7788l6xnlnrgs3tvjmf -- Finishes the migration of People List updates from LiveData to Flow -- Migrates all Refreshable feeds to the Refreshable box component -- Refactors cache methods in GiftWraps -- Migrates Media3 Videos to the DefaultMediaSourceFactory - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.86.0/amethyst-googleplay-universal-v0.86.0.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.86.0/amethyst-fdroid-universal-v0.86.0.apk ) - -[Changes][v0.86.0] - - -<a id="v0.85.3"></a> -# [Release v0.85.3](https://github.com/vitorpamplona/amethyst/releases/tag/v0.85.3) - 2024-03-04 - -- [Displaying issues and Patches in the Notification](https://github.com/vitorpamplona/amethyst/commit/5848255e72fae583057da73a1a2f2d295d4e1d04) -- BugFix: [Cleans up the fork information on new posts has been canceled or posted.](https://github.com/vitorpamplona/amethyst/commit/8299f4cfca41333d3ff212ef723bf86e12e6c080) -- [Displaying correct edits on the new edit proposal](https://github.com/vitorpamplona/amethyst/commit/e9fd62dc26bfa66a59b3da613df522fa21b6a10d) -- [Adds link to the version notes from the drawer](https://github.com/vitorpamplona/amethyst/commit/7bc393143c5e590951f8e499b0caed442d274655) -- [fix crash parsing multiple results from amber](https://github.com/vitorpamplona/amethyst/commit/23718f51dd203020035933af88d54e39c415c215) -- [Adds nostr git for issue management software.](https://github.com/vitorpamplona/amethyst/commit/9081d5a54b9f847e4683de3268681e4a15d2eb4c) -- [Don't show button to edit the post if the author of the original post is not the logged in user](https://github.com/vitorpamplona/amethyst/commit/cd2b5d78a182d386d6197c2040e3b6da53a1aa99) - -[Changes][v0.85.3] - - -<a id="v0.85.1"></a> -# [Release v0.85.1: Medical Data fix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.85.1) - 2024-03-04 - - - -[Changes][v0.85.1] - - -<a id="v0.85.0"></a> -# [Release v0.85.0 Edits, Git, Embed, etc](https://github.com/vitorpamplona/amethyst/releases/tag/v0.85.0) - 2024-03-04 - -Adds support for post edits, post forks, pull requests, open timestamp, git repositories, issues, patches and replies, wiki pages and some medical data. - -New Additions: -- Adds support for editing of notes (NIP-37 / kind: 1010) -- Adds support for sending edit proposals. -- Adds embeded events in NIP-19 uris -- Adds support for NIP-03: OpenTime Stamp attestations (kind: 1040) -- Adds support for Decentralized Wikis (event kind 30818) -- Adds basic support for NIP-34: Git repositories (kind 30617), patches (kind 1617), issues (kind 1621) and replies (kind 1622). -- Adds rendering support for FHIR payloads (kind 82). -- Adds support for the q tag -- Adds early support for Kind1 forks. -- Sets zap splits automatically for quotes and forks -- V4V: Ask for donations in the Notification page -- Adds relay icon rendering from the NIP11 document - -Bug Fixes: -- Fixes the text's vertical alignment when emoji's are present -- Fixes DM Chatroom edit button -- Fixes the crash when images are not being present in the image dialog. -- Inserts uploaded URLs where the cursor is and not at the end of the new post. -- Fixes the rendering of Japanese characters, hashtags and custom emojis in the same line. -- Fixes the dissapearance of some Quartz classes when exporting to maven -- Fixing stack overflow with more than 200 zaps in a single note. -- Fixes image preview visualization on a new post -- Adds support for a new report option as Other -- Fixes missing nsec processing when parsing NIP-29 uris -- Fixes caching issue when creating a Bitcoin invoice for the first time -- Fixes UI issues due to the caching of Polls -- Better aligns post header elements -- Fixes bug with NIP-11s with null `kind` arrays -- Fixes quote and repost notes partially disappearing when they contain hidden users or words. -- Fixes content title for the video playback notification - -Updated translations: -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Spanish, Spanish, Mexico and Spanish, United States by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Swahili by [@npub1q6ps7m94jfdastx2tx76sj8sq4nxdhlsgmzns2tr4xt6ydx6grzspm0kxr](https://github.com/npub1q6ps7m94jfdastx2tx76sj8sq4nxdhlsgmzns2tr4xt6ydx6grzspm0kxr) -- Czech, German, Swedish and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Arabic by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) - -Performance Improvements: -- Improves the speed of the text parser -- Reduced memory footprint of navigation buttons -- Faster hex validation -- Increases the speed of the Robohash SVG to byte buffer function -- Adds Benchmark tests for the content parser -- Adds Benchmark tests for the url detector -- Speeds up image compositions -- Improves relay list NIP-11 caching -- Faster Longform tag filters -- Speeds up the loop through local cache -- Improves the speed of Location services -- Improves the cache of LnInvoices -- Improves cache of cashu tokens -- Reduces memory footprint for parsed posts. - -Code Quality Improvements: -- Moves content parsers and media classes to commons. -- Moves emoji parsers to commons -- Moves Wallet Connect code to Quartz -- Moves Relay information code to Quartz -- Removes dependency on Kotlin serialization -- Adds a release draft generator to CI -- Updates Vico, Compose UI Version, Coil and Google Service dependencies -- Refactors the code to manage extra characters after Bech32 Links -- Moves to Android Studio Iguana | 2023.2.1 -- Moves gradle to 8.4 -- Moves project to version catalogs - -[Changes][v0.85.0] - - -<a id="v0.84.3"></a> -# [Release v0.84.3: Emoji Passwords in NIP49](https://github.com/vitorpamplona/amethyst/releases/tag/v0.84.3) - 2024-02-16 - -#Amethyst v0.84.3: NIP-49 Emoji Password fix - -If you exported your secret key with any composable Unicode characters as passwords (like an emoji), please export it again. - -Bugfixes: -- Counts any quoted post as retweets in the notification stats -- Avoids the need to p-tag the user to count as a mention -- Normalizes passwords to Unicode's NFKC in NIP49 -- Adapts Scrypt lib to support empty keys - -Code Quality Improvements: -- Refactors clickable text and notification feed filter -- Updates secp256k1 - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.3/amethyst-googleplay-universal-v0.84.3.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.3/amethyst-fdroid-universal-v0.84.3.apk ) - -[Changes][v0.84.3] - - -<a id="v0.84.2"></a> -# [Release v0.84.2: Fixes text alignment issues](https://github.com/vitorpamplona/amethyst/releases/tag/v0.84.2) - 2024-02-15 - -#Amethyst v0.84.2: Text alignment fix - -Bugfixes: -- Fixes link misalignment in posts - -Updated translations: -- Czech, German, Swedish, and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-googleplay-universal-v0.84.2.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-fdroid-universal-v0.84.2.apk ) - -[Changes][v0.84.2] - - -<a id="v0.84.1"></a> -# [Release v0.84.1 Support for NIP-49](https://github.com/vitorpamplona/amethyst/releases/tag/v0.84.1) - 2024-02-14 - -#Amethyst v0.84.1: ncryptsec support (NIP-49) - -Now you can export and log in with a password-protected version of your private key. This new format starts with **ncryptsec** and requires inputting a password to decrypt the key before loading it into a client. Keep in mind that the new format is not designed to replace your **nsec**, but to work side-by-side with it. Keep your nsec in the safest place you can and use the **ncryptsec** to move your key between devices, deleting it as soon as you are done with the transfer. - -New Additions: -- Adds support for NIP49 to login and back up key screens -- Adds cryptographic support for NIP-49 to Quartz -- Enables citation on chats via @ -- Adds "₿itcoin" to the set of custom hashtags - -Updated translations: -- Portuguese by [@npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6](https://github.com/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Chinese by [@npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7](https://github.com/npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7) - -Performance Improvements: -- Avoids the memory use of the flatten operation on Notification counters -- Adds a check for the main thread when pulling opengraph tags. -- No need to crossfade when clicking on Show More - -Code Quality Improvements: -- Updates Compose dependencies - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.1/amethyst-googleplay-universal-v0.84.1.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.1/amethyst-fdroid-universal-v0.84.1.apk ) - -[Changes][v0.84.1] - - -<a id="v0.83.13"></a> -# [Release v0.83.13: Community and Public Chats early support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.13) - 2024-02-12 - -New Additions: -- Adds background support for community and public chat list events from NIP-51 -- Removes the confusing notification dot of the discovery tab - -Bugfixes: -- Fixes thread rendering when `mention` events are added without mentioning any event. -- Unwrapps the reply message if the GiftWrap was tagged as a reply instead of the correct message id. -- Fixes Send to Top in the marketplace -- Fixes text-to-voice accessibility issues in the main feed UI -- Inverts the order of the hidden users in the security screen: last blocked goes first -- Fixes crash when mute list has `e` tags that are not valid hexes -- Fixes crash when opening an incorrect `nostr:` uri - -Updated translations: -- Czech, German, Swedish, and Portuguese by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) - -Performance Improvements: -- Moves language translation cleanup to the IO thread since it clears disk files as well -- Small adjustments in the re-use of modifiers - -Code Quality Improvements: -- Small refactoring to focus the encrypted storage procedures to the application context. - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.13/amethyst-googleplay-universal-v0.83.13.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.13/amethyst-fdroid-universal-v0.83.13.apk ) - -[Changes][v0.83.13] - - -<a id="v0.83.12"></a> -# [Release v0.83.12](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.12) - 2024-02-06 - -Performance Improvements: -- Improved scroll performance with faster text parsing tools - -[Changes][v0.83.12] - - -<a id="v0.83.10"></a> -# [Release v0.83.10: Bug Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.10) - 2024-02-05 - -#Amethyst v0.83.10: Bug Fixes - -New Additions: -- Adds Horizontal Scroll to the action buttons in the New Post screen to partially fix hidden buttons in small/thin screens. - -Bugfixes: -- Fixes crash with an invalid custom Zap Amount -- Fixes relay re-connection issues when the relay closes a connection -- Fixes the top padding of the quoted note in a post -- Optimizes memory use of the visual user and url tagger in new posts - -Updated translations: -- Persian by [@npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk](https://github.com/npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk) -- French and English, United Kingdom by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) - -Performance Improvements: - -Code Quality Improvements: - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-fdroid-universal-v0.83.10.apk ) - -[Changes][v0.83.10] - - -<a id="v0.83.9"></a> -# [Release v0.83.9: NIP-92 support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.9) - 2024-02-01 - -#Amethyst v0.83.9: Support for NIP-92 - -New Additions: -- [Includes the product in the first message of the marketplace.](https://github.com/vitorpamplona/amethyst/commit/54155a3c30bbf59849b7f42b0b518a92c0552696) -- [Adds support for NIP-92 in public messages and new DMs. NIP-54 stays in NIP-04 DMs](https://github.com/vitorpamplona/amethyst/commit/e56377f8c3f8e8e53844d1cc56786f3072876e42) - -Updated translations: -- Ukrainian by lizzz -- Spanish, Spanish, Mexico and Spanish, United States by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Arabic by [@npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t](https://github.com/npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t) - -Code Quality Improvements: -- [Updates to Android Studio Hedgehog | 2023.1.1 Patch 2](https://github.com/vitorpamplona/amethyst/commit/34d373c293db368173b66b71bba0c90fe584b389) - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.9/amethyst-googleplay-universal-v0.83.9.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.9/amethyst-fdroid-universal-v0.83.9.apk ) - -[Changes][v0.83.9] - - -<a id="v0.83.8"></a> -# [Release v0.83.8 Bug Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.8) - 2024-01-31 - -#Amethyst v0.83.8: Bug Fixes - -New Additions: -- Removes the need for Amber's package name in the androidManifest for the external signer by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds a longpress to copy url to the url preview card by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds support for always rejected permissions from external signer by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds the exception descripton to the Zap error message. - -Bugfixes: -- Fixes secondary buttons theme in the login and signup screens. -- Fixes vertical misalignment of some npubs in the middle of the note. -- Fixes NPE when accounts are not present when resuming the app in a group -- Fixes missing language options for Greek by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes content sensitivity for NIP-54 images. -- Fixes proxy setup when de/activating Tor / changing ports by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes remember of the wrong hashtag because it's a different post. - -Updated translations: -- Czech, German, Swedish and Portuguese, Brazilian by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Spanish, Mexico and Spanish, United States by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- French by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Serbian by [@npub187h9tymz5j6vhyl26kl74yh6yzqzpjec9806w7taey2zefytlmdsttx7v2](https://github.com/npub187h9tymz5j6vhyl26kl74yh6yzqzpjec9806w7taey2zefytlmdsttx7v2) - -Performance Improvements: -- Makes sure cancellation of coroutines stops long processes. - -Code Quality Improvements: -- Makes the benchmark module profileable -- Updates dependencies - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.8/amethyst-googleplay-universal-v0.83.8.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.8/amethyst-fdroid-universal-v0.83.8.apk ) - -[Changes][v0.83.8] - - -<a id="v0.83.7"></a> -# [Release v0.83.7: New Sign up screen](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.7) - 2024-01-11 - -New Additions: -- [New Signup screen](https://github.com/vitorpamplona/amethyst/commit/7b7e3624acef4f502eb82116098e3fd05f972d6d) -- [Reduces the size of the following icon](https://github.com/vitorpamplona/amethyst/commit/d27b9ae5a84e753b04592daa5b17cacacbd39dca) - -Bugfixes: -- [Fixes the BookmarkScreen update after adding and removing a bookmark to the list](https://github.com/vitorpamplona/amethyst/commit/383d859544d55eae71b41c2be4a4d78b7ebfce11) -- [Solving viewModel creation bug for Public Bookmarks](https://github.com/vitorpamplona/amethyst/commit/81400eb863cdca70ad65f78eeb295e2c3ffd340a) -- [Fixes bug when creating bookmarks for the first time.](https://github.com/vitorpamplona/amethyst/commit/422446f53e15c2ae7e22449d3e4eb4480d8f0769) -- [Fixes bug in checking bookmarks without running the onReady.](https://github.com/vitorpamplona/amethyst/commit/f1e721f32378bd5a9cc512e6801f9dea71235946) -- [Fixes some of the margins in the login screen.](https://github.com/vitorpamplona/amethyst/commit/8f31c60cd1d2c70973ef1e90d89ae7592f652703) - -Updated translations: -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Serbian by [@npub187h9tymz5j6vhyl26kl74yh6yzqzpjec9806w7taey2zefytlmdsttx7v2](https://github.com/npub187h9tymz5j6vhyl26kl74yh6yzqzpjec9806w7taey2zefytlmdsttx7v2) - -Code Quality Improvements: -- [Improves dev previews](https://github.com/vitorpamplona/amethyst/commit/9234f4b7a8f6f069bfa957842da5f5e9b636dbac) -- Updates libraries and build tools on GitHub Actions -- Updates dependencies - -[Changes][v0.83.7] - - -<a id="v0.83.5"></a> -# [Release v0.83.5: flare.pub support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.5) - 2024-01-06 - -BugFix: [Makes sure the relay list contains unique urls.](https://github.com/vitorpamplona/amethyst/commit/6e99e6f4a241be85cb46aab607b47d0f8b513e9e) -[Fixes the lack of relay icons in the NIP44 messages](https://github.com/vitorpamplona/amethyst/commit/0be1f8936829c0009f33a584a816d84b7e49a520) -[Fixing some of the minification issues.](https://github.com/vitorpamplona/amethyst/commit/5c7e9258c1cbf869bfde4c5f054e4a73bbac3cc1) -[Creating the benchmark build type on the modules as well.](https://github.com/vitorpamplona/amethyst/commit/f877b6ff6805b07ce05c447f992a18c8ae9a8e3c) -[Adds spotless](https://github.com/vitorpamplona/amethyst/commit/ec867ae8a2df0f6afdf05a0aa5512280e2011aa7) -[Improves the speed of contains](https://github.com/vitorpamplona/amethyst/commit/620b2bfa9fee9e25649d1ab3e79db93090885a1e) -[Updates dependencies](https://github.com/vitorpamplona/amethyst/commit/4291a58f85789bea412c27f94b21b9c09a223153) -[Fixes the benchmark for Robohash](https://github.com/vitorpamplona/amethyst/commit/00cffc8b6b35f1c8a76168b1cd5a4958a8d997d9) -[Fixes the giftWrap test cases since the migration to v2](https://github.com/vitorpamplona/amethyst/commit/7143d786635f66d83975305449ee700fcc3774ba) -[Fixes the position of the participant list in live streams.](https://github.com/vitorpamplona/amethyst/commit/eaaa9a6446799237878e767386925f29f2b13077) -[Initializing the isHidden state for the note correctly](https://github.com/vitorpamplona/amethyst/commit/89fb83cd9aa3420bd8328350036ae00f24ee3dcc) -[Avoiding the creation of modifiers.](https://github.com/vitorpamplona/amethyst/commit/2355a95202cc43aa7c3a48505a97543bc4e9e30b) -[no need to remember the showProfilePicture state](https://github.com/vitorpamplona/amethyst/commit/53a320b4dcfaf7caecafd7e20ee801ea5b6c0cb3) -[Adds support for displaying video events.](https://github.com/vitorpamplona/amethyst/commit/2de3d19a34b97c012e39b203070d9c1c0b1f0520) -[Refactoring some of the old nomenclature of Kind 1 tags.](https://github.com/vitorpamplona/amethyst/commit/57430c43662a18d9b5764d920cba6c1e8c6eeca6) -[Add write support for NIP-10 deprecated positional tags in text notes to maximize backwards compatibility](https://github.com/vitorpamplona/amethyst/commit/9101fb1e9ebd69f25b2237fe99361fb0f3b42060) - -[Changes][v0.83.5] - - -<a id="v0.83.4"></a> -# [Release v0.83.4: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.4) - 2023-12-27 - -- [Fixes reconnecting issue when DataSources were not active and become active later.](https://github.com/vitorpamplona/amethyst/commit/1cedc8a6c83f4e663e3d5b0a898f461ea687f859) -- [Initial support for separate vertical and horizontal videos.](https://github.com/vitorpamplona/amethyst/commit/4afb0027fb7b8f2ab6e6919b1d836899a00203fe) -- [Adds support for NIP-75 Zap Goal events](https://github.com/vitorpamplona/amethyst/commit/48aa9f950dfab7c2b9556b670efb040204cd982a) -- [Fixing the zap of live streams to go to the Host and not the pubkey in the event.](https://github.com/vitorpamplona/amethyst/commit/ec4981852ba651c643308c3a22f761052bf6e53a) -- [Fixes the loading of reactions and zaps to replaceable events.](https://github.com/vitorpamplona/amethyst/commit/77eb0663627a8afc0e87376a2a2fae4e85a5d14c) -- [Improves the design of the discovery cards for Live Activity and Chats.](https://github.com/vitorpamplona/amethyst/commit/abef3d13f27d283305ea428e5a176eb983c456d7) - -[Changes][v0.83.4] - - -<a id="v0.83.3"></a> -# [Release v0.83.3: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.3) - 2023-12-23 - - - -[Changes][v0.83.3] - - -<a id="v0.83.1"></a> -# [Release v0.83.1 NIP-44v2](https://github.com/vitorpamplona/amethyst/releases/tag/v0.83.1) - 2023-12-22 - -#Amethyst v0.83.1: NIP-44 DMs are up! - -New Features -- Moves DMs to the audited NIP-44v2 -- Adds support for NIP-31 alt tags -- Adds a k-tag to reactions -- Adds i18n for error messages when uploading images - -Performance Improvements: -- Improves the performance of Robohash -- Add less memory-intensive timeAgo calculations and translations -- Uses primitives instead of the wrapped object in several places -- Moves to a less memory-intensive way to write and send filters to the server. -- Refines recompositions of routes and bottom icons -- Avoids the creation of new sets when looping through cached maps of User and Notes -- Avoids recreating the EOSE array when changing filters -- Reuses SessionToken for all Playback connections -- Improving the memory use of concurrent hashmaps and immutable collections -- Reduces the use of remember for fixed UI modifiers - -Bugfixes: -- Detects URL mime-types by pinging the server instead of relying on the url's extension -- Fixes bug with cropped joinToString assemblies of relay filters -- Avoids Concurrent Modification Exception on the EOSE markups -- Forces nip95 to be under 80Kb to make sure relays can receive it -- Fixes bug that error messages wouldn't show an error when uploading images to the reels page -- Fixes post video dimensions when the user has selected not to load videos automatically -- Updates dependencies - -Updated translations: -- Chinese by [@ra5pvt1n](https://github.com/ra5pvt1n) -- Finnish by [@npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj](https://github.com/npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Dutch by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Tamil by [@npub1q6ps7m94jfdastx2tx76sj8sq4nxdhlsgmzns2tr4xt6ydx6grzspm0kxr](https://github.com/npub1q6ps7m94jfdastx2tx76sj8sq4nxdhlsgmzns2tr4xt6ydx6grzspm0kxr) - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.1/amethyst-googleplay-universal-v0.83.1.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.1/amethyst-fdroid-universal-v0.83.1.apk ) - -[Changes][v0.83.1] - - -<a id="v0.82.3"></a> -# [Release v0.82.3: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.82.3) - 2023-12-14 - - - -[Changes][v0.82.3] - - -<a id="v0.82.2"></a> -# [Release v0.82.2: Markdown improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.82.2) - 2023-12-13 - -- Fixes the transition between short preview and full text on markdown -- Adds NIP-44 metatags to markdown rendering. -- Adds background video rendering on markdown -- Performance: Calculates the text width of a space outside a composable. -- Fixes the image preview with uppercase extensions. - -[Changes][v0.82.2] - - -<a id="v0.82.1"></a> -# [Release v0.82.1: NIP-96, NIP-44, Selling](https://github.com/vitorpamplona/amethyst/releases/tag/v0.82.1) - 2023-12-12 - -Adds support for selling and listing in Amethyst as well as NIP-96 Image Servers and NIP-54 inline metadata. Provides large improvemetns to Cashu's token redemption and fixes several bugs. - -https://image.nostr.build/40ae418ccc5336e17b5949bacc11c31835603437816f8bf867c171f07d34dd54.jpg#m=image%2Fjpeg&dim=720x1612&blurhash=%5BLFFgJMyj%5Bt74TMyoft70LxufiV%5B_Nt7f6WB4TogoMj%5Bxut7ofWAS%7EofbFjtD%25xtWBWBs%2BM%7BjbbH&x=c3a3f49c017f58749226f8ae6021c11a745d2354f52a229cb99eef4a9d20ec39 - -- Adds selling: ShopStr's classified creation -- Migrates old image server uploads to NIP-96 -- Adds support for NIP-54 inline metadata -- Adds a Marketplace tab to Discovery -- New Cashu Redeeming card UI. -- Shows the blurhash with a Download icon instead of the URL when the user chooses to not automatically load images/videos -- Improves the video switching flicker from blurhash to video -- Optimizes the rendering of the drawer -- Updates EOSE status in the same thread of the new event to reduce the amount of coroutine launches. -- Uses just one HTTPClient for the entire app -- Adds a User Agent to all HTTP requests. -- Improves Cashu Redeeming UI feedback -- Adds support for the FileServers kind -- Adds relay information for Replaceable events -- Unifies upload options into NIP-94 images -- Improves the rendering of inline metadata -- Uses nostr.wine instead of filter.nostr.wine as a search relay -- Fixes bottom bar appearing in chats when the keyboard is open -- Fixes uploading crash due to malformed video formats -- Fixes crash when image is an SVG and tries to compress -- Fixes deletion of replaceable events -- Fixes hash calculation from the entire payload to only the bytes in the file -- Fixes bug when updating relay list that used keep the previous list -- Presents better error messages when the image upload fails -- Adds a button to Cashu preview to redeem on external wallet by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Fixes zap splits when using amber with intents by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Updates translations for cs/de/sv/pt by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Updates Hungarian translations by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Updates Finnish translations by [@petrikaj](https://github.com/petrikaj) -- Updates Dutch translations by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) -- Updates French translations by [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Updates dependencies - -[Changes][v0.82.1] - - -<a id="v0.81.5"></a> -# [Release v0.81.5](https://github.com/vitorpamplona/amethyst/releases/tag/v0.81.5) - 2023-11-30 - -- [Fixes blocked lists on Videos.](https://github.com/vitorpamplona/amethyst/commit/efb9814f1b28b70a2dfdeb879b4bd89b327fa1c8) -- [Pre-loads profile/mute list information for all the logged in accounts in the app.](https://github.com/vitorpamplona/amethyst/commit/b2b7cba352177e1c409883d437e8b815b08d0519) -- [Fixes bug when creating accounts in the background and trying to create the live set](https://github.com/vitorpamplona/amethyst/commit/47ae42e3fed1a45b940df5938cb052665a33f31f) -- [Makes relay pool coordinator thread-safe. Forcing the disconnect of an old relay list before connecting to a new one.](https://github.com/vitorpamplona/amethyst/commit/5db3ede09e8a17899ed61c36e106e7911fc944bd) -- [Avoids sending filters with empty follow lists on Videos](https://github.com/vitorpamplona/amethyst/commit/5347373cdeb5b8756aa8be0035f23634902fafe8) -- [Forces websocket closure onFailure](https://github.com/vitorpamplona/amethyst/commit/05596cb9bf6a4a06490e6e232dad19df65eb176e) -- [Fixes: emitting an empty follow list if it cannot decrypt it](https://github.com/vitorpamplona/amethyst/commit/b021920eaa1f95fb7f35b3d8f61577e9f4236223) -- [Caches zap calculations in notification cards.](https://github.com/vitorpamplona/amethyst/commit/2b27ac3aec64f5c91bef404921abc8d46e1f3d57) -- [Fixes null list names showing before the list is loaded](https://github.com/vitorpamplona/amethyst/commit/c692c9125b846f5f7caeb073ba10233f3e0ad485) -- [Fixes hidden buttons in the Chat floating button](https://github.com/vitorpamplona/amethyst/commit/7322fd7aae3d51817cd288b9220da4c92f17c37b) -- [Fixes all-or-nothing decryption procedure for Zap lists which were failing in a few cases.](https://github.com/vitorpamplona/amethyst/commit/07a92cd70d0e357da050c5e95e4a9ad1acc6c280) - -[Changes][v0.81.5] - - -<a id="v0.81.3"></a> -# [Release v0.81.3: NIP-88](https://github.com/vitorpamplona/amethyst/releases/tag/v0.81.3) - 2023-11-29 - -#Amethyst v0.81.3: - -- Massive refactoring to unify our internal signer with Amber's signer in all supported events -- Adds [NIP-88](https://github.com/nostr-protocol/nips/pull/901) NOTIFY request support -- Migrates our Block list to kind:10000 -- Fixes the breaking of [@npubs](https://github.com/npubs) when other words are combined with the nostr address -- Adds default encryption and decryption permissions to the Amber login call to avoid multiple Amber screens open at once by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds support for sending/receiving/approving multiple events at once by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds a chat with seller flow to ShopStr's event rendering. -- Reduces the amount of downloads to build the Notification chart of the week -- Immediatly force-closes the WebSocket when leaving the app -- Narrows the re-downloads of event reactions down -- Fixes the blue notification dot appearing when the user receives a notification from a blocked account before downloading the blocklist -- Fixes URL Preview card when websites use a blended version of multiple open graph specs -- Adds a geohash mipmap to event tags -- Reduces multiple reconnections to relays when the app cold starts. -- Adds back arrow button to the top of the Nav bar of the Thread view. -- Fixes race conditions when opening videos at the same time -- Fixes spacing when drawing POW and Geolocation at the same time -- Runs the translation as the UI Scope instead of ViewModel's -- Migrates the event's tag list from List to Array to save some bytes. -- Increases connection timeouts when on mobile data. -- Improves the EOSE logic when creating filters by grouping filter requests with similar `since`clauses -- Fixes video release coroutine being killed by Android, leaving the Video playing in the background -- Adds a cache of the total amount of Zaps per note -- Allows sat amounts up to 4 digits without abbreviation [@vicariousdrama](https://github.com/vicariousdrama) -- Improves the rendering of LN Invoice Previews. -- Fixes error message when parsing LnInvoice -- Updates several dependencies - -Updated translations for: -- Czech, German, Swedish and Portuguese, Brazilian by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Spanish, Mexico and Spanish, United States by [@npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903](https://github.com/npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903) -- Hungarian by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Chinese Simplified by https://crowdin.com/profile/stella2023 -- Persian by [@npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk](https://github.com/npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk) - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.81.3/amethyst-googleplay-universal-v0.81.3.apk ) -- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.81.3/amethyst-fdroid-universal-v0.81.3.apk ) - -[Changes][v0.81.3] - - -<a id="v0.80.7"></a> -# [Release v0.80.7](https://github.com/vitorpamplona/amethyst/releases/tag/v0.80.7) - 2023-11-08 - -- Migrates external sharing service to njump.me by @ @[es09l4ps](https://coracle.social/people/nprofile1qqs05qt95rce97cwj8rasugw2ats45nmxu2u55scrak98jdjqvuhqucprpmhxue69uhkv6tvw3jhytnwdaehgu3wwa5kuef0qy88wumn8ghj7mn0wvhxcmmv9uq3qamnwvaz7tmp9ehx7uewd3hkctc9zwssw) -- Adds support for Greek by @ @[csavastel](https://coracle.social/people/nprofile1qqs8wfdhfs8hvnsg265mngx7vwkvmtg34zglecmqlmc9mzje6cq7sqspzamhxue69uhhyetvv9ujumn0wd68ytnzv9hxgtcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhszynhwden5te0dehhxarjxgcjucm0d5hsky9vyw) -- Adds support for Indonesian by @ @[Yonle](https://coracle.social/people/nprofile1qqsrg73rwzgq6xd5u36kyg2ef69a5ur2uhrcthsfuk0yvp0ergplf8qpzemhxue69uhkymmnw3ezuemvd96xx6pwd4jj7qfqwaehxw309a3x7um5wgh8jmmwd3jjumr9vd682unfveujumn9wshszxrhwden5te0ve5kcar9wghxummnw3ezuamfdejj7wgxqka) and @ @[3ssspdkl](https://coracle.social/people/nprofile1qqsvxj2am3fs0xunr7rj0elhg2xf8utpyfnzzrq2mnlqjtraag8s5ccprpmhxue69uhkv6tvw3jhytnwdaehgu3wwa5kuef0qy88wumn8ghj7mn0wvhxcmmv9uq3qamnwvaz7tmp9ehx7uewd3hkctcjw5pr7) -- Updates Spanish translations by @ @[⚡₿it₿y₿it⚡](https://coracle.social/people/nprofile1qqs07tjpyvvlq9ugdpax8h3jfrpwn7kr72k3tc7ky83tggn4et9eangprpmhxue69uhkv6tvw3jhytnwdaehgu3wwa5kuef0qy88wumn8ghj7mn0wvhxcmmv9uq3qamnwvaz7tmp9ehx7uewd3hkctcw534ek) -- Updates Arabic translations by @ @[fqwvx23t](https://coracle.social/people/nprofile1qqsgs9hgjw87vz36jf2r83m5zree2q87zvs8s7kty9jljdz7wprytysprpmhxue69uhkv6tvw3jhytnwdaehgu3wwa5kuef0qy88wumn8ghj7mn0wvhxcmmv9uq3qamnwvaz7tmp9ehx7uewd3hkctckmawlz) -- Fixes position of video controlling buttons when top bars are present in full screen. -- Fixes bug when the app calls isAcceptable directly, bypassing the other checks in AccountViewModel -- Fixes race condition when pausing and restarting relay connections -- Updates Kotlin compiler version -- Removes a recomposition between the started state and the isOnline state that is already cached. -- Migrates the check if stream is online to a single compose object. -- Forces relay reconnection when a new WIFI service is available -- Fixing translations of the that create the same message but with different character cases -- Refines the layout of Author Pictures for performance -- Refines layout of URL Previews for performance -- Refines the padding of chat messages and reaction row -- Correctly highliting a notification card on touch - -[Changes][v0.80.7] - - -<a id="v0.80.4"></a> -# [Release v0.80.4: Performance Optimizations](https://github.com/vitorpamplona/amethyst/releases/tag/v0.80.4) - 2023-10-30 - -- Starts videos from Main, but in a thread. -- Fixes new line of Loading Animation and Download buttons for images. -- Minimizes Jittering when loading videos. -- Improving rendering of reaction types -- Only updates notification dots once at every 3 seconds. -- New users now follow themselves by default -- Fixes Floating Action Button not showing again after changing screens by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Updates French, Dutch, Hungarian, Czech, German, Portuguese and Swedish translations by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) [@npub1rq9x6sk86e8ccw2cm8gsm4dyz9l24t823elespupaxjnzdk026fsca2r93](https://github.com/npub1rq9x6sk86e8ccw2cm8gsm4dyz9l24t823elespupaxjnzdk026fsca2r93) and [@npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz](https://github.com/npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz) -- Fixing colors of edit buttons -- Login and Logout already in IO threads -- Performance: Stable class review. -- Adds images and posts to notes without an extra line. -- Fixes: Direct replies have "reply" marker instead of "root" marker -- Speeds up the boost count method -- Fixes weird alignment of multi-row post titles from highlighter. -- Fixes the update on Profile Feed when Blocking/Unblocking the user. -- Refines Markdown to match Material3 Style -- Reduces the font size of Subject labels -- Fixes the use of decimals on Notification's chart - -[Changes][v0.80.4] - - -<a id="v0.80.2"></a> -# [Release v0.80.2: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.80.2) - 2023-10-28 - -- [Slight improvement in the performance of Reaction Row's rendering](https://github.com/vitorpamplona/amethyst/commit/2cc9bb8b720ae63716d27d2aa89ba1a3b1a225be) -- [Reducing the size of Subject labels](https://github.com/vitorpamplona/amethyst/commit/553bebb6cf5d362819b3e5c4b17c5b8f9c57d2a5) -- [Refining some of the Markdown to match Material3 Style](https://github.com/vitorpamplona/amethyst/commit/e91e5c366d6d2baaf61eba3df040101bb0a93528) -- [Update Profile Feed when Blocking/Unblocking the user.](https://github.com/vitorpamplona/amethyst/commit/9d32718c1304dd8174bdf22dbb6fc710ae002660) -- [Reverts the New Post button animation due to bugs of not coming back](https://github.com/vitorpamplona/amethyst/commit/64c9505620c55b5085aa3710f88df08b5c072d94) -- [Fixes the use of decimals on Notification's chart](https://github.com/vitorpamplona/amethyst/commit/75dc55858cab5667cd27827b9fd52df1014e38f7) -- [Reverts the hidden note LiveData to make sure the interface doesn't blink with the hidden note that just disappears ms later.](https://github.com/vitorpamplona/amethyst/commit/c6062120434b3c442ff26d0a20ede140837f7179) -- [fix typo in resource string name](https://github.com/vitorpamplona/amethyst/commit/e89384ee6181bc197daa18f0bf2604fc60defdad) [@davotoula](https://github.com/davotoula) -- [Updated translations for cs/de/sv/pt](https://github.com/vitorpamplona/amethyst/commit/bb324776716be5dad4e26f3315b53ee01cd4d076) by [@davotoula](https://github.com/davotoula) - -[Changes][v0.80.2] - - -<a id="v0.80.1"></a> -# [Release v0.80.1: Foss Push Notifications](https://github.com/vitorpamplona/amethyst/releases/tag/v0.80.1) - 2023-10-27 - -- Adds support for Unified Push in the FOSS edition by [@KotlinGeekDev](https://github.com/KotlinGeekDev) -- BugFix for missing push notifications when using Google's edition -- Creates a ViewModel store for each user, which allows faster memory cleanup when switching accounts. -- Fix crash when uploading images/videos using external signer by [@greenart7c3](https://github.com/greenart7c3) -- Adds Lifecycle to all Flow collects in compose to stop processing new events when the app is paused. -- Avoids creation of the LiveData in every recomposition -- Removing Stop with Task from PushNotifications to make sure the PushService remains active. -- Updated translations to Sweden, Portuguese, Czech and German by [@davotoula](https://github.com/davotoula) - -[Changes][v0.80.1] - - -<a id="v0.79.13"></a> -# [Release v0.79.13: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.13) - 2023-10-23 - -[Makes sure only one npub is logged in at the same time](https://github.com/vitorpamplona/amethyst/commit/191254d920ce5a78ed141d1387a2785f551996a8) -[Update UI dependencies](https://github.com/vitorpamplona/amethyst/commit/91bfb60613436abb494d02b10f61da9ee94a2027) -[Making sure the UI update from language is in the Main thread.](https://github.com/vitorpamplona/amethyst/commit/9dfb4d1a1b7aa06b903d477f83b8c9416707443b) -[Solves NPE when the TextToSpeech engine isn't ready.](https://github.com/vitorpamplona/amethyst/commit/f92e13270de30f9a063da3b2a9c9740387e4b5c5) -[Fixes status update field when more than one status is available.](https://github.com/vitorpamplona/amethyst/commit/10a0dc7f8aa103013ddd4363e78e2e2ecefc254f) -[Moves Relay and User Metadata update buttons from Post to Save.](https://github.com/vitorpamplona/amethyst/commit/5b0fc7982b86575ee5cad012fb346de63aaa45b4) -[Slight adjustment on the rendering of hashtags.](https://github.com/vitorpamplona/amethyst/commit/6831349f2fe1b9082309865071014cb17d0e2aea) - -[Changes][v0.79.13] - - -<a id="v0.79.12"></a> -# [Release v0.79.12: Tablet Layouts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.12) - 2023-10-22 - -- Adds a [Tablet view on chats](https://github.com/vitorpamplona/amethyst/commit/80df2eefed1c7cb1174eecacac2c2b632437ba98) -- [TwoPane display for chats in tablets and folds.](https://github.com/vitorpamplona/amethyst/commit/884a124c7e534f18e15ad20c549d5d0b5204f98c) -- [Fixes orientation changes when in full screen](https://github.com/vitorpamplona/amethyst/commit/a284c1b9c665327fa9a80ff521ef11c3e59fbd22) -- [Updates Jsoup](https://github.com/vitorpamplona/amethyst/commit/ccf428eff6bb58145f78ae2a255b63989a512df9) - -[Changes][v0.79.12] - - -<a id="v0.79.10"></a> -# [Release v0.79.10: Full Blacks](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.10) - 2023-10-21 - - - -[Changes][v0.79.10] - - -<a id="v0.79.9"></a> -# [Release v0.79.9: Full screen videos/images](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.9) - 2023-10-20 - -- [Solving one of the OutOfMemory errors (too many translators instantiated at the same time)](https://github.com/vitorpamplona/amethyst/commit/3321448dd29a74ca4d91fc2dd08590dcb12d265e) -- [Fixes the spacing of the account switcher to avoid getting too close to navigation buttons.](https://github.com/vitorpamplona/amethyst/commit/8065b942bd6b1c5f0ff714298ded19de4388a775) -- [Fixes full-screen dialog for videos and images](https://github.com/vitorpamplona/amethyst/commit/17e59c5ae882438c463433154e3222a0bd521bf3) -- [Activates single-tap to double zoom when on full-screen dialog](https://github.com/vitorpamplona/amethyst/commit/39d84e33bb2d829db45768db399071b24afef983) -- [Fixes caching issue of the saved list selection in the top bar on cold starts](https://github.com/vitorpamplona/amethyst/commit/87e8948d9ae4ed1419180697ca01e9ff41cc87d7) -- [Fixes one of connectivity issues that kept the app off-line even though the phone is connected to the internet](https://github.com/vitorpamplona/amethyst/commit/1ab93adc22b69db3c181ebe886fe7c0cf15fd205) -- [fix double encryption when sending dms, when login with external signer set privKey to null to be sure we have no private keys](https://github.com/vitorpamplona/amethyst/commit/800c0b0131cdc4124cdfece23159d852db53b60d) by [@greenart7c3](https://github.com/greenart7c3) - -[Changes][v0.79.9] - - -<a id="v0.79.7"></a> -# [Release v0.79.7](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.7) - 2023-10-18 - -- [Removing bold titles](https://github.com/vitorpamplona/amethyst/commit/27286c7ffeab8239c4925e4a3297c3e9efc47d79) -- fix crash when sending giftwraps with amber -- Moves app startup to an IO thread. -- Upgrades Shared Preferences serialization to a Single JSON object -- Simplifies Shared Preferences state changes -- LazyInitializes Video cache - -[Changes][v0.79.7] - - -<a id="v0.79.6"></a> -# [Release v0.79.6](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.6) - 2023-10-17 - -[Stops PushNotifications when the app is killed.](https://github.com/vitorpamplona/amethyst/commit/35f6ecad59c8ac90478d535003c196dcb732f4f4) -[Dump memory states to debug OOO](https://github.com/vitorpamplona/amethyst/commit/2a328ca12057769a1c8e8913649802aecea627bb) -[Only changes Username if value changes.](https://github.com/vitorpamplona/amethyst/commit/1e83dbfbe356ef050ac34ffdb3ce094b0d6e8181) -[Added #thenostr hashtag icon by @believethehype ](https://github.com/vitorpamplona/amethyst/commit/3ea7b8194d6f86056962e9d4445b96599cb74b2f) -[Activates color for the zaps in the Notification chart](https://github.com/vitorpamplona/amethyst/commit/1a7ae33fa867ef38b511a8193b5a290d7cc82598) -[Removes configuration cache: was creating a lot of build issues](https://github.com/vitorpamplona/amethyst/commit/2bbe126c8d2d5aab8698e093145c2d02d909be77) -[Improving Git hooks](https://github.com/vitorpamplona/amethyst/commit/f2e6efe2b7761429e56797a2d91e758aabfb8074) -[Moves playback services to a package](https://github.com/vitorpamplona/amethyst/commit/404e6cd8627f8a583e085cb051d28db7aa15b16c) - -[Changes][v0.79.6] - - -<a id="v0.79.5"></a> -# [Release v0.79.5](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.5) - 2023-10-17 - -- [Forces a filter reset after authentication for inbox.nostr.wine](https://github.com/vitorpamplona/amethyst/commit/611e8f1a6d75068dcc63809414029ce9fc5627b7) -- [BugFix: Resolves a duplicated entry on relay list of Notes](https://github.com/vitorpamplona/amethyst/commit/c619a9000c58d8ddd3a6f582c5c4a90ea822b1e9) -- [Avoids testing the signature when the id or sig fields are blank](https://github.com/vitorpamplona/amethyst/commit/c29b4b8e5f53fccdc2df245aa599f8c68ab5d09c) -- [Improves the feedback to messages from relays.](https://github.com/vitorpamplona/amethyst/commit/4286b64b415813f2f8e61ed1655668d9d4b2c7d8) -- [add exception handling for parsing geohash by @jiftechnify ](https://github.com/vitorpamplona/amethyst/commit/407807d8711fd2d20f1ffd8b66e413f80bc754ba) -- [Only adds a border to video controls when in full screen](https://github.com/vitorpamplona/amethyst/commit/e1e42ed500f5ea4b38a1efcac1af265dd70245fb) -- [Activates images controllers on click](https://github.com/vitorpamplona/amethyst/commit/1767cc74a978bbfd8329393993a0488990dd99db) -- [Adds a border in the image dialog to avoid overriding controllers.](https://github.com/vitorpamplona/amethyst/commit/4028018605461f9e8f209b993b5557453e3c553d) -- [Image bleeding into separate page bugfix](https://github.com/vitorpamplona/amethyst/commit/33f8b6d6d86912fdc04f28a6cda3aff7367cd167) -- [Update dependencies](https://github.com/vitorpamplona/amethyst/commit/f9fed8a04f6a801e5c4597bb1e024eaedda11bbb) -- [Fixes all the other mute list feeds](https://github.com/vitorpamplona/amethyst/commit/ba4a594a41743ef081f66b53ec7260eec212282d) -- [fix double encryption on nip04 by @greenart7c3 ](https://github.com/vitorpamplona/amethyst/commit/173245d9003fd95c619cca4407b6604d0f2402b1) -- [try to fix messages being encrypted twice by @greenart7c3](https://github.com/vitorpamplona/amethyst/commit/5bc4aab8d809303f64485ded74465d65554901bb) -- [change mute test to equality with string including event 30000 and user hex by @davotoula ](https://github.com/vitorpamplona/amethyst/commit/c7861dfac44a58a8b5d574f1c4eb42d10649df4c) -- [Fixes visibility of the bottom and top bar when the user comes back](https://github.com/vitorpamplona/amethyst/commit/275051ed308b4bfeb960bcf859248424579a1573) -- [Fixes failure to load long form content with d tags reserved url chars](https://github.com/vitorpamplona/amethyst/commit/6748abbf70416e36a99a16ba02f819fe2296c6e1) -- [Fixes bug of not immediately loading chat rooms](https://github.com/vitorpamplona/amethyst/commit/e04a35afed3df7d3572e22d23029c2773bbe591f) -- [Moves the Post button on chat screens to a > Button](https://github.com/vitorpamplona/amethyst/commit/f67ff43eb36fe1fdda2afee70c581fb65d04bb42) -- [Hide's the Video Full Screen dialog buttons together with video](https://github.com/vitorpamplona/amethyst/commit/b8b41f840a326c248dc41b838c0d96cffa5988f1) -- [Fixes the saving of the video position between screens](https://github.com/vitorpamplona/amethyst/commit/e8c9e73985d9ef0c64bd9d19e85846f8cfaa26e0) -- [Updated translations for cs/de/sv](https://github.com/vitorpamplona/amethyst/commit/56c67b9ec6c256c35c2296eb4fee1b457befe462) -- [Moves Following icon from crossfade to animatedVisibility.](https://github.com/vitorpamplona/amethyst/commit/7eddf4a12a525f4522c03ce57504506c77119607) -- [Breaks down Compose components in the Discovery tab.](https://github.com/vitorpamplona/amethyst/commit/cec204b7ae1bf625cd923298cdc8fe9320d4ddb8) -- [Allows Base64 images on profiles.](https://github.com/vitorpamplona/amethyst/commit/188ef3762d7c249a52abf1a1d9baa7c9fff797a0) -- [Fixes bug of messages not reappearing after memory trimming.](https://github.com/vitorpamplona/amethyst/commit/0da031fae4952ca8259b52833fe3cce6e4738e9d) -- [Moves activeOnScreen calculations to a LaunchedEffect](https://github.com/vitorpamplona/amethyst/commit/72dff060d2e295324d0c6161f253e261ab556577) -- [Only changes shouldShow if the value is different.](https://github.com/vitorpamplona/amethyst/commit/d6f4ffafa16517f678eb3623b28e4a935c2222f0) -- [Moves audiothumbnail loader to the viewModel scope](https://github.com/vitorpamplona/amethyst/commit/4b362176990deaa39ff0278da070994d598b1277) - - -[Changes][v0.79.5] - - -<a id="v0.79.2"></a> -# [Release v0.79.2: Performance updates](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.2) - 2023-10-01 - -- Fixes the repeat payment requests on zaps when the NWC is not setup. -- Improves error zap message screens for Material3 -- Uses Lists name/title instead of d-tag on the Top Bar -- Increases the performance of the Bottom bar's Notification dot calculations -- Speeds the composition of the Topbar -- Uses the "host" tag as the creator of the stream. -- Moves check if the stream is online process to the viewModel scope. -- Moves many Toasts to the better designed Information Dialogs. -- Speeds up cold startup. -- Makes sure shared flows don't suspend. -- Updates dependencies - -[Changes][v0.79.2] - - -<a id="v0.79.0"></a> -# [Release v0.79.0 material 3](https://github.com/vitorpamplona/amethyst/releases/tag/v0.79.0) - 2023-09-29 - - - -[Changes][v0.79.0] - - -<a id="v0.78.2"></a> -# [Release v0.78.2: BugFix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.78.2) - 2023-09-26 - -Bug fixes for the playback crashing due to video player calls in the wrong thread - -[Changes][v0.78.2] - - -<a id="v0.78.1"></a> -# [Release v0.78.1: BugFixes and Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.78.1) - 2023-09-25 - -[Updated translations for cs/de/sv](https://github.com/vitorpamplona/amethyst/commit/e1caf40c3aa49c42c1db3b1356be21d223928f1c) -[Updating dependencies](https://github.com/vitorpamplona/amethyst/commit/d966541ea7c954e63e5d1c5e78b650c8a2cc8d2c) -[Forces the presence of the name field in kind 1](https://github.com/vitorpamplona/amethyst/commit/74a2b465084b5af237faf5aa0700a4e7c4186ff0) -[Disable swipe to change page in the hope page](https://github.com/vitorpamplona/amethyst/commit/045699ecd9af273c16b3213e521331025f75fece) -[Performance Improvements to PreviewCards](https://github.com/vitorpamplona/amethyst/commit/35f0b1291fa4bdfd70bfe418a2b94bbc35580094) -[Avoids creating nip94 and nip95 events with blank alts](https://github.com/vitorpamplona/amethyst/commit/f2b6a9bedb61a2c9ccffbabe7d6dd6102c130a70) -[Adds NIP19 parser to the AccountViewModel thread scope](https://github.com/vitorpamplona/amethyst/commit/fc4433e7ae91a05fe206d8b6c1492ced50545986) -[fix contact link in relay information dialog](https://github.com/vitorpamplona/amethyst/commit/b5a0d65f3c37bfdf2d21fc2834ed65a2b24a96c9) -[add notifications for users of amber](https://github.com/vitorpamplona/amethyst/commit/52f600485b16421613c5e3cedb422c6c7de33200) -[moves mark as read to the AccountViewModel scope.](https://github.com/vitorpamplona/amethyst/commit/e89e8e5d010d4d4a9aaea5312aec82e5f0ee6380) -[Fix for not clearing the new post message. It adds a delay to allow the composer to save the viewModel after calling cancel](https://github.com/vitorpamplona/amethyst/commit/af9b0b444c154682497f37100548ad3b63b12ea8) -[Improving the Stability of VideoView](https://github.com/vitorpamplona/amethyst/commit/b1edf4e934fa08dbce8e77be210de81986df75cd) -[only updates reports for a different note](https://github.com/vitorpamplona/amethyst/commit/288d80d1632e687465f1a59685f6d971567ae82b) -[Increasing video cache from 90 to 150MB](https://github.com/vitorpamplona/amethyst/commit/69d7e82b7106e45fbf8fe77c6c4b317cb4c4cade) - -[Changes][v0.78.1] - - -<a id="v0.78.0"></a> -# [Release v0.78.0 Mute words](https://github.com/vitorpamplona/amethyst/releases/tag/v0.78.0) - 2023-09-24 - -- [Mute keywords](https://github.com/vitorpamplona/amethyst/commit/f4da2ae6be21fcda40bfa499d14d67cf65202290) -- [Adds an option in Settings to disable immersive scrolling](https://github.com/vitorpamplona/amethyst/commit/c6c15c3ec7c36f2c6d7ec502e860e4a1bc8c2447) -- [Only show backup keys if account has a private key](https://github.com/vitorpamplona/amethyst/commit/b3f39434eed8b8fb46f75f62780bc66174580514) -- [Reverts to have videos starting from the IO thread](https://github.com/vitorpamplona/amethyst/commit/57dfe3af8c03a3ba3a475060df278f3de26dc1a1) -- BugFix for restarting the relay filters when switching accounts after Amber's integration. - -[Changes][v0.78.0] - - -<a id="v0.77.8"></a> -# [Release v0.77.8 BugFixes and Translation Updates](https://github.com/vitorpamplona/amethyst/releases/tag/v0.77.8) - 2023-09-22 - -- Filters zap payments outside the established min-max parameters on polls. -- Moves Live streaming video from the top bar to the body of the screen to avoid cancelling the video on immersive scrolling -- Reduces padding of custom emojis -- Refactors markdown parser away from the compose Class -- Fixes inconsistencies in memory management with the LifeCycle object and DispableEffect -- Speeds up the display of unused hashtags -- Moves video playback creation to the main thread -- Fixes profile website URLs without schema (http://, https://, etc) -- Updated translations for cs/de/sv by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fix crash when editing account with Amber by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Updates pt-br translations by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Updates to Chinese and Hungarian translations by [@npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7](https://github.com/npub1raspu6ag9kfcw9jz0rz4z693qwmqe5sx6jdhhuvkwz5zy8rygztqnwfhd7) and [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) - -[Changes][v0.77.8] - - -<a id="v0.77.7"></a> -# [Release v0.77.7: Immersive scrolling](https://github.com/vitorpamplona/amethyst/releases/tag/v0.77.7) - 2023-09-21 - -- Animates top and bottom bars out of the way when scrolling by [@greenart7c3](https://github.com/greenart7c3) -- Removes the need for the split payment popup for only one invoice -- Removes the flickering on chat screens when the bottom bar disappears -- Fixes video keeps playing in the background bug when pressing the bottom bar twice -- Moves reaction calls to the viewModelScope -- Protects against crashing on null contact lists -- Migrates alt descriptions of NIP-94 and NIP-95 from .content to alt tag -- Adds a new line limit to the char limit of the Show More button by [@davotoula](https://github.com/davotoula) - -[Changes][v0.77.7] - - -<a id="v0.77.5"></a> -# [Release v0.77.5 Hides Nav and Top Bar when scrolling](https://github.com/vitorpamplona/amethyst/releases/tag/v0.77.5) - 2023-09-21 - - - -[Changes][v0.77.5] - - -<a id="v0.77.3"></a> -# [Release v0.77.3: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.77.3) - 2023-09-20 - - - -[Changes][v0.77.3] - - -<a id="v0.77.1"></a> -# [Release v0.77.1: Communities, hashtags, and geotags in the Top Bar list](https://github.com/vitorpamplona/amethyst/releases/tag/v0.77.1) - 2023-09-20 - -- Adds communities, hashtags, and geohashes to the lists on the top navigation bar. -- New Repost profile picture arrangement from [@npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx](https://github.com/npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx) -- Adds a Zap Split view to the master node in the Thread View -- Change nav bar color by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Removes the old user migration from the old preferences database -- Sets up default reactions to be Rocket, Hugs, Watching and Laughing -- Starting a Refactoring of LocalCache away from a Singleton instance -- Fixes blank summaries occupying empty spaces in Long Form content -- Improvements to the rendering of long-form content -- Groups notifications by day first to avoid merging multi-day notification events -- Performance improvements on loading users and calculating reports -- Disable buttons if terms are not accepted in the Login Screen with Amber by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Fixes a crash when login with Amber by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Updates Bengali and Dutch translations -- Removes the follow/unfollow button from the Zap to User view -- Performance improvement by moving zap decryption to run in a group, avoiding multiple co-routines per zap -- Updates Google Play services - -[Changes][v0.77.1] - - -<a id="v0.77.0"></a> -# [Release v0.77.0: Amber support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.77.0) - 2023-09-18 - -Merging Amber branch - -[Changes][v0.77.0] - - -<a id="v0.76.2"></a> -# [Release v0.76.2: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.76.2) - 2023-09-18 - -- [Removes the ZapSplit display when the weights are zero or were incorrectly created](https://github.com/vitorpamplona/amethyst/commit/83be43e94efdbe44adac188a8d624520ae9fec14) -- [Adds NIP24 Kind1 create function](https://github.com/vitorpamplona/amethyst/commit/4d1a99d0769d6ebe4550590ecaf01d30f96d0146) -- [AnimatedVisibility seems faster than Crossfade](https://github.com/vitorpamplona/amethyst/commit/b1debd98796a871b6f4e741e9eb494f2f8f47ba9) -- [Makes sure the account is writeable before signing Auth for notifications](https://github.com/vitorpamplona/amethyst/commit/3843917bd1300f89bc5a3b37ff9cc282264b0a37) -- Bengali, French, and Dutch translation updates. - -[Changes][v0.76.2] - - -<a id="v0.76.1"></a> -# [Release v0.76.1 Zap Splits](https://github.com/vitorpamplona/amethyst/releases/tag/v0.76.1) - 2023-09-15 - -Adds support for Zap Split setup and payment via NWC and manually via wallet calls. - -- Adds zap split setup -- Adds zap split display -- Adds zap split payment -- Fixes bug when verifying the checksum of LNURLs (bech32) in uppercase -- Allows playback to save any position in real-time and not only after 5 seconds -- Removes potential bugs when the list contains an e tag with an a reference -- Moves deletion and report to be the last item on the dropdown menu -- Moves broadcast up in the dropdown menu -- Updating dependencies -- Updated translations for cs/de/sv -- Forces valid hexes in p- and e- tags when receiving the event -- Stops accepting space as a Valid hex char and requires an even number of chars (padding) -- Forces the ZapRequest to exist when processing the LnZapEvent -- Fixes some long NIP-19 cutting off in notes -- Updates to Finnish, Swedish, Czech and German translations -- Adds Bengali, Bangladesh translations -- Adds Italian Translations -- Updates Chinese Translations - -[Changes][v0.76.1] - - -<a id="v0.76.0"></a> -# [Release v0.76.0: GiftWrapped Push Notifications](https://github.com/vitorpamplona/amethyst/releases/tag/v0.76.0) - 2023-09-08 - -- Migrates to Encrypted Push Notifications -- BugFix with video thumbnails failing to load when using the Android's new Photo Picker -- Improvements to the relay settings UI. -- BugFix for invalid LNURLs highlighting as a link -- Uses the correct observer for the picture profile in the Top bar. -- Adds nostr.wine and noswhere.com as two new NIP-50 relays in the default list. -- Adds SeenOn relays when successfully broadcasting a note. -- Refines relay icon row compose -- Fixes the missing channel header for Live Activity replies in Conversations. -- Upgrades the RelayPoolStatus to a Flow -- Avoids race condition when updating EOSEs -- Adds arm64-v8a and armeabi-v7a build target -- Adds Indonesian translation -- Updates Czech, German, Swahili, Chinese, and Italian translations - -[Changes][v0.76.0] - - -<a id="v0.75.14"></a> -# [Release v0.75.14: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.14) - 2023-09-02 - -- [Fixes thread ordering issues with two notes with the same idHex](https://github.com/vitorpamplona/amethyst/commit/d13e8bacec4eff952a058eeea0ff9e277c96ec35) -- [Improves error message from LN Invoice servers.](https://github.com/vitorpamplona/amethyst/commit/a9ea9ea2aeac5966ce51db035611c1e34f3c9244) -- Error msg grammar adjustment -- [Adds UI Feedback for Custom Zaps](https://github.com/vitorpamplona/amethyst/commit/734dd2e119cc60c968085220040c572efdc93e3d) -- [Remeber should be based on the Note](https://github.com/vitorpamplona/amethyst/commit/d3fa05a4dfd2559aba0ee4fb1f067b635a97fa16) - -[Changes][v0.75.14] - - -<a id="v0.75.13"></a> -# [Release v0.75.13](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.13) - 2023-09-01 - -- Ignores brb.io if in the relay list to reduce crashes at the WebSocket protocol level -- Only trigger notifications for events within 5minutes from the current time (avoids old notifications when rebroadcasting events) -- Identifies which relays are using zlib compression -- Doesn't notify if the GiftWrap's inner message came from the logged-in user. -- Fixes reply order for two replies made at the exact same second -- Fixes unit tests of thread ordering - -[Changes][v0.75.13] - - -<a id="v0.75.12"></a> -# [Release v0.75.12: Global Feed bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.12) - 2023-08-31 - -- Fixes global feed -- Removes LiveData Redirections - -[Changes][v0.75.12] - - -<a id="v0.75.11"></a> -# [Release v0.75.11: New Thread sorting scheme](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.11) - 2023-08-31 - -Replies on each thread level are now ordered by: -1. Parent-level reply author, ascending order by date. -2. My replies, descending by date. -3. My follow's replies, descending by date. -4. Everybody else, descending by date. - -- Removes Zaps without a valid ZapRequest -- Removes unnecessary refresh checks since the dual LiveData structure refactoring -- Fixes wrong EOSE updates. -- Requires minimum 10 sat zaps to trigger notifications -- New Crowdin translations by GitHub Action -- Fixes the grammar in the nip05 error messages -- Improve cohesiveness of the pruning system. -- Reduce the padding space between display name and status in the left drawer -- Reducing the height of the banner in the drawer in the left drawer -- Fixes the report card when the author has been blocked by the loggedin user but the feed has been loaded before the block user list. -- Marks keepScreenOn to false when leaving the video player without pausing the video - -[Changes][v0.75.11] - - -<a id="v0.75.10"></a> -# [Release v0.75.10](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.10) - 2023-08-29 - -- Fixes app crash when creating note with @ and any url on Samsungs -- Fixes link highligthing off when composing a message with @ and urls at the same time -- Refines zap and report memory pruning procedures -- Fix the mis-deletion of reports -- Lots of new translations from Crowdin collaborators (Thank you all!) - -[Changes][v0.75.10] - - -<a id="v0.75.8"></a> -# [Release v0.75.8: Status Rotation bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.8) - 2023-08-27 - -- [clears the index cache when changing status fields.](https://github.com/vitorpamplona/amethyst/commit/6c54082a125b3059b0e7dd7bdb4ea9c9bd79c8a0) -- [Do a warning log and not an error log for verification fails](https://github.com/vitorpamplona/amethyst/commit/e1738a25a1244ee81d652a777941a4b72a454bd7) - -[Changes][v0.75.8] - - -<a id="v0.75.7"></a> -# [Release v0.75.7: Quick Fix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.7) - 2023-08-27 - -- Fixes for a crash when receveing a new notification before other logged in accounts get loaded in memory - -[Changes][v0.75.7] - - -<a id="v0.75.6"></a> -# [Release v0.75.6: Improved Error Handling for Zaps](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.6) - 2023-08-27 - -- Better error handling screens when Zaps fail -- Fixes an app crash when a user has more than 1 status -- Fixes All Follows filter for Live Activities. -- Additional Hungarian translation by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) -- Restructures status filters to minimize duplications. -- Adds polls to the hashtag screen -- Improves User filters by merging lastEOSEs into a single filter -- Faster crossfade animation between navigation screens -- Update French translations by [@anthony-robin](https://github.com/anthony-robin) -- Separates observer cleanup from memory pruning on the app pause -- Migrates to a double-layer observer structure for the memory cache -- Removes prefix filters -- Restructures channel data sources to avoid rebuilding the channel object - -[Changes][v0.75.6] - - -<a id="v0.75.3"></a> -# [Release v0.75.3: 1 sat 1 vote polls](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.3) - 2023-08-25 - -- Activates 1 sat, 1 vote (min/max zap values) for polls -- BugFix for deleting statuses -- Moves isAcceptableNote calculations to viewModel -- Moves coroutine of NIP-05 verification to the viewModel -- Moves zap amount/deryption calculations to a viewModel -- Moves tallie calculations to a viewModel -- Adds a Toast message when no wallet is found to pay the invoice -- BugFix: starts the wallet app in a separate task to allow people to come back to Amethyst by switching apps instead of the back button -- Reduces the icon to the music and adds space before the status -- Adds the keyboard done action to be used as Post in the status field -- BugFix: Moves the User filter invalidation to the right channel -- Updates some dependencies -- BugFix: Adds some padding between the status and the clickable icon -- BugFix: for reloading events that have been deleted crashing the app -- Improves blinking issues when loading a thread. -- Fixes r-tag search in statuses -- BugFix for serialization of the per-relay since attributes in the event filter - -[Changes][v0.75.3] - - -<a id="v0.75.2"></a> -# [Release v0.75.2: BugFix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.2) - 2023-08-24 - -Removes amethyst.social test code when creating new status updates - -[Changes][v0.75.2] - - -<a id="v0.75.1"></a> -# [Release v0.75.1: Status updates](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.1) - 2023-08-24 - -https://cdn.nostr.build/i/c56a49811acd3a79a2b9b27d61c867d7132601157d6569f3d4d580446127d212.jpg - -- Adds support for NIP-315: Status Updates -- Adds support for NIP-40: Expiration Timestamp, prunes events accordingly -- Only renders status if the nip05 checks pass -- Updates status on the Drawer -- Reduces font size for statuses and nip05s -- Animates a rotation of statuses if more than 1 is found -- Displays clickable links in the status row if they are present. -- Moves Manifest to target Android API 34 -- Moves livedata creation to long-lived objects - -[Changes][v0.75.1] - - -<a id="v0.75.0"></a> -# [Release v0.75.0 Replies and Boosts in NIP94/95, Lib Updates](https://github.com/vitorpamplona/amethyst/releases/tag/v0.75.0) - 2023-08-23 - -- Separates NIP24 implementation into the encryption NIP 44 and the messaging NIP 24 -- Migrates to Compose 1.5.0, Updates Gradle plugin, Compose navigation, exoplayer, and vico dependencies to the latest. -- Saves some Notification Tab loading time by adding a check if a notification was already asked and rejected -- BugFix on bundled update dispatchers being on the wrong thread -- Faster daily reaction row compositions in the Notification tab -- Protects contact lists from pruning of all accounts in the device. -- Prunes events that are not from or cite one of the logged-in accounts -- BugFix Force Relay Reconnection when broadcasting new events. -- Small refactoring to create the PrivateZap Event for Quartz -- Activates pull to refresh on the Stories feed. -- Base support for Calendar events -- Migrates Addressable Events to a Base class on Quartz -- Refactors replaceable Event consumers into a single main function -- Activates pruning of old replaceable versions -- Adds replies and boosts buttons to Stories -- Adds Thai translation [@npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e](https://github.com/npub1vm0kq43djwdd4psjgdjgn9z6fm836c35dv7eg7x74z3n3ueq83jqhkxp8e) -- Removes Zap events from search. -- Corrects imePadding of the NewPostView -- Fixes quotes and boost previews for NIP94 and NIP95 content - -[Changes][v0.75.0] - - -<a id="v0.74.5"></a> -# [Release v0.74.5: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.74.5) - 2023-08-21 - -- Fixes Thread's search for root to go backwards in the etag-stack. -- Fixes TopBar's and extended header's padding issues. -- Fixes the loading of GiftWraps URIs as intents in the app. -- Fixes a crash when not using play services with notifications by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) - -[Changes][v0.74.5] - - -<a id="v0.74.4"></a> -# [Release v0.74.4: Coroutine Adjustments.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.74.4) - 2023-08-21 - -- Fixes TopBar's and extended header's padding issues. -- Adds user info to the header of single-user chat rooms -- Breaks User + Created At UI line into two -- Moves community description and rules texts to a single Translateable text -- Fixes the copy to clipboard note ID of a GiftWrap message -- Fixes the feed refresh after adding an image on stories -- Fixes the divider presence in the Discover and Notification tabs -- Filter follow lists with the entire list of participants for Live Activities -- Uses KIND3 follows for the discovery tab notification dot -- Fixes issue with unfollowing hashtags written in a different case -- Minimizes the creation of new coroutine scopes -- Moves coroutines of BundledUpdate and BundledInsert to a managed model. -- Broadcasts all replied, quoted, and mentioned events together with the main event. - -[Changes][v0.74.4] - - -<a id="v0.74.3"></a> -# [Release v0.74.3: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.74.3) - 2023-08-20 - -Adds Copy URL and Copy Note ID popups for images and videos for Gigi -BugFix for empty global feeds -BugFix to re-broadcast GiftWraps -BugFix to include zap events signed by the user's main key in the summary chart -BugFix for Playback services taking too long to start on Samsung phones -Updates media control dependencies to the latest version - -[Changes][v0.74.3] - - -<a id="v0.74.2"></a> -# [Release v0.74.2: JSON Parser migration, UI Improvements, StemStr, new modules](https://github.com/vitorpamplona/amethyst/releases/tag/v0.74.2) - 2023-08-19 - -- Adds support for Stemstr's kind 1808 -- Adds a waveform visual to audio playback when available -- Creates a Quartz Module for Nostr Events -- Creates a Benchmark Module to test Performance -- Migrates from GSon to Jackson for Performance gains, adapts all serializers accordingly -- Automatically activates NIP24 chats when users have received NIP24 messages from the counterparty -- Implements contextual rounded corners for images and videos -- Caches NIP-44 shared key for performance. -- Improves BechUtils encoding performance. -- Recreates Hex encoding/decoding classes for Performance. -- Migrates NIP24 to the new ByteArray concat encoding. -- Fixes Zap Notifications when the Zap payer is the logged-in user -- Fixes ThreadAssembler when there are two roots to a conversation branch -- Fixes the color of the historical chart to follow the chosen theme in settings -- Fixes chatroom names when clients send the same user twice in the p-tags. -- Removes support for lenient choices in the. events. -- Refactors TLV's, Events, and NIP-19 dependencies. -- Adds a large set of events as a test case for signature validation -- Adds new translations for cs/de/se by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Fixes url regex pattern for URLs with a dash by [@npub1l60d6h2uvdwa9yq0r7r2suhgrnsadcst6nsx2j03xwhxhu2cjyascejxe5](https://github.com/npub1l60d6h2uvdwa9yq0r7r2suhgrnsadcst6nsx2j03xwhxhu2cjyascejxe5) -- Fixes image uploading tests without an account -- Adds account info to image uploads test suites -- Moves navigation's top bar to use the Arrow Back UI Concept -- Trims display names when possible -- Don't display the username if the display name is available. -- Moves Relay List to a composable surface -- Migrates the use of pubkey prefixes in filters from 6 to 8 because more relays seem to work with this -- Adds read support for NIP-65 -- Adds UI Improvements to the Settings Interface -- BugFix: Highlight event builder using the wrong kind -- BugFix: Avoids displaying a NIP-94 event without a url tag -- Updates SDK and dependencies to the latest - -[Changes][v0.74.2] - - -<a id="v0.73.3"></a> -# [Release v0.73.3: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.73.3) - 2023-08-13 - -- BugFix for the blank screen when pressing the message button on a User Profile without any history of Chats -- BugFix for the crashing route when clicking in the GiftWrap DM Notification -- BugFix: Correctly updates Hashtags on a note after a new follow/unfollow -- Moves Zap Amount calculator from Compose scope to ViewModel Scope -- Avoids account state recompositions -- Moves Show And Hide functions from the Compose Scope to ViewModel Scope -- Moves the creation of the TopBar live data for lists into the AccountViewModel -- Moves HTTP and EMAIL regex compilation to a singleton - -[Changes][v0.73.3] - - -<a id="v0.73.2"></a> -# [Release v0.73.2: PushNotifications for GiftWraps](https://github.com/vitorpamplona/amethyst/releases/tag/v0.73.2) - 2023-08-12 - -- Adds PushNotification for all GiftWrapped Events -- Fix: Marking All GiftWraped DMs as read -- Fix: Displays GiftWraped DM as Messages in the Notification Screen -- Fix: Menu drawer half open when switching to landscape mode by [@npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k](https://github.com/npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k) -- Fix: Forces the creation of Notification Channels to enable toggles in the Settings of Samsung phones -- Fix: Correctly displaying/hiding pictures in Chat Compose - -[Changes][v0.73.2] - - -<a id="v0.73.1"></a> -# [Release v0.73.1: Private DMs and Group Chats](https://github.com/vitorpamplona/amethyst/releases/tag/v0.73.1) - 2023-08-12 - -This is an **alpha** release of the new GiftWrapped DMs. Please do not consider anything to be private until we have a stable version. This is early and there might be bugs that leak information. - -This implementation is very similar to how Slack manages direct DMs to multiple users. If three users are having a conversation and want to add a forth person, the forth's user will not see the past. This guarantees maximum privacy: only the receivers of a message at the time of writting will ever be able to decrypt it. - -The claims of this new method are: -- Messages are encrypted with a superior XChaCha algorithm to each participant's public key individually. -- Chat participant identities, each message's real date and time, event kinds, and other tags are all hidden from the public. -- Senders and receivers cannot be linked with public information alone. -- Minimal trust in counterparties: Counterparties cannot expose verifiable details of your message, including the metadata, without exposing their entire user and all of their other messages (private key) -- There is no central queue, channel or otherwise converging event id to link or count all messages in the same group. -- There is no moderation role (i.e. no group admins, no invitations or bans) -- There is no chatroom secret that can leak or be mistakently shared -- Messages can be fully recoverable in any client (that implements NIP-24) with the receiver or the sender's private key -- The protocol's messages can flow through public relays without loss of privacy. Private relays can increase privacy further, but they are not needed. -- The protocol is extensible to make any other event kind fully private (private likes, private reports, private long-form content, etc) - -In the near future, we will implement Forward Secrecy -- Users will be able to opt-in for "Disappearing Messages" that are not recoverable with their private key -- Users will be able to also opt-in to sharing messages with a new key exclusive for DM backup and recovery. - -You can activate this mode by clicking in the Incognito icon on the Chat screen. For now, only Amethyst supports this NIP. Thus we recommend only testing with other Amethyst users. Coracle and 0xChat are finishing their implementations in the upcoming days/weeks. - -- Support for NIP-24 Private Messages and Small Groups -- Support for NIP-59 Gift Wraps & Seals -- Support for NIP-44 Versioned Encrypted Payloads -- Support for XChaCha encryption algorithm -- Fix: Loading of Alby's NWC URI -- Fix: Only requests notification permission once. -- Fix: Show reposts and reactions in search -- Fix: Signed byte used for array slice inside the TLV by [@npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams](https://github.com/npub1xpuz4qerklyck9evtg40wgrthq5rce2mumwuuygnxcg6q02lz9ms275ams) -- Fix: Global feed only shows events from Global-active relays by [@npub10npj3gydmv40m70ehemmal6vsdyfl7tewgvz043g54p0x23y0s8qzztl5h](https://github.com/npub10npj3gydmv40m70ehemmal6vsdyfl7tewgvz043g54p0x23y0s8qzztl5h) -- Updates Dutch translations by [@npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd](https://github.com/npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd) - -[Changes][v0.73.1] - - -<a id="v0.72.2"></a> -# [Release v0.72.2 Small Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.72.2) - 2023-08-05 - -[TimeAgo refactoring](https://github.com/vitorpamplona/amethyst/commit/c2b72f1e45f5f28a8b2ef2e9a043581eb617e62e) -[Puts marked as read on chat to an IO thread](https://github.com/vitorpamplona/amethyst/commit/28e6471adeef567bbbe93716bc17b946cc7d87eb) -[Puts an invalidate data on an IO thread](https://github.com/vitorpamplona/amethyst/commit/c3b8eb508724365cc6484530f30334408b394b9b) -[simple refactoring](https://github.com/vitorpamplona/amethyst/commit/75779e7ddc8dc74315b88ca115be7e94f66764b2) -[Don't invalidate data on channels if it is already invalidating](https://github.com/vitorpamplona/amethyst/commit/118bc7b73c30d25258d334b7e0956a77e9f13c0b) -[Removes unnecessary signing step](https://github.com/vitorpamplona/amethyst/commit/e6a0fdedd9bd78fb3e1057d687f7454cd6d78ea4) -[Don't push to invalidate unless there is an observer.](https://github.com/vitorpamplona/amethyst/commit/28098fafd7c3403bd85414ef70ab59d7b48d3aa6) -[Moves headers to the top nav bar](https://github.com/vitorpamplona/amethyst/commit/0c0e87af21617939933992d17af728f23c09d998) -[Removes the need for .0 when on base sats.](https://github.com/vitorpamplona/amethyst/commit/8abfd7149b62e2c1f9c68adc76fedacffde97331) -[Moves coroutine creation to the viewModel](https://github.com/vitorpamplona/amethyst/commit/674896cea468abe1c9ba24a3b7646b5da70c75c5) -[Makes the New Channel creation scrollable.](https://github.com/vitorpamplona/amethyst/commit/ec514651fcca8678462cf4bcf4afe8b8f7014ee1) -[updates github action to support minified versions of the app](https://github.com/vitorpamplona/amethyst/commit/8071d48911d452364b8ad620e243d2ad94a02b86) - -[Changes][v0.72.2] - - -<a id="v0.72.1"></a> -# [Release v0.72.1: Caching Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.72.1) - 2023-08-04 - -- Expanded disk cache for images -- Moves NIP95 cache to internal/secure folders -- Fixes the interference between video and image caches -- Adds a cache forever tag to robohashes -- Moves URL Previews into suspend functions -- Adds a little border for Long Form Content -- Updated image upload API for nostr.build to v2 by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Added support for NIP98 for nostr.build. Users with an account at nostr.build will upload files to their personal gallery through amethyst now by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Updated French translations by [@anthony-robin](https://github.com/anthony-robin) -- Fixes new lines after images -- Fixes URL links containing "-" -- Displaying a bytecounter for events in memory when the app pauses. -- Slighly Faster picture galleries -- Avoids null image url errors that might be delaying some of the rendering. -- Uses Lists instead of Sets to reduce memory consumption of pointers. -- Moves onCreate intent evaluation to the App Navigation -- Moves Geolocation search to the IO thread -- Avoids using String.format due to an inner Synchronized block -- Uses a faster method to generate the hex of a url -- Moves to a minified release. - -[Changes][v0.72.1] - - -<a id="v0.72.0"></a> -# [Release v0.72.0 New Memory Management](https://github.com/vitorpamplona/amethyst/releases/tag/v0.72.0) - 2023-08-01 - -- Adds aggressive memory management to avoid Out of Memory -- Fixes the App losing the back stack of screens -- Interns event strings to avoid duplicating memory with hashes and pubkeys -- Improves search by looking into all tags of all events -- Improves Tamil Translations by @ -- Adds missing translations cs/de/se by [@npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef](https://github.com/npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef) -- Adds cryptographic and base event support for NIP-24 (GiftWraps and Sealed Gossip) -- Increases confidence requirements for the Translator -- Refactors the event creation into a Factory -- Adds new kinds to the hashtag and geotag search feeds -- Fixes the explainer text of the geohash addon -- Updates to the latest libraries -- Removes leakcanary due to irrelevance -- Cleans up unused values in strings -- Fix: Ignores past version of addressable content in feeds -- Fix: Avoids showing community definition types in the community feed. -- Fix: Avoids downloading 1000s of Nostr Connect events that some clients are generating. - -[Changes][v0.72.0] - - -<a id="v0.71.0"></a> -# [Release v0.71.0 Geohash](https://github.com/vitorpamplona/amethyst/releases/tag/v0.71.0) - 2023-07-25 - -- Adds support for the `g` tag with a precision of 5x5km. -- Adds support for the following locations -- Adds support for multiple locations in lists -- Refactors New Post Buttons to make them more similar to one another -- Improves Translation-skip Indexes -- Reviews closing of response.body calls -- Refactors unused elements in VideoView -- Refactors Relay class to remove the Synchronized block -- Improves reachability of the relay screen when the keyboard is visible -- Fixes memory leaks when playback services are destroyed -- Fixes video release leak when double clicking on the Stories tab -- Fixes a crash when the video playback service isn't ready - -[Changes][v0.71.0] - - -<a id="v0.70.8"></a> -# [Release v0.70.8: Permission Request test](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.8) - 2023-07-24 - -- Adds notification permissions request for Android Tiramisu - -[Changes][v0.70.8] - - -<a id="v0.70.7"></a> -# [Release v0.70.7: Coding is hard](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.7) - 2023-07-24 - -- Bug fix for encrypting new private messages -- Bug fix for the back button going back in the stack instead of leaving the app - -[Changes][v0.70.7] - - -<a id="v0.70.6"></a> -# [Release v0.70.6: Refactoring and improvements (Don't use this version)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.6) - 2023-07-24 - -- Fixes a crash when onNewIntent is called before onCreate -- New Post, Relay Choice: Select/Deselect all options by [@greenart7c3](https://github.com/greenart7c3) -- New Post, Relay Choice: Fixes missing switch when url is too long by [@greenart7c3](https://github.com/greenart7c3) -- Adds missing OptIn when using GlobalScope by [@greenart7c3](https://github.com/greenart7c3) -- Refactors Crypto/Hex/Bech classes and dependencies -- Simplifies relay connection status -- Moves to OkHttpClient on URL Preview Queries -- Moves to OkHttpClient on Image uploads -- Refactoring of the Connectivity Settings -- Avoids crossfading animations when loading NIP94 and NIP95 content -- Moves playback service startup to the IO Thread -- Activates Strict mode in debug -- Updates Firebase version -- Updates Hungarian translations by [@ZsZolee](https://github.com/ZsZolee) - -[Changes][v0.70.6] - - -<a id="v0.70.5"></a> -# [Release v0.70.5: UI Bugfixes and improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.5) - 2023-07-22 - -[Releases the video player on Mutex when destroying the app.](https://github.com/vitorpamplona/amethyst/commit/8a12cc3cce58346773f9cd5e1643258b8db14355) -[Releases the Mutex when stopping the feed.](https://github.com/vitorpamplona/amethyst/commit/7e03870c0be640a5b5dc48855826c5c8feb41ee4) -[Fixes lightning colors](https://github.com/vitorpamplona/amethyst/commit/a6f56416e3f3394401c4221175d9001ba4896774) -[Fixes color of the NIP05 address in the profile](https://github.com/vitorpamplona/amethyst/commit/efe7772135dbb318508da0bc04fff26f8e8c2816) -[Removes extra padding in the video of a live activity mention](https://github.com/vitorpamplona/amethyst/commit/15c96ce00728afd5c7dc8410cd66c697354366bd) - -[Changes][v0.70.5] - - -<a id="v0.70.4"></a> -# [Release v0.70.4: Wakelock Leak](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.4) - 2023-07-21 - -[Moves wake lock to activate only when the video is actually playing](https://github.com/vitorpamplona/amethyst/commit/2664292993e731012ec351a823cd1203aafbaff5) - -[Changes][v0.70.4] - - -<a id="v0.70.3"></a> -# [Release v0.70.3: Bugfixes and improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.3) - 2023-07-21 - -- Fix KeepPlayingButton color in light theme by [@greenart7c3](https://github.com/greenart7c3) -- Adds a space when rendering inline images and url previews for [@npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx](https://github.com/npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx) -- [Moves the loading of an Accounts backup contacts to the IO Thread](https://github.com/vitorpamplona/amethyst/commit/5cdceb51942a8ecc8497c1a2e841ba411ba22fd6) -- [Adjustments to Modifiers in the Chatroom screen](https://github.com/vitorpamplona/amethyst/commit/2c82e6c44731b139fca52bc403519e7bcbecf751) -- [Refactors ChatroomHeader compose](https://github.com/vitorpamplona/amethyst/commit/9a517380a0447223c88346e28dffc23983b5c48b) - -[Changes][v0.70.3] - - -<a id="v0.70.2"></a> -# [Release v0.70.2: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.2) - 2023-07-21 - -- Updated se/de/cs translations by [@davotoula](https://github.com/davotoula) -- Moves the video caching service initialization foreground services. -- Fix boosted notes from blocked users appearing as blank by [@greenart7c3](https://github.com/greenart7c3) - -[Changes][v0.70.2] - - -<a id="v0.70.1"></a> -# [Release v0.70.1: BugFix for F-Droid](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.1) - 2023-07-21 - -- Restructures the spacing in the first and second rows of a post -- Fixes Wake-Lock permission line that crashes on the F-droid version - -[Changes][v0.70.1] - - -<a id="v0.70.0"></a> -# [Release v0.70.0: Background Playback](https://github.com/vitorpamplona/amethyst/releases/tag/v0.70.0) - 2023-07-20 - -- Moves Video/Audio player to a foreground service. -- Migrates Feed, Stories, and Live Stream screens to use that service -- Blocks screen from going to sleep if a video is playing. -- Blocks WIFI from going to sleep if an online video is playing. -- Allows the app to pause while listening to media and continue playing -- Manages cache for up to 30 videos in parallel for each of the 3 categories: local, streaming, progressive content -- Activates the use of popups with artwork that points to the screen with the video -- Creates a button to allow any video to play while browsing the app/phone -- Moves app to SingleTop mode. -- Keeps viewed position cached for up to 100 videos. -- Restructures the starting screen from App Navigation - -[Changes][v0.70.0] - - -<a id="v0.69.3"></a> -# [Release v0.69.3: BugFix NIP05 colors](https://github.com/vitorpamplona/amethyst/releases/tag/v0.69.3) - 2023-07-17 - - - -[Changes][v0.69.3] - - -<a id="v0.69.2"></a> -# [Release v0.69.2: Updated Verification icons](https://github.com/vitorpamplona/amethyst/releases/tag/v0.69.2) - 2023-07-17 - -Adds [@Niel Liesmons](https://snort.social/p/npub149p5act9a5qm9p47elp8w8h3wpwn2d7s2xecw2ygnrxqp4wgsklq9g722q)'s icons for Following, Nostr Address Verification, and NIP-94 hash verification - -[Changes][v0.69.2] - - -<a id="v0.69.1"></a> -# [Release v0.69.1: BugFix to view the mute feed](https://github.com/vitorpamplona/amethyst/releases/tag/v0.69.1) - 2023-07-17 - - - -[Changes][v0.69.1] - - -<a id="v0.69.0"></a> -# [Release v0.69.0: Ad-hoc local lists removed](https://github.com/vitorpamplona/amethyst/releases/tag/v0.69.0) - 2023-07-16 - -This version starts the migration to store a user's following communities, channels, and block/mute list in the Contact List and People List kinds. To migrate, please block (not report) and unblock somebody, follow and unfollow somebody. - -- Moves video player to the brand new Exoplayer package -- Moves Following Communities' local DB to the Contact List -- Moves Following Channels local DB to Contact List -- Moves Following BlockList local DB to Mute List (Private part) -- Migrates all past local lists to their event kinds -- Views Mute Feed (disables hidden authors for that specific list) -- Breaks Security Filters screen in 2 tabs: Blocked and Spammers (automated filter) -- Restructures ContactList to avoid removing unsupported tags -- Restructures PeopleList to avoid removing non-people tags -- Fixes older channels and communities not loading on the discovery tab -- Forces a given event kind to be displayed in the Discovery tab -- BugFix for AppDefinitionEvent updates -- Moves contact list closer to metadata in the Local Cache -- Adds parsing support for NNS Events. -- Moves id hex prefixes from 6 to 8 chars. -- Fixes the profile display for new users without pictures. - -[Changes][v0.69.0] - - -<a id="v0.68.4"></a> -# [Release v0.68.4: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.68.4) - 2023-07-14 - -- Renames NIP-05 to Nostr Address for [@derekross](https://github.com/derekross) -- Moves UserProfile NewThreads, Conversations, Reports and App Recommendations to be additive filters -- Moves the synchronized Zap allocation block to run only when needed -- Moves badges out of the User class -- Keeping media feed active from the start of the app. -- Only logging pruning and printing stacktraces when it matters -- Adds EOSE limits to the Video tab -- Adds EOSE limits to the Discovery tab. -- Only triggers mutable state of connection if the connection actually changes. -- Reduces profile feed size to 200 and zaps, reports and followers to 400 -- Puts the Wifi signal processing in an IO Thread - -Download: -- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.68.4/amethyst-googleplay-universal-v0.68.4.apk) -- [F-Droid Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.68.4/amethyst-fdroid-universal-v0.68.4.apk) - -[Changes][v0.68.4] - - -<a id="v0.68.3"></a> -# [Release v0.68.3: Bug fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.68.3) - 2023-07-14 - -- Adds [NostrCheck](https://nostrcheck.me/) uploads on the Media Feed -- Adds the title of the blog post to the [highlighter's](https://highlighter.com/) display -- Fixes size of custom reactions on the Stories feed -- Fixes crash when changing setting in android api < 13 by [@greenart7c3](https://github.com/greenart7c3) -- Migrates DropDownMenu to MutableState for performance -- Changes clicks on Community and Channel headers to go to the community/channel screen instead of expanding it inline -- Update Japanese translations by [@akiomik](https://github.com/akiomik) -- Update Portuguese translations by [@greenart7c3](https://github.com/greenart7c3) - -[Changes][v0.68.3] - - -<a id="v0.68.2"></a> -# [Release v0.68.2: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.68.2) - 2023-07-13 - -- [Fixing Right To Left Text](https://github.com/vitorpamplona/amethyst/commit/0d9399a1a45da32b86c043fa5323866bcd040b18) -- BugFix for Aways show images/videos in the dialog screen - -[Changes][v0.68.2] - - -<a id="v0.68.1"></a> -# [Release v0.68.1: Settings Page](https://github.com/vitorpamplona/amethyst/releases/tag/v0.68.1) - 2023-07-13 - -- Adds Settings for (by [@greenart7c3](https://github.com/greenart7c3)) - - The default language of the app - - The default theme of the app - - If it loads images automatically or not - - If it loads URL previews automatically or not - - If it plays videos automatically or not -- Updates to the Japanese translations by [@akiomik](https://github.com/akiomik) - -[Changes][v0.68.1] - - -<a id="v0.68.0"></a> -# [Release v0.68.0 Classifieds](https://github.com/vitorpamplona/amethyst/releases/tag/v0.68.0) - 2023-07-13 - -- Adds Support for Classifieds ([nostr-protocol/nips#662](https://github.com/nostr-protocol/nips/pull/662)) -- Adds Japanese Translations by [@npub1f5uuywemqwlejj2d7he6zjw8jz9wr0r5z6q8lhttxj333ph24cjsymjmug](https://github.com/npub1f5uuywemqwlejj2d7he6zjw8jz9wr0r5z6q8lhttxj333ph24cjsymjmug) -- BugFix to cut multiple emojis from reactions -- BugFix to remove emoji package list selection from Profile threads. - -[Changes][v0.68.0] - - -<a id="v0.67.1"></a> -# [Release v0.67.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.67.1) - 2023-07-12 - -- When clicking in a repost of a note, redirects the user to the note directly, not to the repost -- Adjustments to allow removing of the selected emoji packs from the reaction setup dialog - - -[Changes][v0.67.1] - - -<a id="v0.67.0"></a> -# [Release v0.67.0: Custom Emoji Reactions](https://github.com/vitorpamplona/amethyst/releases/tag/v0.67.0) - 2023-07-12 - -- Support for Emoji Packs -- Support for Personal Emoji Lists -- Support for Custom emoji Reactions - -[Changes][v0.67.0] - - -<a id="v0.66.7"></a> -# [Release v0.66.7: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.7) - 2023-07-12 - -- Fixes race condition when deleting a reposted event -- Fixes a crash when a lightning service returns an emtpy pr field. -- Fixes images going over text in a markdown rendering -- Early support for displaying custom emoji reactions -- Updates exoplayer version -- Makes nav(route) threaded operation -- Refactoring of Badge Box codes and Time classes -- Adds rendering of community approval posts to the NoteMaster of a thread -- Updates Tamil translations by [@AutumnSunshine](https://github.com/AutumnSunshine) - -[Changes][v0.66.7] - - -<a id="v0.66.6"></a> -# [Release v0.66.6: BugFix for content-warning checkbox not working](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.6) - 2023-07-09 - - - -[Changes][v0.66.6] - - -<a id="v0.66.5"></a> -# [Release v0.66.5: Content-Warning in the video feed](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.5) - 2023-07-09 - -- Adds content-warning toggle when sending an image. -- Toggle marks the media event (NIP-94 or NIP-95) as content-sensitive -- Change renderers in chat, communities, feed, and stores to show a warning before the image is displayed. - -[Changes][v0.66.5] - - -<a id="v0.66.4"></a> -# [Release v0.66.4](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.4) - 2023-07-08 - - - -[Changes][v0.66.4] - - -<a id="v0.66.3"></a> -# [Release v0.66.3: Live Notification Bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.3) - 2023-07-07 - -- Avoids triggering the Live Notification bubble when the live event is simply updated with new participants -- Adds content-sensitivity warnings for LiveStreams -- Fixes translations from Japanese with special chars breaking the url -- Adds hashtags to the description of communities and channels -- Displays community/channel descriptions with Translations. -- Slightly faster reactions and zap icon rendering - -[Changes][v0.66.3] - - -<a id="v0.66.2"></a> -# [Release v0.66.2: BugFixes for Crashes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.2) - 2023-07-07 - -[Creates a fallback for Android9 on OPPO, which doesn't seem able to parse the Japanese regex.](https://github.com/vitorpamplona/amethyst/commit/c93a6dffc4dc56eaee28689af85d1cb915ea8968) -[BugFix for invalid URLs crashing the app.](https://github.com/vitorpamplona/amethyst/commit/60dadc008877de62927c490ed32066e30958c75e) -[Fixes caching of time display.](https://github.com/vitorpamplona/amethyst/commit/a26c7e3d9777210dae4d84a59de0f7cb06b5ae4c) - -[Changes][v0.66.2] - - -<a id="v0.66.1"></a> -# [Release v0.66.1: Community/Chat headers with reactions/zaps](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.1) - 2023-07-07 - -- Moves communities from /c/name to /n/name -- Adds reactions to owners of Chats and Communities -- Adds zaps reactions to owners of Chats and Communities -- Fixes Emoji combinations for -- Reduces the size of the discovery tab captions -- Increases the most recent downloads per chat from 25 to 50 -- Removes top bar for Community and Hashtag posts -- Adds EOSE treatment for the Discovery datasource -- Allows notification to chat channel headers - -[Changes][v0.66.1] - - -<a id="v0.66.0"></a> -# [Release v0.66.0: Communities (NIP 172)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.66.0) - 2023-07-06 - -- Breaks the live screen in 3 tabs -- Adds Support for Communities (172) -- Adds Community discovery screen -- Adds Public Chat discovery screen -- Adds Community Follow/Unfollow -- Adds rendering of Community Posts in the feed -- Creates a summary of verified participants in Communities/Chats/Streams -- Restructures Hashtag Screen to the new Screen Building structure -- Remembers scroll position in Live, Community, and Chat discovery -- Displays Approval Notes in the notifications -- Removes the Live bar from the top of the feed -- Adds notification dots in the LiveStream bottom button. -- Updated Swedish/Czech/German translations by [@davotoula](https://github.com/davotoula) -- BugFix for invalid hexes in the hex index -- BugFix for Japanese Url parsing and Text newline breaks by [@ShinoharaTa](https://github.com/ShinoharaTa) - -[Changes][v0.66.0] - - -<a id="v0.65.1"></a> -# [Release v0.65.1: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.65.1) - 2023-07-05 - -- [Performance updates](https://github.com/vitorpamplona/amethyst/commit/f78ec5cc90e6232531e1322f9161111289eb35a9) -- [Fixes](https://github.com/vitorpamplona/amethyst/commit/fd6a63a76c51ab72b1698d99c1e70c7d82ef36fa) [#487](https://github.com/vitorpamplona/amethyst/issues/487) -- [BugFix for not rendering images on markdown when there is no NIP19 reference in the text.](https://github.com/vitorpamplona/amethyst/commit/e0f186bb94063068d27d14b93b463b66dd79a5bd) - -[Changes][v0.65.1] - - -<a id="v0.65.0"></a> -# [Release v0.65.0: Live Streaming Browser](https://github.com/vitorpamplona/amethyst/releases/tag/v0.65.0) - 2023-07-04 - -- Live Streaming Browser - -[Changes][v0.65.0] - - -<a id="v0.64.4"></a> -# [Release v0.64.4: BugFix for chat delay](https://github.com/vitorpamplona/amethyst/releases/tag/v0.64.4) - 2023-07-03 - -- [BugFix for delayed update of the last messages in the Messages screen](https://github.com/vitorpamplona/amethyst/commit/91c47b6e87495dd5f540842579ac396efe8e0c4a) - -[Changes][v0.64.4] - - -<a id="v0.64.3"></a> -# [Release v0.64.3: New icon for verified follows](https://github.com/vitorpamplona/amethyst/releases/tag/v0.64.3) - 2023-07-02 - -- BugFix for IndexOutOfBoundsException when loading invalid hexes -- Adds New Icon for verified follows -- Fixes the lack of the checkmark in the verification icons -- Adds #Cashu custom tag by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) - -[Changes][v0.64.3] - - -<a id="v0.64.2"></a> -# [Release v0.64.2: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.64.2) - 2023-07-01 - -- [Shows channel picture on the public chat channel's header instead of the creator's picture](https://github.com/vitorpamplona/amethyst/commit/5a3ea1c25824c2bc3b3f9a5d00eb13f9c325cb84) -- [Adds a Toast message for not having a private key when following/unfollowing](https://github.com/vitorpamplona/amethyst/commit/e4020817775d7e2e215cf22e6e7579a58798c8d7) -- [BugFix for not changing background color on Notes](https://github.com/vitorpamplona/amethyst/commit/ed9c27341f925feb9e3d47d86c0f5b896a42d48c) -- [Performance Improvements: Moves away from `drawBehind` and uses background color instead](https://github.com/vitorpamplona/amethyst/commit/56d9c9a50fbc39177f92931cf14dffdffc2c4c07) -- [Removes the need to draw a background in the verification symbol](https://github.com/vitorpamplona/amethyst/commit/09582fd0b13ec6893d7fbaa9ef944bdd037f4694) -- [Reduces the max grouped reactions on Notifications to 30](https://github.com/vitorpamplona/amethyst/commit/1d5dfbfd29aad9d36813f14be4a75332f3a94018) -- [Moves Note/User updates from 300ms to 500ms](https://github.com/vitorpamplona/amethyst/commit/cd9465c0e755b92d34475afae4d4a2dfb4f02107) -- [New RichText engine to help with testing classes.](https://github.com/vitorpamplona/amethyst/commit/d179c93c0bd19a5e6ba59c8c1a19085641440216) -- [fix relay tab not working in profile page](https://github.com/vitorpamplona/amethyst/commit/53a146d0e54a8b0f33f4869625447b2da6c5894a) -- [BugFix Crashing relay screen on invalid URLs](https://github.com/vitorpamplona/amethyst/commit/b820b6564f5812c28888398b93fdbd577e47f912) -- [Reduces mentions to hex in the UI](https://github.com/vitorpamplona/amethyst/commit/3908c42c7fa940e5f9a5236f6a2c4a4f2f2acce7) - -[Changes][v0.64.2] - - -<a id="v0.64.0"></a> -# [Release v0.64.0: UI Changes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.64.0) - 2023-06-29 - -- Merges Global into Home as a top-bar List -- Moves Relay Settings info from top bar to left drawer -- Adds search to the top bar -- Slims down the live headers -- Additional checks for main thread in Notifications -- Makes NIP05 work with domain names only (No need for the _@ in the "_@ domain" address) -- Improved AsyncImage loading states -- Logs out the time to display each reaction type -- BugFix: Not showing display name on [n] tags -- BugFix: Trimmed paragraphs losing indentation -- BugFix: Don't show URL previews in quoted notifications and other cases where the preview is not needed. - -[Changes][v0.64.0] - - -<a id="v0.63.0"></a> -# [Release v0.63.0: Zitron's Logo!](https://github.com/vitorpamplona/amethyst/releases/tag/v0.63.0) - 2023-06-29 - -- Moves to [@npub103vypyhddrad9289zp8lf2dxlkkrmq3e0utx3qg449ea8x2wel6sas2700](https://github.com/npub103vypyhddrad9289zp8lf2dxlkkrmq3e0utx3qg449ea8x2wel6sas2700) 's Logo -- Adds followed tags in profile screen by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adds music note to #tunestr - -[Changes][v0.63.0] - - -<a id="v0.62.8"></a> -# [Release v0.62.8: More Performance](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.8) - 2023-06-28 - -#Amethyst v0.62.8: More Performance - -- Performance improvements and additional MainThread checks -- Logging rendering performance -- BugFix for rendering url of highlight note by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Add copy token button to Cashu's Preview by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- BugFix better default error message by [@npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8](https://github.com/npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8) -- Runs translation chain in the background -- Restructures Multi-Notification Galleries -- BugFix: Avoids blinking the nip05 address -- BugFix: Ignoring case when comparing original and translated versions. -- Moves RichText/Markdown Modifiers to singleton classes. -- Updates URLPreview in the main thread. -- Updates dependencies to the newest version -- Updates Hungarian translation by [@npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp](https://github.com/npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp) - - -Download: -- Play Edition: https://github.com/vitorpamplona/amethyst/releases/download/v0.62.8/amethyst-googleplay-universal-v0.62.8.apk -- F-Droid Edition: https://github.com/vitorpamplona/amethyst/releases/download/v0.62.8/amethyst-fdroid-universal-v0.62.8.apk - -[Changes][v0.62.8] - - -<a id="v0.62.7"></a> -# [Release v0.62.7: BugFix for zapraiser](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.7) - 2023-06-27 - -- Fixes the large UI padding in the details of the zapraiser - -[Changes][v0.62.7] - - -<a id="v0.62.6"></a> -# [Release v0.62.6: Perf improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.6) - 2023-06-27 - -- Moves state assignments to the main thread -- Reduces full-screen updates to once a second -- Minor adjustments in threading -- Moves cashu processing to a state class to account for errors - -[Changes][v0.62.6] - - -<a id="v0.62.5"></a> -# [Release v0.62.5: Bugfix for crashing on invalid cashu token](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.5) - 2023-06-27 - -- [Minor adjustments in UI threading](https://github.com/vitorpamplona/amethyst/commit/52af109b4eb8ea91716de96090693d584b32e2c4) -- [Moves cashu processing to a state class to account for errors.](https://github.com/vitorpamplona/amethyst/commit/833e6bc3e14534a396a320fe6fa1f0663f3a06b5) - -[Changes][v0.62.5] - - -<a id="v0.62.4"></a> -# [Release v0.62.4: Cashu Token support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.4) - 2023-06-26 - -- Adds Cashu Token redemption to the User's ln address by [@believethehype](https://github.com/believethehype) -- Video Dialog now go full screen by [@vitorpamplona](https://github.com/vitorpamplona) -- Updates Dutch Translations by [@Bardesss](https://github.com/Bardesss) -- Adds a confirmation dialog before logout by [@greenart7c3](https://github.com/greenart7c3) -- Bugfix for not updating user picture and name after a new message arrives on the Message List by [@vitorpamplona](https://github.com/vitorpamplona) -- Several little performance Improvements across the app by [@vitorpamplona](https://github.com/vitorpamplona) -- Adjusts Profile images to crop and not fit by [@vitorpamplona](https://github.com/vitorpamplona) -- Preloads channel data from LocalCache by [@vitorpamplona](https://github.com/vitorpamplona) -- Removes live thumb and adjusts padding of the live header [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.62.4] - - -<a id="v0.62.3"></a> -# [Release v0.62.3: BugFixes and Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.3) - 2023-06-25 - -- Adds picture of the User to the Stream's header -- Migrates the UserProfile page to the new ViewModel structure -- Requires an update in the live stream event every two hours to keep live -- Adds a less memory-intensive Hex Checker -- Moves OnlineChecker to a singleton class with a cache -- Moves LocalCache's Synchronized block to Concurrent implementations -- Moves user msg synchronization to its own object -- Crossfades changes in Reactions and Zaps -- Improves the rendering speed of chat messages -- BugFix for new posts by the user not showing up immediately in the feed -- BugFix for duplicated notes in the LocalCache -- BugFix for the Click on UserPicture event - -[Changes][v0.62.3] - - -<a id="v0.62.2"></a> -# [Release v0.62.2: BugFix for crashing relay screen](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.2) - 2023-06-23 - -- Some relays don't return a JSON when requested and that was crashing the app. - -[Changes][v0.62.2] - - -<a id="v0.62.1"></a> -# [Release v0.62.1: BugFix for posts with createdAt in the future](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.1) - 2023-06-23 - -- Stops showing posts in the future (Fixes Tony's Highlighter post) -- BugFix on publishedAt date for Longform posts - -[Changes][v0.62.1] - - -<a id="v0.62.0"></a> -# [Release v0.62.0 NIP-98 Support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.62.0) - 2023-06-23 - -- Adds NIP98 Support by [@believethehype](https://github.com/believethehype) -- Adds nostrcheck.me image host by [@believethehype](https://github.com/believethehype) -- Fixes UTF-32 Reactions cutting off -- Adds icon for paid relays in the relay list by [@greenart7c3](https://github.com/greenart7c3) -- Improves rendering time of the Channel header -- Avoids crashing due to illegal zap address -- Making more classes stable for performance reasons. -- Improves relay options rendering performance. -- Review to avoid Synchronized blocks -- Uses derivedOf to protect from updates with the same content -- Generalizes the SensitivityWarning to any Note -- Moves LaunchedEffects to only run if objects haven't yet being loaded -- Checks main thread in preferences -- Moves the image border modifiers to app variable - -[Changes][v0.62.0] - - -<a id="v0.61.4"></a> -# [Release v0.61.4: Relay Info pages](https://github.com/vitorpamplona/amethyst/releases/tag/v0.61.4) - 2023-06-21 - -- Adds internal Relay Info pages when clicking on relay icons by [@greenart7c3](https://github.com/greenart7c3) -- Removes old image proxy classes by [@vitorpamplona](https://github.com/vitorpamplona) -- Improves rendering performance of Chat screens and messages by [@vitorpamplona](https://github.com/vitorpamplona) -- Updates Hungarian translation for Zapraiser by [@ZsZolee](https://github.com/ZsZolee) - -[Changes][v0.61.4] - - -<a id="v0.61.3"></a> -# [Release v0.61.3 Too many Live Streams Bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.61.3) - 2023-06-20 - -- BugFix for too many streams (max = 2 for now) -- BugFix for opening the video chat with the video in full screen until it loads. -- Fix margins when the Live Activity summary is not available -- Refreshing stream list when follows change, including Live activities. - -[Changes][v0.61.3] - - -<a id="v0.61.2"></a> -# [Release v0.61.2: Live streaming in the chat](https://github.com/vitorpamplona/amethyst/releases/tag/v0.61.2) - 2023-06-20 - -- Restructures chat message rendering -- Adds sensitive content warning to chats -- Adds live activities to the main feed -- Adds live activities chats to the conversations tab -- Adds public chat msgs to the conversations tab - -[Changes][v0.61.2] - - -<a id="v0.61.1"></a> -# [Release v0.61.1: Screen diet](https://github.com/vitorpamplona/amethyst/releases/tag/v0.61.1) - 2023-06-20 - -- Improvements in the use of space of UI elements -- Moves the streaming label to the bottom of the video -- Removes the top bar for streaming and chats -- Adds options to react and zap the author of the streaming -- BugFix for streaming rotation of videos -- BugFix for not updating Notifications quickly - -[Changes][v0.61.1] - - -<a id="v0.61.0"></a> -# [Release v0.61.0: Live streaming chats](https://github.com/vitorpamplona/amethyst/releases/tag/v0.61.0) - 2023-06-19 - -- Support for Live Chat event kind (1311) -- Renders Channel headers, NIP94 and NIP95 in the thread's master post -- Add toast message to show relay icon description by [@greenart7c3](https://github.com/greenart7c3) -- BugFix for notifications over very old posts not appearing - -[Changes][v0.61.0] - - -<a id="v0.60.2"></a> -# [Release v0.60.2: Scroll to The top BugFix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.60.2) - 2023-06-19 - -- Restructures feed invalidations to account for changes in the selected top list -- Restructures scroll-to-the-top implementations to avoid using arguments in the navigator -- Restructures Stable elements for minor performance gains. - -[Changes][v0.60.2] - - -<a id="v0.60.1"></a> -# [Release v0.60.1: Per-Post Zapraisers](https://github.com/vitorpamplona/amethyst/releases/tag/v0.60.1) - 2023-06-18 - -Allows users to add an amount of sats to raise per post. -Displays zap raisers as a progress bar to completion in the post. - -[Changes][v0.60.1] - - -<a id="v0.60.0"></a> -# [Release v0.60.0: Live Activities & Generic Repost support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.60.0) - 2023-06-18 - -- Basic Support for live activities (NIP-102) -- Adds support for Kind 16 (Generic Repost) -- Activates support for m3u8 streaming -- Migrates `e` citations to replaceable events to their latest version -- Migrates last-seen times saved per route to account management -- Removes original tags from Reposts -- BugFix on restarting the video when pressing mute -- BugFix to not scroll the feed to the top when the new follow list is updated -- BugFix for a background color for messages in notifications -- Increases performance of the follow/follower count - -[Changes][v0.60.0] - - -<a id="v0.59.1"></a> -# [Release v0.59.1: Name Playback](https://github.com/vitorpamplona/amethyst/releases/tag/v0.59.1) - 2023-06-16 - -https://nostr.build/av/e99f5528e0f18946b171b36da852e7470f84d13956a37fa4dcaae89c010d5d35.mp4 - -- BugFix for Messages being marked as read from Notifications. -- Text to speech for usernames and display names -- Removes some duplicated Boosts -- BugFix for feed not updating after change in follows. -- BugFix for some reposts not showing up in the feed. - -[Changes][v0.59.1] - - -<a id="v0.58.3"></a> -# [Release v0.58.3: Custom Reaction bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.58.3) - 2023-06-15 - -- BugFixes for the size of emojis on the Video feed. -- BugFixes for the alignment of emojis on the feed of some phones. -- BugFix for deleting a reaction and going back to the Red Heart image - -[Changes][v0.58.3] - - -<a id="v0.58.2"></a> -# [Release v0.58.2: Like always active Bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.58.2) - 2023-06-15 - - - -[Changes][v0.58.2] - - -<a id="v0.58.1"></a> -# [Release v0.58.1: Bug Fix for long reaction comments](https://github.com/vitorpamplona/amethyst/releases/tag/v0.58.1) - 2023-06-15 - - - -[Changes][v0.58.1] - - -<a id="v0.58.0"></a> -# [Release v0.58.0: Custom reactions](https://github.com/vitorpamplona/amethyst/releases/tag/v0.58.0) - 2023-06-14 - -- Custom reactions -- Starts to preload objects before the feed is ready -- Simplifies URL Preview calls -- Hungarian translation updates by [@ZsZolee](https://github.com/ZsZolee) - -[Changes][v0.58.0] - - -<a id="v0.57.0"></a> -# [Release v0.57.0: Animations for loading posts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.57.0) - 2023-06-13 - -- Uses animateItemPlacement to show new Notes and update the size of current ones as they load -- Adds NIP-10 markers to kind 1s, fixing bug with thread view in other clients -- New Privacy Policy for F-droid -- Adds an option to opt-out from automatic spam and report filters by [@npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5](https://github.com/npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5) -- Adjusts the size and alignment of relay icons in the video feed -- Refactors some of the Zap Request,Response objects in the interface for performance -- BugFix for emtpy space in Notications when blocked posts are included. -- Updates kotlin dependencies -- Activates build cache - -[Changes][v0.57.0] - - -<a id="v0.56.5"></a> -# [Release v0.56.5: Support for NIP-14 (Subject Line)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.56.5) - 2023-06-11 - -- Basic Support for displaying NIP-14 on notes (Subject) -- BugFix for Video Uploads when the video is already compressed to the minimum -- Improves markdown support -- Fixing upstream library issue with images on markdown -- Refactors color objects to avoid recreating them on-demand - -[Changes][v0.56.5] - - -<a id="v0.56.4"></a> -# [Release v0.56.4: BugFix for a race condition when starting the app](https://github.com/vitorpamplona/amethyst/releases/tag/v0.56.4) - 2023-06-09 - - - -[Changes][v0.56.4] - - -<a id="v0.56.3"></a> -# [Release v0.56.3: Reconnecting after network drop](https://github.com/vitorpamplona/amethyst/releases/tag/v0.56.3) - 2023-06-09 - -- Adds a network listener to reconnect when connectivity comes back -- BugFix for duplicated push notifications -- Moves background color changes to IO Threads with Mutable State -- Breaks down the Note Composition stack further -- Fixes border/spacing issues between multiple note types -- Aligns Quick Actions to the center of the note. -- Improves the speed of the highlighter rendering -- Refactoring all shapes to the appropriate file - -[Changes][v0.56.3] - - -<a id="v0.56.2"></a> -# [Release v0.56.2: BugFixes and Adjustments](https://github.com/vitorpamplona/amethyst/releases/tag/v0.56.2) - 2023-06-08 - -- Moves channel's sending message from the main thread to the IO thread -- Removes duplicated event-id protections from Notifications -- BugFix for notifications sometimes not reassembling when pressing the bottom button. -- Adds a second rule for short messages in the spam filter to avoid recognizing "@ bot, command" messages spam. -- Moves the creation of the VideoPlayer to its own composition -- Adds #onyx custom tag by [@TonyGiorgio](https://github.com/TonyGiorgio) -- Updates Hungarian translation by [@ZsZolee](https://github.com/ZsZolee) - -[Changes][v0.56.2] - - -<a id="v0.56.1"></a> -# [Release v0.56.1: Compressed Uploads](https://github.com/vitorpamplona/amethyst/releases/tag/v0.56.1) - 2023-06-08 - -- Adds Video Compression on upload -- Adds Image Compression on upload (GIFs not supported yet) -- Adds #weedsstr custom tag from Onyx/[@TonyGiorgio](https://github.com/TonyGiorgio) - -[Changes][v0.56.1] - - -<a id="v0.56.0"></a> -# [Release v0.56.0: Per-post reaction display](https://github.com/vitorpamplona/amethyst/releases/tag/v0.56.0) - 2023-06-07 - - - -[Changes][v0.56.0] - - -<a id="v0.55.4"></a> -# [Release v0.55.4: Support for Relay Set Event Type](https://github.com/vitorpamplona/amethyst/releases/tag/v0.55.4) - 2023-06-07 - - - -[Changes][v0.55.4] - - -<a id="v0.55.3"></a> -# [Release v0.55.3: Refactoring Messages Screen](https://github.com/vitorpamplona/amethyst/releases/tag/v0.55.3) - 2023-06-07 - - - -[Changes][v0.55.3] - - -<a id="v0.55.2"></a> -# [Release v0.55.2: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.55.2) - 2023-06-05 - - - -[Changes][v0.55.2] - - -<a id="v0.55.1"></a> -# [Release v0.55.1: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.55.1) - 2023-06-05 - - - -[Changes][v0.55.1] - - -<a id="v0.55.0"></a> -# [Release v0.55.0: NIP-89 App Recommendation Systems](https://github.com/vitorpamplona/amethyst/releases/tag/v0.55.0) - 2023-06-03 - -- Displays NIP-89 Notes in the feed -- Displays recommendations on profile -- Displays NIP-89-related notifications for your apps. -- BugFix: Making sure video does not restart when pressing the mute button -- BugFix: Keeping the position of the feed in navigation - -[Changes][v0.55.0] - - -<a id="v0.54.2"></a> -# [Release v0.54.2: More Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.54.2) - 2023-06-03 - -- Adds comment and amount of sats sent directly to the profile in Notifications -- Adds more caching capabilities in content parsing -- Restructures WordRenderer to help recompositions -- Improves performance of custom emojis -- Improves performance of DropDown menus -- Removing outdated version of the FlowRow -- Runs bottom's new notification refresher in the IO thread -- Only refreshes follow lists once per notification event -- Moves post's bounty calculations to the IO thread -- Moves profile DB watchers to their own compose functions -- Adds a new thread call for the notification of reaction rows -- Ignores unloaded notes in the MultiComposeSet renderer. -- Moves UserFeeds to ImmutableLists -- Moves reaction counts to a thread -- Moves channel checks to before a Channel object is loaded. - -[Changes][v0.54.2] - - -<a id="v0.54.1"></a> -# [Release v0.54.1: More Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.54.1) - 2023-06-01 - -[Fixes Translations bug by previous version](https://github.com/vitorpamplona/amethyst/commit/b0e50e0c00f6cfe7cc4da234675aee281192c1bd) -[Restructuring observables](https://github.com/vitorpamplona/amethyst/commit/92c61f317bef860c0ab2e80ed4f6fb9e0160378c) - -[Changes][v0.54.1] - - -<a id="v0.54.0"></a> -# [Release v0.54.0: More Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.54.0) - 2023-06-01 - -- [Add support for per-app languange preferences](https://github.com/vitorpamplona/amethyst/commit/a466f33b4e4f9fb420123ce2efb3da38026ab08d) by [@davotoula](https://github.com/davotoula) -- [Avoids notifying twice](https://github.com/vitorpamplona/amethyst/commit/1cc0f4bf80dc750bbace2b156fad2828982c938c) -- [Restructuring ViewModels](https://github.com/vitorpamplona/amethyst/commit/d942c126277d9737c4d67b75b6e341915ca83887) -- [Improves Poll Rendering speed.](https://github.com/vitorpamplona/amethyst/commit/3eb832c4e09cdfa9bd2d33bef74ad904e99f58f2) -- [Reducing recompositions on images.](https://github.com/vitorpamplona/amethyst/commit/6072e5977e8a0d9a95b53004f54ce8812dcfdda9) -- [Improves rendering performance of the ExoPlayer](https://github.com/vitorpamplona/amethyst/commit/91f34000f535230ae198747d90cbaaf2995ffc4c) - -[Changes][v0.54.0] - - -<a id="v0.53.7"></a> -# [Release v0.53.7: Zap, Reply, etc to Reports](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.7) - 2023-05-30 - - - -[Changes][v0.53.7] - - -<a id="v0.53.6"></a> -# [Release v0.53.6: Security Filter List bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.6) - 2023-05-30 - -[Sorting blocked users by name](https://github.com/vitorpamplona/amethyst/commit/030064e112ed9e5982bfd7b1e2f32c4943eaa634) -[BugFix for not loading the security filter screen](https://github.com/vitorpamplona/amethyst/commit/d9a392d2c1f3314e6d7ae277e7f4c09228e846ba) - -[Changes][v0.53.6] - - -<a id="v0.53.5"></a> -# [Release v0.53.5: Videos over Tor bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.5) - 2023-05-30 - -[Restart Video Cache when chancing Tor Setup](https://github.com/vitorpamplona/amethyst/commit/24a6d8a155159884c451638ba614e741643333d5) - -[Changes][v0.53.5] - - -<a id="v0.53.4"></a> -# [Release v0.53.4: Markdown bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.4) - 2023-05-30 - -- [Fixes bug with rendering of markdown with user citations.](https://github.com/vitorpamplona/amethyst/commit/6ea0972151b9237a4e15f7426b84d5c6f76b2836) -- [Improves rendering of Markdown summaries](https://github.com/vitorpamplona/amethyst/commit/f2db2b88c25595b3b47acdddc640f5d98ae089a1) - -[Changes][v0.53.4] - - -<a id="v0.53.3"></a> -# [Release v0.53.3: Video Player with Tor Support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.3) - 2023-05-29 - -- [adds flavor to the version descriptor in the bottom left of the drawer](https://github.com/vitorpamplona/amethyst/commit/1972297755366bb94d29a2cc24b067756d6cab8e) -- [Moves ExoPlayer to OkHttp in order to enable Tor support.](https://github.com/vitorpamplona/amethyst/commit/ac006946907ef9bf13f8b005056384f7ad128b96) - -[Changes][v0.53.3] - - -<a id="v0.53.2"></a> -# [Release v0.53.2: Message Notification bubble bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.2) - 2023-05-29 - -- [Correcting the user's room for the notification marker](https://github.com/vitorpamplona/amethyst/commit/43d508ba3366e0f2c205dc6a7d7903496a0d4a36) - -[Changes][v0.53.2] - - -<a id="v0.53.1"></a> -# [Release v0.53.1: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.1) - 2023-05-29 - -[Filters Message Feed DAL to only take PrivateMessages in the Notification Bubble.](https://github.com/vitorpamplona/amethyst/commit/64f98e4b924e4fe99dee8d42270822ff3c1e19c0) -[Adding immutable tag to ResizeImage class](https://github.com/vitorpamplona/amethyst/commit/c58c20cc541ddd45b684c81d60ae0cbdea28d124) - -[Changes][v0.53.1] - - -<a id="v0.53.0"></a> -# [Release v0.53.0](https://github.com/vitorpamplona/amethyst/releases/tag/v0.53.0) - 2023-05-29 - -[Updated Persian Translations by @malimahda](https://github.com/vitorpamplona/amethyst/commit/83df639aff159da7b547d7030f72e6f915975b4a) [#430](https://github.com/vitorpamplona/amethyst/pull/430) -[Bugfix case sensitive nip05 by @davotoula ](https://github.com/vitorpamplona/amethyst/commit/0f6bf1cc59c409e880fc8ab831e94723e00a6e8e) [#428](https://github.com/vitorpamplona/amethyst/pull/428) -[Adds separate colors for the newItem background between light and dark themes](https://github.com/vitorpamplona/amethyst/commit/cb92a51191469ad2c480c9bb382cd3bcf1974a00) -[Adds Czech translations by @davotoula](https://github.com/vitorpamplona/amethyst/commit/213b172c32f7ddbf3bdaf2ba917ba14d706cbac0) [#431](https://github.com/vitorpamplona/amethyst/pull/431) -[Moves chatroom list to an additive filter](https://github.com/vitorpamplona/amethyst/commit/a5a3c62edd75bfb36085d1f6b88264455354f3ad) -[Avoids running through the entire filter when collection is Empty](https://github.com/vitorpamplona/amethyst/commit/02ad85a740172b61508e16521cf64acd4127feef) -[Adds mute control per track on the screen.](https://github.com/vitorpamplona/amethyst/commit/446273de80498b665e0d1fb58bb19c20b5a9fd12) -[Optimizing UserProfile status](https://github.com/vitorpamplona/amethyst/commit/b513877552f28b256bc01ba4d49b3768478a1d2a) -[Refreshing user status when added to the transient spam list](https://github.com/vitorpamplona/amethyst/commit/1a477ea1c89dadbf3b94533c7207c25549b6f238) -[Adds some documentation to make sure any NIP05 changes take into account that we are converting the entire json to lowercase.](https://github.com/vitorpamplona/amethyst/commit/f298cc6f13a79234e8fa4dd437636b253f57d207) -[Loading reply from launch and not withContent](https://github.com/vitorpamplona/amethyst/commit/6bc1b6b28ab2d20b77f9a823f1f00f7854757afe) - -[Changes][v0.53.0] - - -<a id="v0.52.3"></a> -# [Release v0.52.3: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.52.3) - 2023-05-28 - -[Moves all note states to leaf compositors](https://github.com/vitorpamplona/amethyst/commit/5e3703e849adcbf7c881db58a73de68680fd72ff) -[Better recomposition structure for ReactionsRow](https://github.com/vitorpamplona/amethyst/commit/acbd41ce61c8f5e7736c7346cf163139b06748f7) -[Performance Improvements when rendering a channel creation note](https://github.com/vitorpamplona/amethyst/commit/056d00b73b51b9cc467e3f06e15b6260d3b3ea32) - -[Changes][v0.52.3] - - -<a id="v0.52.2"></a> -# [Release v0.52.2: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.52.2) - 2023-05-27 - -- Sensitive content Hungarian translation by [@ZsZolee](https://github.com/ZsZolee) -- Trims relay urls - -[Changes][v0.52.2] - - -<a id="v0.52.1"></a> -# [Release v0.52.1](https://github.com/vitorpamplona/amethyst/releases/tag/v0.52.1) - 2023-05-27 - - - -[Changes][v0.52.1] - - -<a id="v0.52.0"></a> -# [Release v0.52.0 NIP-36 (Sensitive Content)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.52.0) - 2023-05-26 - - - -[Changes][v0.52.0] - - -<a id="v0.51.4"></a> -# [Release v0.51.4: Zap Amount contrast improvements in light mode](https://github.com/vitorpamplona/amethyst/releases/tag/v0.51.4) - 2023-05-26 - - - -[Changes][v0.51.4] - - -<a id="v0.51.3"></a> -# [Release v0.51.3: BugFix for changing lists not changing the feed](https://github.com/vitorpamplona/amethyst/releases/tag/v0.51.3) - 2023-05-26 - -Only changing stats for the Reaction Row if the data actually changed. -Improves rendering of messages in the Notification feed -Feed invalidation is necessary for lists -Simplifying Private message rendering scheme -Updates jsoup and activity compose to the latest versions - -[Changes][v0.51.3] - - -<a id="v0.51.2"></a> -# [Release v0.51.2](https://github.com/vitorpamplona/amethyst/releases/tag/v0.51.2) - 2023-05-25 - -Changes the structure of the notification view to increase speed. -Preserves invalidations of DataSources for Home, Videos and Notificat… -Only removes scrollTop from Uri after 1 second and to avoid changing … -Activates invalidations that can be ignored if another invalidation i… -Only scrolls to the top if not at the top -Remembers formatting for the Notification Reaction Row -Updates Notification lists in intervals of 1 second. -Remember modifiers to avoid recomposition -Moves Home and Video screen invalidations to follow the scrollTop par… -Adds @ stable tag to Notification Card models -Remembering the size of the robohash picture -Removes logs -Removing the procedure to start connecting with relays before logging in. - -[Changes][v0.51.2] - - -<a id="v0.51.1"></a> -# [Release v0.51.1](https://github.com/vitorpamplona/amethyst/releases/tag/v0.51.1) - 2023-05-24 - - - -[Changes][v0.51.1] - - -<a id="v0.51.0"></a> -# [Release v0.51.0](https://github.com/vitorpamplona/amethyst/releases/tag/v0.51.0) - 2023-05-24 - - - -[Changes][v0.51.0] - - -<a id="v0.50.6"></a> -# [Release v0.50.6: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.50.6) - 2023-05-23 - -- [Adds Immutable and Stable annotations to avoid recomposing](https://github.com/vitorpamplona/amethyst/commit/8898e5d10bf7797a92e08a6ca98ad4a5e173ca44) -- [Improves the speed of updating Notification notes](https://github.com/vitorpamplona/amethyst/commit/15621f16a2e6f945c5aab7aa7b896190b89c6c0b) -- [Adds a cache layer for citations in Notes](https://github.com/vitorpamplona/amethyst/commit/a29d9ad433afccbd04f8b9c0c51234336135845e) -- [Fixes click on the Home to update the feed](https://github.com/vitorpamplona/amethyst/commit/d355f22938b765acefca8cfb8452f711e38071b1) -- [Fixes size for following symbol](https://github.com/vitorpamplona/amethyst/commit/2082c6cb28bab865edbde715faeb29c1f8e1ecbe) -- [Adds Better translations to unfollow in Portuguese](https://github.com/vitorpamplona/amethyst/commit/4b49cc1aab15322df67bf774f6073b056928fbe6) -- [Updates dependencies](https://github.com/vitorpamplona/amethyst/commit/a1da4affeb5ef65cee66b7b94d4bfc7fd24699e6) -- [Moves more variables to remember clauses](https://github.com/vitorpamplona/amethyst/commit/0bf94f316f6b21d4eb132e89e661036cccd715c2) - -[Changes][v0.50.6] - - -<a id="v0.50.5"></a> -# [Release v0.50.5](https://github.com/vitorpamplona/amethyst/releases/tag/v0.50.5) - 2023-05-22 - -- [Moves zapProgress to inside the IO update to make sure it only update…](https://github.com/vitorpamplona/amethyst/commit/6437a2106a8e0ac80322bc86f1d481de78e726e7) -- Stops recomposing the drawer at every new route. -- Adjusts bottombar to not recalculate hasNewItems in every recomposition. -- Moves default state of url previews to the remember function -- Remembering Background color and Text elements for posts. -- Optimizes MultiSetCompose rendering -- Optimizes recomposition of bottom items. -- Optimizes NIP-05 procedures -- Optimizes Drawer for Recomposition -- Removes the time log for Composing a note -- Only updates the refreshing state if it was refreshing. -- Deprecating IMGUR -- Updates relay UI on messages sent by the logged in user in chat -- Migrates notification summary to a new day without having to restart -- Apply fix to profile navigation of the same page by [@KotlinGeekDev](https://github.com/KotlinGeekDev) -- Adds hashtags and #[n] rendering to markdown -- Fixes NWC with Auth -- [Adds nostr: prefix to channel id share.](https://github.com/vitorpamplona/amethyst/commit/330f3503a6961ef1e5c0fcdf55e751f8a41ba810) - -[Changes][v0.50.5] - - -<a id="v0.50.4"></a> -# [Release v0.50.4](https://github.com/vitorpamplona/amethyst/releases/tag/v0.50.4) - 2023-05-17 - -[Bugfix for saving Replaceable notes in Bookmarks.](https://github.com/vitorpamplona/amethyst/commit/323e71c7cbbe27a1716f3c0a20d830866b3f1f02) -[Bugfix for channel metadata messages show up before channel creation](https://github.com/vitorpamplona/amethyst/commit/e41df98920764888199507378e35518eb1269d7e) - -[Changes][v0.50.4] - - -<a id="v0.50.3"></a> -# [Release v0.50.3 BugFixes and Updates](https://github.com/vitorpamplona/amethyst/releases/tag/v0.50.3) - 2023-05-17 - -[Replacing some missing Hungarian translation](https://github.com/vitorpamplona/amethyst/commit/c5176b8c4973be9b2527aadcbbe674c68b6f62b5) -[Add lowercase variant of hashtag when creating event.](https://github.com/vitorpamplona/amethyst/commit/d6c8651f0d46ed205e48788bd4e26daeaa2837dc) -[Fixes Auth for NIP-42 sporadic connections.](https://github.com/vitorpamplona/amethyst/commit/5040350be5ff659d0682f859741061c6e5a224b8) - -[Changes][v0.50.3] - - -<a id="v0.50.2"></a> -# [Release v0.50.2: Jumping chat post fix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.50.2) - 2023-05-17 - -[Fixes jumping chat screens on hidden posts.](https://github.com/vitorpamplona/amethyst/commit/117446688b0f9d4045a216fad1feaa51c8142b7a) - -[Changes][v0.50.2] - - -<a id="v0.50.1"></a> -# [Release v0.50.1: Reaction Watch Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.50.1) - 2023-05-17 - -- UI and Performance improvements -- [Adds Navigation markers to the top panel and reaction watch](https://github.com/vitorpamplona/amethyst/commit/95a21cc08c161b138915c652d324402f404526af) -- BugFixes when switching accounts -- BugFix in the like count -- Better caching system for compiled stats - -[Changes][v0.50.1] - - -<a id="v0.50.0"></a> -# [Release v0.50.0: Reaction Watch](https://github.com/vitorpamplona/amethyst/releases/tag/v0.50.0) - 2023-05-17 - - - -[Changes][v0.50.0] - - -<a id="v0.49.4"></a> -# [Release v0.49.4: Colored relay icons](https://github.com/vitorpamplona/amethyst/releases/tag/v0.49.4) - 2023-05-16 - -- [Adds online search when typing the username in the new Post field.](https://github.com/vitorpamplona/amethyst/commit/5a3cf4040210aefd7217e301ad93c7953635f9cc) -- [Increasing size of the "More Options" button in the Video Feed.](https://github.com/vitorpamplona/amethyst/commit/1519e70b24691d127b85f2521dc02ce2016b61d6) -- [Optimizes the code for chat bubble rendering](https://github.com/vitorpamplona/amethyst/commit/135596591a48766d266869dda0a3e4718aade8eb) -- [Fixes Robohash dependency in size](https://github.com/vitorpamplona/amethyst/commit/389afcb6003e31e3c8c39d400d1f459ae8e5322a) -- [Adds color to relay icons](https://github.com/vitorpamplona/amethyst/commit/d6c29667925428e1816dc2e83ead705a458d55b8) -- [Avoiding creating a lnurl link with just those words](https://github.com/vitorpamplona/amethyst/commit/dd8b208a0d6227592f51f123ecb754030784dae5) - -[Changes][v0.49.4] - - -<a id="v0.49.3"></a> -# [Release v0.49.3: Search improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.49.3) - 2023-05-16 - -- [Improves rendering performance for chats.](https://github.com/vitorpamplona/amethyst/commit/3950517743aa396e9393cf98611708d1abe87c57) -- [Parsing NIP-19 on search to improve search results.](https://github.com/vitorpamplona/amethyst/commit/9e93a35ee7d16aed6b3c1d443d06cbe55a655695) -- [Making sure filters return similar orders when two posts are published by adding idHex as a sorting element](https://github.com/vitorpamplona/amethyst/commit/76df70c6f67880dd552c7671a3969686db83c1d3) - -[Changes][v0.49.3] - - -<a id="v0.49.2"></a> -# [Release v0.49.2: Custom Emoji for Chats](https://github.com/vitorpamplona/amethyst/releases/tag/v0.49.2) - 2023-05-16 - -[Fixing emoji's on chat handles](https://github.com/vitorpamplona/amethyst/commit/f2d87b78b1b882f048ceb3efde7d02e6d8a1b6e4) - -[Changes][v0.49.2] - - -<a id="v0.49.1"></a> -# [Release v0.49.1: Custom Emoji Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.49.1) - 2023-05-16 - -- [Better shade for Pins](https://github.com/vitorpamplona/amethyst/commit/d29e6b4d0be69b8a18f683998914cd2ffec14c2d) -- [Avoid color-filtering the emoji](https://github.com/vitorpamplona/amethyst/commit/14e670cd9a953aeedf7690ae88e7621a3bd0577d) -- [Adjustments to Markdown detection and rendering.](https://github.com/vitorpamplona/amethyst/commit/332490376b7ca459cbd6170b3b82613b6f6942aa) -- [BugFix for incorrect caching of NIP95](https://github.com/vitorpamplona/amethyst/commit/17fbf8c01f6d555ce2b57d3c15576016ed7c69aa) - -[Changes][v0.49.1] - - -<a id="v0.49.0"></a> -# [Release v0.49.0: Pinstr support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.49.0) - 2023-05-16 - - - -[Changes][v0.49.0] - - -<a id="v0.48.0"></a> -# [Release v0.48.0: NIP-30: Custom Emojis](https://github.com/vitorpamplona/amethyst/releases/tag/v0.48.0) - 2023-05-16 - -- Adds support for NIP-30 in Kind 0 (metadata) and Kind 1s (posts) -- Adds Swedish translations by [@Pextar](https://github.com/Pextar) -- Adds NIP-94 shareable link to filestr -- Adds zapstr.live and listr.lol as shareable links -- Adds a 5 minute expiration time for image loading errors -- Fixes NPE on opening the QuickAction on a "Post Not Found" event. - -[Changes][v0.48.0] - - -<a id="v0.47.0"></a> -# [Release v0.47.0: Support for Zapstr.live (Audio Tracks)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.47.0) - 2023-05-15 - - - -[Changes][v0.47.0] - - -<a id="v0.46.6"></a> -# [Release v0.46.6](https://github.com/vitorpamplona/amethyst/releases/tag/v0.46.6) - 2023-05-15 - - - -[Changes][v0.46.6] - - -<a id="v0.46.5"></a> -# [Release v0.46.5](https://github.com/vitorpamplona/amethyst/releases/tag/v0.46.5) - 2023-05-15 - - - -[Changes][v0.46.5] - - -<a id="v0.46.4"></a> -# [Release v0.46.4](https://github.com/vitorpamplona/amethyst/releases/tag/v0.46.4) - 2023-05-14 - - - -[Changes][v0.46.4] - - -<a id="v0.46.3"></a> -# [Release v0.46.3](https://github.com/vitorpamplona/amethyst/releases/tag/v0.46.3) - 2023-05-14 - -- [Avoids Notification duplicates](https://github.com/vitorpamplona/amethyst/commit/cf9395d5e3827df2b65eaf498a018a166bc91d79) -- [BugFix for non-english hashtags] by [@vitorpamplona](https://github.com/vitorpamplona) ([`d261003217`](https://github.com/vitorpamplona/amethyst/commit/d2610032172bb26083d61ae2b8a09378b001d565)) by [@vivganes](https://github.com/vivganes) - -[Changes][v0.46.3] - - -<a id="v0.46.2"></a> -# [Release v0.46.2](https://github.com/vitorpamplona/amethyst/releases/tag/v0.46.2) - 2023-05-14 - -- Improvements to Poll Caching system (Fixes: [#406](https://github.com/vitorpamplona/amethyst/issues/406)) -- Performance Improvements to Follow Lists - Turn FollowList into a ViewModel -- Allows the RichTextViewer to render before resolving urls. -- BugFix: Protects against exceptions when creating keys with invalid bytearrays. -- BugFix: [Failing to load a video thumbnail](https://github.com/vitorpamplona/amethyst/commit/fc2408b526a4c74c6cbbace43b41774da36b0aaa) -- [French translation](https://github.com/vitorpamplona/amethyst/commit/672380653414520da6a9f2a87f8f9011408fe32b) by [@judemont](https://github.com/judemont) -- [TOR Hungarian translation](https://github.com/vitorpamplona/amethyst/commit/93c3a49ecf3e1952897c1891a35ef4ef65c1ed5f) by [@ZsZolee](https://github.com/ZsZolee) - -[Changes][v0.46.2] - - -<a id="v0.46.1"></a> -# [Release v0.46.1: Notify buttons on reply](https://github.com/vitorpamplona/amethyst/releases/tag/v0.46.1) - 2023-05-11 - -- Displays People Lists as Notes in the Feed -- Improves reply screen to clarify which users will be notified -- Adds action button to join channels/users in the chat screen -- Isolates state changes in the profile page, avoiding recomposition -- General Performance Improvements - -[Changes][v0.46.1] - - -<a id="v0.46.0"></a> -# [Release v0.46.0: Tor Support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.46.0) - 2023-05-11 - -- Tor/Orbot support by [@greenart7c3](https://github.com/greenart7c3) -- Added Farsi translations by [@malimahda](https://github.com/malimahda) -- Updated Ukrainian and Russian translations by [@Radiokot](https://github.com/Radiokot) -- Updated Dutch translations by [@Bardesss](https://github.com/Bardesss) - -[Changes][v0.46.0] - - -<a id="v0.45.1"></a> -# [Release v0.45.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.45.1) - 2023-05-10 - -- [Fixing blank messages and Zapped DM content](https://github.com/vitorpamplona/amethyst/commit/c1d127ac1e251f37a72adec547f32692272ca977) -- [Don't try to decrypt if message is blank](https://github.com/vitorpamplona/amethyst/commit/434c1e9af324cbef64ea0a5c20e55e768c300219) -- [Capitalize notification sentence](https://github.com/vitorpamplona/amethyst/commit/0b2e5de7527384b4efef0d558269fdfdcb25f683) -- Performance Upgrades - -[Changes][v0.45.1] - - -<a id="v0.45.0"></a> -# [Release v0.45.0](https://github.com/vitorpamplona/amethyst/releases/tag/v0.45.0) - 2023-05-10 - - - -[Changes][v0.45.0] - - -<a id="v0.44.0"></a> -# [Release v0.44.0: Lists to Notifications](https://github.com/vitorpamplona/amethyst/releases/tag/v0.44.0) - 2023-05-08 - -- [Adding List Choice to Notifications](https://github.com/vitorpamplona/amethyst/commit/04c13003170f452839e2eee73cd364ea33805e0f) -- [Fixing tab sliders from jumping from one state to the other](https://github.com/vitorpamplona/amethyst/commit/4dcf38c492ca1c9ef541dccfe044552224c737cd) -- [Moving follows to the known list in DMs](https://github.com/vitorpamplona/amethyst/commit/81290f2b26b1487f55302a612ab8f0eccd200227) -- Performance upgrades - -[Changes][v0.44.0] - - -<a id="v0.43.2"></a> -# [Release v0.43.2: BugFix for NostrFiles.dev](https://github.com/vitorpamplona/amethyst/releases/tag/v0.43.2) - 2023-05-08 - - - -[Changes][v0.43.2] - - -<a id="v0.43.1"></a> -# [Release v0.43.1: NostrFiles.dev support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.43.1) - 2023-05-08 - - - -[Changes][v0.43.1] - - -<a id="v0.43.0"></a> -# [Release v0.43.0: Performance improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.43.0) - 2023-05-07 - - - -[Changes][v0.43.0] - - -<a id="v0.42.5"></a> -# [Release v0.42.5: Gradient removal](https://github.com/vitorpamplona/amethyst/releases/tag/v0.42.5) - 2023-05-05 - -- [Testing removing the gradient of quoted posts.](https://github.com/vitorpamplona/amethyst/commit/635279d59af22313d48a7d54509a9f66f5c04973) - -- [BugFix for displaying other people's list after loading them from the…](https://github.com/vitorpamplona/amethyst/commit/0c673e91c6dd8f5930015d20bb58ecb10da15b19) - -- [BugFix for the empty selection](https://github.com/vitorpamplona/amethyst/commit/3519e20a98d7c8191cf964a16a2310a774ea85ba) - -[Changes][v0.42.5] - - -<a id="v0.42.4"></a> -# [Release v0.42.4: BugFix: Private Zap decrypt without a Private Key](https://github.com/vitorpamplona/amethyst/releases/tag/v0.42.4) - 2023-05-05 - - - -[Changes][v0.42.4] - - -<a id="v0.42.2"></a> -# [Release v0.42.2: Bookmarks bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.42.2) - 2023-05-05 - - - -[Changes][v0.42.2] - - -<a id="v0.42.1"></a> -# [Release v0.42.1: BugFix for migration of Account lists](https://github.com/vitorpamplona/amethyst/releases/tag/v0.42.1) - 2023-05-05 - - - -[Changes][v0.42.1] - - -<a id="v0.42.0"></a> -# [Release v0.42.0: Private & Public Follow Lists](https://github.com/vitorpamplona/amethyst/releases/tag/v0.42.0) - 2023-05-05 - -- Private & Public Follow Lists in the top bar by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix for garden hashtag by [@believethehype](https://github.com/believethehype) - -[Changes][v0.42.0] - - -<a id="v0.41.0"></a> -# [Release v0.41.0: NFC, nostr.build, img proxy removal](https://github.com/vitorpamplona/amethyst/releases/tag/v0.41.0) - 2023-05-03 - -Amethyst 0.41.0 (alpha): NFC, Nostr.build uploader and Img Proxy removal test - -- Adds nostr.build uploader by [@believethehype](https://github.com/believethehype) -- Adds NFC support for NIP19 URIs by [@vitorpamplona](https://github.com/vitorpamplona) -- Updates Dutch translations by [@Bardesss](https://github.com/Bardesss) -- Removes image proxy for profiles (testing) by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix for tall images bleeding into other posts on Stories by [@vitorpamplona](https://github.com/vitorpamplona) - -Download: -- Play Edition with Translations: https://github.com/vitorpamplona/amethyst/releases/download/v0.41.0/amethyst-googleplay-universal-v0.41.0.apk -- F-Droid Edition without Translations: https://github.com/vitorpamplona/amethyst/releases/download/v0.41.0/amethyst-fdroid-universal-v0.41.0.apk - -[Changes][v0.41.0] - - -<a id="v0.40.6"></a> -# [Release v0.40.6 Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.40.6) - 2023-05-02 - -- BugFix to avoid blinking the url while loading images -- BugFix for drawing the border of the image without content -- BugFix on calculating the Zap amount without a Zap Event -- BugFix for video save to gallery tool -- BugFix videos of one page leaking to the next page - -[Changes][v0.40.6] - - -<a id="v0.40.5"></a> -# [Release v0.40.5: BugFix for volume controls](https://github.com/vitorpamplona/amethyst/releases/tag/v0.40.5) - 2023-05-02 - - - -[Changes][v0.40.5] - - -<a id="v0.40.4"></a> -# [Release v0.40.4: MP3 Support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.40.4) - 2023-05-02 - -Increases visibility of buttons in Stories. -Adds basic MP3 Support (regular, chats and stories) -Revamps search filters (it should be easier to find stuff now) -Adds dimensions to NIP-94,-95 Image files. - -[Changes][v0.40.4] - - -<a id="v0.40.3"></a> -# [Release v0.40.3:Adjustments and BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.40.3) - 2023-05-02 - -- BugFix for mixing NIP94 and NIP95 selections on the Video Feed -- BugFix for now showing the error message when the phone can't open the File -- Adds language to warn that NIP94 and 95 are new and other clients might not see it. -- Adds tamil translations - -[Changes][v0.40.3] - - -<a id="v0.40.2"></a> -# [Release v0.40.2: New Video Uploading and Playback](https://github.com/vitorpamplona/amethyst/releases/tag/v0.40.2) - 2023-05-01 - -- Adds "Seen on Relay" information for NIP-94 and 95 posts -- Adds custom video playback controls with autoplay starting on mute -- Adds video uploading progress UI -- Adds NIP-95 in-disk caching -- Adds Zap payment confirmations without the receiver having a Zap setup -- Animates to the top after posting in the Video feed -- Keeps position in the Video feed between screen -- Fixes the Crossfade for BlurHash on NIP94 and NIP95 events - -[Changes][v0.40.2] - - -<a id="v0.40.1"></a> -# [Release v0.40.1: BugFix for the Nostr Report](https://github.com/vitorpamplona/amethyst/releases/tag/v0.40.1) - 2023-05-01 - - - -[Changes][v0.40.1] - - -<a id="v0.40.0"></a> -# [Release v0.40.0: Global Video/Image Feed](https://github.com/vitorpamplona/amethyst/releases/tag/v0.40.0) - 2023-04-29 - - - -[Changes][v0.40.0] - - -<a id="v0.38.0"></a> -# [Release v0.38.0: Highlighter Events](https://github.com/vitorpamplona/amethyst/releases/tag/v0.38.0) - 2023-04-28 - -Adds support for Highlighter.com - -[Changes][v0.38.0] - - -<a id="v0.37.4"></a> -# [Release v0.37.4: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.37.4) - 2023-04-27 - -- [Adjusts size of the Zap Forward text.](https://github.com/vitorpamplona/amethyst/commit/f173f3194bedd4e6ab418f0acb04b057553d9dc9) -- [Avoids flickering in the Global Feed](https://github.com/vitorpamplona/amethyst/commit/8ca63f32f45e6b227b2a993eb92fe1115251acb6) - -[Changes][v0.37.4] - - -<a id="v0.37.3"></a> -# [Release v0.37.3: Trying to solve the random crash](https://github.com/vitorpamplona/amethyst/releases/tag/v0.37.3) - 2023-04-27 - -[Reverting compose to 1.4.2](https://github.com/vitorpamplona/amethyst/commit/1ae785061e81ec9c84c754e6d3af40a26c518fde) -[Add logs for fail to decrypt zap](https://github.com/vitorpamplona/amethyst/commit/ddba4ac5100691244f96aaac7798061580a87f55) -[Bugfix for clickable url](https://github.com/vitorpamplona/amethyst/commit/0fe6bd6c8372f58bebf238ec207479d5bb051e7b) -[Reducing the amount of filters to send to relays to 10](https://github.com/vitorpamplona/amethyst/commit/925289ae64b7921b07dca97e682f65170195c63d) - -[Changes][v0.37.3] - - -<a id="v0.37.2"></a> -# [Release v0.37.2: non-english hashtags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.37.2) - 2023-04-27 - -- Adds support for non-english hashtags by [@vivganes](https://github.com/vivganes) -- Improves translations of NIP-94/95 servers by [@vitorpamplona](https://github.com/vitorpamplona) -- Correctly copy-pastes NIP94 and NIP95 addresses by [@vitorpamplona](https://github.com/vitorpamplona) -- Updates dependencies by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.37.2] - - -<a id="v0.37.1"></a> -# [Release v0.37.1: Zap Forwarding](https://github.com/vitorpamplona/amethyst/releases/tag/v0.37.1) - 2023-04-27 - -- Adds Zap Address preference to the `zap` event tag -- Zaps note-tags before user info -- Enables raw URL uploads with warnings. -- Prunes NIP-95 data from memory when pausing the app. - - -[Changes][v0.37.1] - - -<a id="v0.37.0"></a> -# [Release v0.37.0: NIP-95 (Image upload to relays)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.37.0) - 2023-04-26 - -Adds popup to choose which image server to use by [@vitorpamplona](https://github.com/vitorpamplona) -Adds choice of NostrImg.com by [@vitorpamplona](https://github.com/vitorpamplona) -Adds choice of NIP-95 relays by [@vitorpamplona](https://github.com/vitorpamplona) -BugFix for AUTH w/ Nostr.wine by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.37.0] - - -<a id="v0.36.0"></a> -# [Release v0.36.0: Support for private relays (NIP-42)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.36.0) - 2023-04-26 - -- Adds automatic authentication for private relays implementing NIP-42 by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.36.0] - - -<a id="v0.35.1"></a> -# [Release v0.35.1: Private Zaps on Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v0.35.1) - 2023-04-25 - -- Adds support for poll votes with Private Zaps by [@vitorpamplona](https://github.com/vitorpamplona) -- Marks all Zaps to be private by default by [@vitorpamplona](https://github.com/vitorpamplona) -- Activates gif preview and translations in Zap Messages by [@vitorpamplona](https://github.com/vitorpamplona) -- Improves the performance and UI of Private Zap notifications by [@vitorpamplona](https://github.com/vitorpamplona) -- Improves the performance of FileHeader loading by [@vitorpamplona](https://github.com/vitorpamplona) -- Opens image dialog in the page of the current selection by [@vitorpamplona](https://github.com/vitorpamplona) -- Improves configuration screen for Zap types (double click and click and hold) by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds Preview for quote noting by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds New Hungarian translations by [@ZsZolee](https://github.com/ZsZolee) -BugFix for NIP-47 scheme check by [@kiwiidb](https://github.com/kiwiidb) - -[Changes][v0.35.1] - - -<a id="v0.35.0"></a> -# [Release v0.35.0: Accessible Images/Videos](https://github.com/vitorpamplona/amethyst/releases/tag/v0.35.0) - 2023-04-25 - -Adds support for NIP-94 video and image descriptions for accessibility by [@vitorpamplona](https://github.com/vitorpamplona) -Adds confirmation screen before uploading an image/video by [@vitorpamplona](https://github.com/vitorpamplona) -Deprecates #[n]s tags creation by [@vitorpamplona](https://github.com/vitorpamplona) -Updates Wallet Connect API (JSON-RPC) by [@vitorpamplona](https://github.com/vitorpamplona) -Adds Private zaps notifications with messages by [@believethehype](https://github.com/believethehype) -Adds Basic Private Zap support by [@believethehype](https://github.com/believethehype) -Adds Content previews (any kind: images, videos, posts) on the new post screen by [@vitorpamplona](https://github.com/vitorpamplona) -Adds New custom hashtags for #grownostr and #footstr by [@believethehype](https://github.com/believethehype) -Adds Material You's adaptive icons by [@jltdhome](https://github.com/jltdhome) - -[Changes][v0.35.0] - - -<a id="v0.34.1"></a> -# [Release v0.34.1: BugFixes for Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v0.34.1) - 2023-04-21 - - - -[Changes][v0.34.1] - - -<a id="v0.34.0"></a> -# [Release v0.34.0: NIP-94 Support for Uploads](https://github.com/vitorpamplona/amethyst/releases/tag/v0.34.0) - 2023-04-21 - -- All uploads now create NIP-94 events. - -[Changes][v0.34.0] - - -<a id="v0.33.2"></a> -# [Release v0.33.2: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.33.2) - 2023-04-21 - -BugFix for [#359](https://github.com/vitorpamplona/amethyst/issues/359) -BugFix for [#361](https://github.com/vitorpamplona/amethyst/issues/361) by [@vivganes](https://github.com/vivganes) -Updated NL Translations by [@Bardesss](https://github.com/Bardesss) - - -[Changes][v0.33.2] - - -<a id="v0.33.1"></a> -# [Release v0.33.1: Notification dots on additive filters](https://github.com/vitorpamplona/amethyst/releases/tag/v0.33.1) - 2023-04-20 - - - -[Changes][v0.33.1] - - -<a id="v0.33.0"></a> -# [Release v0.33.0: Additive Filters](https://github.com/vitorpamplona/amethyst/releases/tag/v0.33.0) - 2023-04-20 - - - -[Changes][v0.33.0] - - -<a id="v0.32.3"></a> -# [Release v0.32.3: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.32.3) - 2023-04-07 - -You might see more items (quotes, invoices, reply information) being loaded on-demand, changing the view as it loads. Let me know if this isn't too annoying. - -[Changes][v0.32.3] - - -<a id="v0.32.2"></a> -# [Release v0.32.2: Create posts with LN Invoice](https://github.com/vitorpamplona/amethyst/releases/tag/v0.32.2) - 2023-04-06 - -- Adds Note preview in Chat and DMs -- Adds tagging for Notes and Users in Chats -- Adds LN invoice creation in new posts -- Adds notifications for profile zaps -- Adds private replies of DMs in Notifications - -[Changes][v0.32.2] - - -<a id="v0.32.1"></a> -# [Release v0.32.1: BugFixes for Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v0.32.1) - 2023-04-04 - -Adds payment animation to Polls by [@vitorpamplona](https://github.com/vitorpamplona) -Fixes Voted option highlight by [@vitorpamplona](https://github.com/vitorpamplona) -Fixes Poll Color for Light mode by [@vitorpamplona](https://github.com/vitorpamplona) -Fixes margins when images are used in the poll by [@vitorpamplona](https://github.com/vitorpamplona) -Makes sure before and after voting the padding is the same by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.32.1] - - -<a id="v0.32.0"></a> -# [Release v0.32.0: Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v0.32.0) - 2023-04-04 - -- Adds NIP 69 (Polls) support by [@npub1mgxvsg25hh6vazl5zl4295h6nx4xtjtvw7r86ejklnxmlrncrvvqdrffa5](https://github.com/npub1mgxvsg25hh6vazl5zl4295h6nx4xtjtvw7r86ejklnxmlrncrvvqdrffa5) -- Adds Show QR option to Profile screen by [@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z](https://github.com/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z) - -[Changes][v0.32.0] - - -<a id="v0.31.4"></a> -# [Release v0.31.4: Login with QR](https://github.com/vitorpamplona/amethyst/releases/tag/v0.31.4) - 2023-04-03 - -- Login with QR by [@greenart7c3](https://github.com/greenart7c3) -- Adds full nip19 parsing on key decoding capabilities by [@vitorpamplona](https://github.com/vitorpamplona) -- Speeds up RichText rendering parsers by [@vitorpamplona](https://github.com/vitorpamplona) -- added message to LnZapEvent and interface by [@believethehype](https://github.com/believethehype) -- Faster processing of Contact List events by [@vitorpamplona](https://github.com/vitorpamplona) -- Faster user in follow list algo by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.31.4] - - -<a id="v0.31.3"></a> -# [Release v0.31.3: Adds post quotes to Notifications](https://github.com/vitorpamplona/amethyst/releases/tag/v0.31.3) - 2023-03-31 - - - -[Changes][v0.31.3] - - -<a id="v0.31.2"></a> -# [Release v0.31.2: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.31.2) - 2023-03-31 - -BugFixes: -- "author is elon" post with a regex taking over a second to parse. -- thread loading with children of the selected post not loading correctly. - -[Changes][v0.31.2] - - -<a id="v0.31.0"></a> -# [Release v0.31.0: Wallet Connect onboarding with Alby](https://github.com/vitorpamplona/amethyst/releases/tag/v0.31.0) - 2023-03-31 - -- Avoids reloading information when the app resumes by [@vitorpamplona](https://github.com/vitorpamplona) -- Full Wallet Connect onboarding with Alby by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds PoW Display to the MasterNote of a Thread by [@vitorpamplona](https://github.com/vitorpamplona) -- Changes the download folder to remove Amethyst's version by [@vitorpamplona](https://github.com/vitorpamplona) -- Activates public relays for chats by [@vitorpamplona](https://github.com/vitorpamplona) -- Saves the position of the Notification feed by [@vitorpamplona](https://github.com/vitorpamplona) -- Breaks notification cards in chunks of 50 notifications each by [@vitorpamplona](https://github.com/vitorpamplona) -- Custom Zaps Hungarian Translation by [@ZsZolee](https://github.com/ZsZolee) -- Fixes thread loading issue where the clicked note's children is not loaded by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix for some events not loading by [@vitorpamplona](https://github.com/vitorpamplona) -- Updates search results as new notes come in by [@vitorpamplona](https://github.com/vitorpamplona) -- Refactoring EOSEs to consider account switching in the app by [@vitorpamplona](https://github.com/vitorpamplona) -- Centers the loading icon for image previews by [@vitorpamplona](https://github.com/vitorpamplona) -- Dependency updates by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.31.0] - - -<a id="v0.30.2"></a> -# [Release v0.30.2: Custom Zap amounts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.30.2) - 2023-03-29 - -- Adds double-click to custom Zap amounts and messages by [@Believethehype](https://github.com/Believethehype) -- Adds image upload on profile editing page by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds PoW Display to notes by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds additional Hungarian Translation by [@ZsZolee](https://github.com/ZsZolee) -- More forgiving Wallet Connect interface by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds image url with loading icon when previews fail/take too long by [@vitorpamplona](https://github.com/vitorpamplona) -- Increases performance of the bottom navigation by [@vitorpamplona](https://github.com/vitorpamplona) -- Increases performance of relay icon rendering by [@vitorpamplona](https://github.com/vitorpamplona) -- Changes relay byte count to count all messages from relays, including errors by [@vitorpamplona](https://github.com/vitorpamplona) -- Makes the Scan QR Button available in smaller screens by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix: Long names/usernames in the Profile Page breaking the layout by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix: Stick reply setup from Gigi by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix: Sticky notifications on account change by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix: vertical position of the text in short messages with expandable content by [@vitorpamplona](https://github.com/vitorpamplona) -- BugFix for cutting links in half when they are the last word in a post by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.30.2] - - -<a id="v0.30.1"></a> -# [Release v0.30.1: Extra private key for Wallet Connect](https://github.com/vitorpamplona/amethyst/releases/tag/v0.30.1) - 2023-03-27 - -- Adds an extra secret to Zap Payments with Wallet Connect -- Adds a "Follow Back" button on user profiles by [@believethehype](https://github.com/believethehype) - -[Changes][v0.30.1] - - -<a id="v0.30.0"></a> -# [Release v0.30.0 EOSE and Invalidate Cache refactoring](https://github.com/vitorpamplona/amethyst/releases/tag/v0.30.0) - 2023-03-26 - -- Improves the use of EOSE events to avoid re-downloads -- Refactors invalidate cache to allow immediate and spaced-out screen updates -- Preloads URL Previews to avoid jittering when loading -- Remembers video playback functions to avoid jittering -- Fixes feed position when looking at a note and coming back -- Updated Chinese (Traditional) Translation by [@miseelu](https://github.com/miseelu) -- Refines the look to the Zap animation -- BugFix for nostr: clickable uris -- Videos now go full screen. - -[Changes][v0.30.0] - - -<a id="v0.29.2"></a> -# [Release v0.29.2 #SkullofSatoshi](https://github.com/vitorpamplona/amethyst/releases/tag/v0.29.2) - 2023-03-24 - -#SkullofSatoshi by [@believethehype](https://github.com/believethehype) - -[Changes][v0.29.2] - - -<a id="v0.29.1"></a> -# [Release v0.29.1 `R` Tags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.29.1) - 2023-03-24 - -Adds Support for `r` tags by [@vitorpamplona](https://github.com/vitorpamplona) -Adds support for lightning withdrawal links by [@lgleasain](https://github.com/lgleasain) -Adds missing pt-br translations by [@greenart7c3](https://github.com/greenart7c3) -Adds support for Translateable RichText on About Me by [@vitorpamplona](https://github.com/vitorpamplona) -Adds an image carousel to image dialog by [@vitorpamplona](https://github.com/vitorpamplona) -Adds Boosts to Profile Notes by [@vitorpamplona](https://github.com/vitorpamplona) -BugFix for nostr: scheme translations by [@vitorpamplona](https://github.com/vitorpamplona) -BugFix for unknown nevent1 post sending to profile by [@vitorpamplona](https://github.com/vitorpamplona) -BugFix for pledged sats bolt light up by [@vitorpamplona](https://github.com/vitorpamplona) -Migrates per-relay stats from event counter to bytes transmitted by [@vitorpamplona](https://github.com/vitorpamplona) -Simplifes reply view on Notifications by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.29.1] - - -<a id="v0.29.0"></a> -# [Release v0.29.0: Bounties & Wallet Connect API](https://github.com/vitorpamplona/amethyst/releases/tag/v0.29.0) - 2023-03-23 - -- Support for Bounties by [@vitorpamplona](https://github.com/vitorpamplona) -- Support to Pledge in bounties by [@vitorpamplona](https://github.com/vitorpamplona) -- Support for video uploads by [@vitorpamplona](https://github.com/vitorpamplona) -- Support for Wallet Connect API by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds progress bars when Zapping by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds user's banner and profile picture to LongFormPosts if none is found by [@vitorpamplona](https://github.com/vitorpamplona) -- Displays all hashtags at the bottom of each event by [@vitorpamplona](https://github.com/vitorpamplona) -- Checks if the requested amount to matches the received LN Invoice's amount by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds 2 additional popular community hashtagicons by by [@Believethehype](https://github.com/Believethehype) - -[Changes][v0.29.0] - - -<a id="v0.28.1"></a> -# [Release v0.28.1: Bug Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.28.1) - 2023-03-21 - - - -[Changes][v0.28.1] - - -<a id="v0.28.0"></a> -# [Release v0.28.0: Bookmarks](https://github.com/vitorpamplona/amethyst/releases/tag/v0.28.0) - 2023-03-20 - -NIP-51 Public and Private Bookmarks by [@vitorpamplona](https://github.com/vitorpamplona) -Adds Translations for Estonia by [@chiajlingvoj](https://github.com/chiajlingvoj) -Fixes URL guesser by [@vitorpamplona](https://github.com/vitorpamplona) -Fixes zap amount recalculation by [@vitorpamplona](https://github.com/vitorpamplona) -Fixes disappearing messages in chat by [@vitorpamplona](https://github.com/vitorpamplona) -Fixes tag links that did not end with a space by [@vitorpamplona](https://github.com/vitorpamplona) -User-Agent fix [@hsoc](https://github.com/hsoc) -More custom hashtags by [@Believethehype](https://github.com/Believethehype) -Update on Russian and Ukrainian translations by [@radiokot](https://github.com/radiokot) - -[Changes][v0.28.0] - - -<a id="v0.27.2"></a> -# [Release v0.27.2: Custom hashtags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.27.2) - 2023-03-17 - -Makes hashtags case insensitive by [@vitorpamplona](https://github.com/vitorpamplona) -Adds special #Bitcoin hashtag icon by [@Believethehype](https://github.com/Believethehype) -Adds special #Nostr hashtag Icon by [@Believethehype](https://github.com/Believethehype) -Minor changes in Hungarian Translations by [@ZsZolee](https://github.com/ZsZolee) -Improves NIP-19 Parsing in content by [@vitorpamplona](https://github.com/vitorpamplona) -Disables online search after leaving the Search screen by [@vitorpamplona](https://github.com/vitorpamplona) -Fixes Background color of the badge's description [@vitorpamplona](https://github.com/vitorpamplona) -Fixes some missing messages on chat by [@vitorpamplona](https://github.com/vitorpamplona) - - -[Changes][v0.27.2] - - -<a id="v0.27.1"></a> -# [Release v0.27.1: Follow Hashtags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.27.1) - 2023-03-16 - - - -[Changes][v0.27.1] - - -<a id="v0.27.0"></a> -# [Release v0.27.0: Hashtags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.27.0) - 2023-03-15 - -- Adds Support for Hashtags by [@vitorpamplona](https://github.com/vitorpamplona) -- Shows LongForm Texts on Global by [@vitorpamplona](https://github.com/vitorpamplona) -- Prunes contact lists when pausing by [@vitorpamplona](https://github.com/vitorpamplona) -- Fixes the deletion of private messages by [@vitorpamplona](https://github.com/vitorpamplona) -- Shows url host in link previews by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds the Follow verification icon to the chat header by [@vitorpamplona](https://github.com/vitorpamplona) -- Performance Improments across the app by [@vitorpamplona](https://github.com/vitorpamplona) -- Avoids breaking urls and invoices when cutting an expandable content by [@vitorpamplona](https://github.com/vitorpamplona) -- Implements a faster Author Gallery for notifications by [@vitorpamplona](https://github.com/vitorpamplona) -- Reduces badge size in Profiles by [@vitorpamplona](https://github.com/vitorpamplona) -- Makes the Quick Action menu less starkey in dark theme by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds Block button and Dialog by [@maxmoney21m](https://github.com/maxmoney21m) -- Adds Report Dialog w/ Message by [@maxmoney21m](https://github.com/maxmoney21m) - - -[Changes][v0.27.0] - - -<a id="v0.26.2"></a> -# [Release v0.26.2: F-Droid Reproducible Build feature](https://github.com/vitorpamplona/amethyst/releases/tag/v0.26.2) - 2023-03-15 - - - -[Changes][v0.26.2] - - -<a id="v0.26.1"></a> -# [Release v0.26.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.26.1) - 2023-03-14 - -Fixes app crashing when a non-Hex value is found in Contact Lists -Fixes app crashing when a duplicated follow is found in Contact Lists - -[Changes][v0.26.1] - - -<a id="v0.26.0"></a> -# [Release v0.26.0: Performance Upgrades](https://github.com/vitorpamplona/amethyst/releases/tag/v0.26.0) - 2023-03-14 - -Restructuring User/Note index cache to use less memory, more cpu - -[Changes][v0.26.0] - - -<a id="v0.25.3"></a> -# [Release v0.25.3: De-Googled F-Droid Flavor](https://github.com/vitorpamplona/amethyst/releases/tag/v0.25.3) - 2023-03-14 - - - -[Changes][v0.25.3] - - -<a id="v0.25.2"></a> -# [Release v0.25.2: Image Upload in chats](https://github.com/vitorpamplona/amethyst/releases/tag/v0.25.2) - 2023-03-14 - -- Image Upload in Chat by [@believethehype](https://github.com/believethehype) and [@vitorpamplona](https://github.com/vitorpamplona) -- Bug fix for logging off crashing the app. by [@vitorpamplona](https://github.com/vitorpamplona) -- Bug fix to reset conversations tab when switching accounts. by [@maxmoney21m](https://github.com/maxmoney21m) -- Bug fix for the background color of the top bar in light mode by [@maxmoney21m](https://github.com/maxmoney21m) -- Updated Dutch translations by [@Bardesss](https://github.com/Bardesss) - -[Changes][v0.25.2] - - -<a id="v0.25.1"></a> -# [Release v0.25.1: Performance Upgrades](https://github.com/vitorpamplona/amethyst/releases/tag/v0.25.1) - 2023-03-14 - -- Faster Loading times for placeholders -- Reverted DropDown menu in chats -- Bug fix for the style of the Image Upload button before permissions are granted. - -[Changes][v0.25.1] - - -<a id="v0.25.0"></a> -# [Release v0.25.0: Multiple account management](https://github.com/vitorpamplona/amethyst/releases/tag/v0.25.0) - 2023-03-13 - -Adds user-agent header to OkHttpClient requests for NIP-05 Verifications by [@maxmoney21m](https://github.com/maxmoney21m) -Adds Multiple account management by [@maxmoney21m](https://github.com/maxmoney21m) -Replaces mlkit with zxing QR scanner by [@maxmoney21m](https://github.com/maxmoney21m) -Adds a way to remember feed positioning [@maxmoney21m](https://github.com/maxmoney21m) -Adds Robohash profile generation using SVGs by [@maxmoney21m](https://github.com/maxmoney21m) -Adds quick action menu in chats and threads by [@maxmoney21m](https://github.com/maxmoney21m) -Updated Dutch translations by [@matata2140](https://github.com/matata2140) and [@Bardesss](https://github.com/Bardesss) -Corrects identity claim for mastodon by [@h3y6e](https://github.com/h3y6e) -Corrects translation files by [@akiomik](https://github.com/akiomik) -Refactorings by [@Chemaclass](https://github.com/Chemaclass) and [@kappaseijin](https://github.com/kappaseijin) - -[Changes][v0.25.0] - - -<a id="v0.24.2"></a> -# [Release v0.24.2 NIP-39 support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.24.2) - 2023-03-10 - -NIP-39 Support by [@vitorpamplona](https://github.com/vitorpamplona) -Add Taiwanese translations by [@maxmoney21m](https://github.com/maxmoney21m) -BugFix: Scrollable Select screen by [@maxmoney21m](https://github.com/maxmoney21m) -Adds missing ES translations by [@Chemaclass](https://github.com/Chemaclass) -Adds missing Portuguese translations by [@greenart7c3](https://github.com/greenart7c3) -Shows private message notifications by [@vitorpamplona](https://github.com/vitorpamplona) -Upgrades encrypted storage by [@Chemaclass](https://github.com/Chemaclass) -Moves MarkAsRead write and read to IO context by [@vitorpamplona](https://github.com/vitorpamplona) -Moves Zap amount Calculations in cache and into an IO context by [@vitorpamplona](https://github.com/vitorpamplona) -BugFix: Delays in updating UserProfile feeds by [@vitorpamplona](https://github.com/vitorpamplona) -Adds a Reporting menu option to badge notifications by [@vitorpamplona](https://github.com/vitorpamplona) -Uses full banner image sizes on User Profile pages and Drawer by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.24.2] - - -<a id="v0.24.1"></a> -# [Release v0.24.1: Translations and Share Post](https://github.com/vitorpamplona/amethyst/releases/tag/v0.24.1) - 2023-03-09 - -Adds Chinese (Traditional) Translation by [@miseelu](https://github.com/miseelu) -Adds Chinese (Simplified) Translation by [@miseelu](https://github.com/miseelu) -Adds Hungarian Translation by [@ZsZolee](https://github.com/ZsZolee) -Adds Turkish Translation by [@detherminal](https://github.com/detherminal) -Adds Share Post option with a Snort link by [@maxmoney21m](https://github.com/maxmoney21m) -Adds PubKey display in user profile by [@vitorpamplona](https://github.com/vitorpamplona) -Adds New QuickAction Menu (click and hold) by [@maxmoney21m](https://github.com/maxmoney21m) -Adds Lint enforcement and several code quality improvements by [@Chemaclass](https://github.com/Chemaclass) -Updated Russian and Ukrainian translations by [@Radiokot](https://github.com/Radiokot) -BugFix for Biometric support in older phones by [@maxmoney21m](https://github.com/maxmoney21m) -BugFix for LN invoices previews with under 350 chars by [@maxmoney21m](https://github.com/maxmoney21m) -BugFix for Badges not showing up in some profiles by [@vitorpamplona](https://github.com/vitorpamplona) -BugFix for Clickable addresses (naddr) and support for [@naddr](https://github.com/naddr) when writing a post by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.24.1] - - -<a id="v0.24.0"></a> -# [Release v0.24.0: Support for Badges (NIP-58)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.24.0) - 2023-03-05 - -Read only support for Badges (NIP-58) [@vitorpamplona](https://github.com/vitorpamplona) -Allow zooming on profile picture and banner [@maxmoney21m](https://github.com/maxmoney21m) -Refactor ui/dal Filters [@Chemaclass](https://github.com/Chemaclass) -Fixes showing the R.string.first id instead of text [@vitorpamplona](https://github.com/vitorpamplona) -Fixes result order in the search screen [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.24.0] - - -<a id="v0.23.1"></a> -# [Release v0.23.1: Bug Fix for Image Upload crash](https://github.com/vitorpamplona/amethyst/releases/tag/v0.23.1) - 2023-03-05 - - - -[Changes][v0.23.1] - - -<a id="v0.23.0"></a> -# [Release v0.23.0: Search NIP-50](https://github.com/vitorpamplona/amethyst/releases/tag/v0.23.0) - 2023-03-05 - -- Support for NIP-50: Search by [@vitorpamplona](https://github.com/vitorpamplona), thanks to [@brugeman](https://github.com/brugeman) and [@EvgenyPlaksin](https://github.com/EvgenyPlaksin) for the first draft on this feature -- "Mark all messages as Read" feature by [@maxmoney21m](https://github.com/maxmoney21m). Let's see [@derekross](https://github.com/derekross) approves the feature and bounty -- Refactoring by [@Chemaclass](https://github.com/Chemaclass) -- Small performance improvements by [@vitorpamplona](https://github.com/vitorpamplona) -- Require biometrics to export private keys by [@maxmoney21m](https://github.com/maxmoney21m) -- Support for relative paths on preview card's `OG:IMAGE` tags by [@vitorpamplona](https://github.com/vitorpamplona) -- Changes the navigation bar's magnifying glass icon to globe by [@maxmoney21m](https://github.com/maxmoney21m) -- French translation by [@bkBrunobk](https://github.com/bkBrunobk) -- Add Password Managers autofill support to the login screen by [@Radiokot](https://github.com/Radiokot) - -[Changes][v0.23.0] - - -<a id="v0.22.3"></a> -# [Release v0.22.3: BugFix for Snort's relay](https://github.com/vitorpamplona/amethyst/releases/tag/v0.22.3) - 2023-03-04 - -- Performance improvements by [@vitorpamplona](https://github.com/vitorpamplona) -- NIP19 Refactoring by [@Chemaclass](https://github.com/Chemaclass) -- Delete Origin Header as a bug fix of the bugfix for Snort's relay by [@vitorpamplona](https://github.com/vitorpamplona) -- RTL Improvements by [@rashedswen](https://github.com/rashedswen) - -[Changes][v0.22.3] - - -<a id="v0.22.2"></a> -# [Release v0.22.2: Backup Keys](https://github.com/vitorpamplona/amethyst/releases/tag/v0.22.2) - 2023-03-03 - -- Moves nsec backup to Drawer and Dialog, organize Drawer by [@maxmoney21m](https://github.com/maxmoney21m) -- Migrates underlying architecture to avoid using memory-inefficient ByteArrays to store Nostr Events by [@vitorpamplona](https://github.com/vitorpamplona) -- Adds Spanish translations by [@Chemaclass](https://github.com/Chemaclass) and [@JesusValera](https://github.com/JesusValera) -- Displays Group Info in the timeline for quotes of the Creation Event. by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.22.2] - - -<a id="v0.22.1"></a> -# [Release v0.22.1: Replaceable Events NIP-33](https://github.com/vitorpamplona/amethyst/releases/tag/v0.22.1) - 2023-03-03 - -- Adds a Follow Action in a Note's Menu by [@rashedswen](https://github.com/rashedswen) -- Support for Replaceable Events (NIP-33) by [@vitorpamplona](https://github.com/vitorpamplona) -- Add Ukrainian (uk) translation [@Radiokot](https://github.com/Radiokot) -- Add Russian (ru) translation by [@Radiokot](https://github.com/Radiokot) -- Fixes Preview Error when the url is invalid by [@vitorpamplona](https://github.com/vitorpamplona) -- Brazilian translations correction by [@marioaugustorama](https://github.com/marioaugustorama) - -[Changes][v0.22.1] - - -<a id="v0.22.0"></a> -# [Release v0.22.0: Markdown & Long Content (NIP-23)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.22.0) - 2023-03-02 - -Markdown Support by [@vitorpamplona](https://github.com/vitorpamplona) -Long-form Content (NIP-23) Support by [@vitorpamplona](https://github.com/vitorpamplona) - -[Changes][v0.22.0] - - -<a id="v0.21.2"></a> -# [Release v0.21.2: BugFix for Arabic](https://github.com/vitorpamplona/amethyst/releases/tag/v0.21.2) - 2023-03-01 - -- Hides hidden users from main feeds. -- Lowers the speed of the updates of the feed. -- Fix for a String.format on robohash crashing the app in Arabic - -[Changes][v0.21.2] - - -<a id="v0.21.1"></a> -# [Release v0.21.1](https://github.com/vitorpamplona/amethyst/releases/tag/v0.21.1) - 2023-03-01 - -Amethyst v0.21.1: Peace - -- Reply Notifications only show when: - 1. Reply directly cites the user in the reply note - 2. Reply to an event authored by the user -- Internationalization - 1. Arabic translations [@rashedswen](https://github.com/rashedswen) - 2. Portuguese-Brazil translations by [@greenart7c3](https://github.com/greenart7c3) -- Fixes the size of all pictures using the proxy to 200 -- Adds an Origin header to connect with Snort's relay. - - - -[Changes][v0.21.1] - - -<a id="v0.20.5"></a> -# [Release v0.20.5: PlayStore Re-submit](https://github.com/vitorpamplona/amethyst/releases/tag/v0.20.5) - 2023-03-01 - - - -[Changes][v0.20.5] - - -<a id="v0.20.4"></a> -# [Release v0.20.4: Updating Reports to the latest spec](https://github.com/vitorpamplona/amethyst/releases/tag/v0.20.4) - 2023-03-01 - - - -[Changes][v0.20.4] - - -<a id="v0.20.3"></a> -# [Release v0.20.3: Merge Notification Types](https://github.com/vitorpamplona/amethyst/releases/tag/v0.20.3) - 2023-02-28 - - - -[Changes][v0.20.3] - - -<a id="v0.20.2"></a> -# [Release v0.20.2: Conversations++](https://github.com/vitorpamplona/amethyst/releases/tag/v0.20.2) - 2023-02-27 - -Filtering Notifications to the events that cite the user directly (experimental). -Showing direct reply as a Quote. -Marks as read regardless of note.event status. -ThreadView avoids double loading. -Clearly marks the clicked item on ThreadView -Allows users to delete usernames -Adds nip05 to Chatroom headers. -Adds time of the boost and menu options for the boosted event. - -[Changes][v0.20.2] - - -<a id="v0.20.1"></a> -# [Release v0.20.1: BugFix for invalid NIP05 addresses crashing the app](https://github.com/vitorpamplona/amethyst/releases/tag/v0.20.1) - 2023-02-27 - -1. BugFix for invalid nip05 domain names crashing the app. -2. Removing _ from the display -3. Checks if NIP 05 is an email before starting to verify to avoid crashes. -4. BugFix for long NIP05 taking multiple lines. -5. Adds NIP05 to the main note in a thread - -[Changes][v0.20.1] - - -<a id="v0.20.0"></a> -# [Release v0.20.0: Nip05 Support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.20.0) - 2023-02-27 - - - -[Changes][v0.20.0] - - -<a id="v0.19.1"></a> -# [Release v0.19.1: Crops reply information to 2](https://github.com/vitorpamplona/amethyst/releases/tag/v0.19.1) - 2023-02-26 - -1. Crops Reply Information to 2, follows first, expandable to see others -2. Hides bottom navigation bar when keyboard is visible. - -[Changes][v0.19.1] - - -<a id="v0.19.0"></a> -# [Release v0.19.0: NIP-09 Event Deletion support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.19.0) - 2023-02-26 - -Event Deletion works with TextNotes, Likes, Boosts, and Reports. - -[Changes][v0.19.0] - - -<a id="v0.18.3"></a> -# [Release v0.18.3: Displaying Error messages when Zaps fail](https://github.com/vitorpamplona/amethyst/releases/tag/v0.18.3) - 2023-02-26 - -- Displaying Error messages when Zaps fail -- Animating the appearance of the reply note when replying in chats. - - -[Changes][v0.18.3] - - -<a id="v0.18.2"></a> -# [Release v0.18.2: Multiple Zaps per post](https://github.com/vitorpamplona/amethyst/releases/tag/v0.18.2) - 2023-02-25 - -- Improvements to ShowMore background gradient and size of bubbles in chat. -- Fixes Ugly Tab Background color in the light theme -- Removes the block on Multiple Zaps in a single post (copy paste from the like button, that has a block to avoid multiple likes) -- Fixes liveSet to delete. | Solves the outofsync error chat has been reporting. -- Synchronizes `Client.connect` to avoid random number in relay status -- Fixes the URL detector for account for 2 special characters in Chinese "," and "。" -- Reverts fixing the English language to avoid issues with translation -- Updating dependencies - - -[Changes][v0.18.2] - - -<a id="v0.18.1"></a> -# [Release v0.18.1: Know your spammers](https://github.com/vitorpamplona/amethyst/releases/tag/v0.18.1) - 2023-02-24 - -- New Relay Info UI displays the number of messages that were duplicated and deleted per relay. -- Improvements to Home feed update speed (75ms -> 10ms) -- Correctly deletes a slave note -- Updates observers when deleting notes -- Improved timing log -- Home doesn't need isAcceptable filter since all posts are coming from follows. Much faster. -- BugFix Loading one thread after another crashed the app -- Logs Spam count per relay. -- Removing PlayServices tag to import qrcode model -- Using language identification model bundled into the app -- Moving barcode scanning model to ship with the app. - -[Changes][v0.18.1] - - -<a id="v0.18.0"></a> -# [Release v0.18.0: Zaps, Replies and Likes in Public Chats and Private Messages](https://github.com/vitorpamplona/amethyst/releases/tag/v0.18.0) - 2023-02-24 - -Zaps in Private Messages are still public, though. - -- Zaps, Likes, and Replies in Public Chats and Private Messages -- Fixes bug when the note is not included in the returning set for ThreadView -- Fixing [#149](https://github.com/vitorpamplona/amethyst/issues/149) - -[Changes][v0.18.0] - - -<a id="v0.17.11"></a> -# [Release v0.17.11: Language Preferences](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.11) - 2023-02-24 - -Save translation preferences between each language pair -Displays App version in the drawer -Makes the Drawer's Name/Follower count clickable -Fixes Zap amount rounding error. - -[Changes][v0.17.11] - - -<a id="v0.17.10"></a> -# [Release v0.17.10: New Zap Amount Customization screen](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.10) - 2023-02-24 - -New Zap Amount Customization screen -ThreadView: Avoids changing the position of the list when the list updates. -Marking the selected post in the thread view -App Language fixed to English for now ([@Radiokot](https://github.com/Radiokot)) -Properly removes a note from the database on memory trimming -Uses a Name to make the translation layer skip ln addresses -Fixes Channel message deletion bug. - -[Changes][v0.17.10] - - -<a id="v0.17.9"></a> -# [Release v0.17.9: Blocks users from Reporting themselves](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.9) - 2023-02-22 - -Blocks users from Reporting themselves -Fixes missing Relay Information in Profile Pages -Adds LnZapRequest to the event observer as well to download authors. -Puts Follow and Unfollow Buttons into coroutines. -Makes sure people are only added to the hidden list if 5 or more different Nostr events have the same message. -Fixing remaining Follow/Unfollow issue with an outdated Account object. - -[Changes][v0.17.9] - - -<a id="v0.17.8"></a> -# [Release v0.17.8: BugFix Translations breaking URLs/Images/Tags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.8) - 2023-02-22 - - - -[Changes][v0.17.8] - - -<a id="v0.17.7"></a> -# [Release v0.17.7: BugFix for Invalid Hexes in Tags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.7) - 2023-02-22 - - - -[Changes][v0.17.7] - - -<a id="v0.17.6"></a> -# [Release v0.17.6: BugFix for Alternating Follow/Unfollow](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.6) - 2023-02-22 - - - -[Changes][v0.17.6] - - -<a id="v0.17.5"></a> -# [Release v0.17.5: 0 Follows no more.](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.5) - 2023-02-21 - -- Fixes issue with overriding follow list to zero (maintains a local copy. It might still override to a previous version) -- Fixes Notification Dot for Zaps -- Faster notification updates -- Refactoring the saving of user preferences -- More caching to increase speed of updating lists -- Potentially fixing Notifications crash on Samsungs - -[Changes][v0.17.5] - - -<a id="v0.17.4"></a> -# [Release v0.17.4: 5 Strike anti-spam](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.4) - 2023-02-21 - -- Anti-spam filter requires 5 duplicated msgs to temporarily block -- Forces conversion to npub in notes to make sure the Hex key is correct. - -[Changes][v0.17.4] - - -<a id="v0.17.3"></a> -# [Release v0.17.3: Check Hex keys in Event Tags](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.3) - 2023-02-21 - - - -[Changes][v0.17.3] - - -<a id="v0.17.2"></a> -# [Release v0.17.2: Quote Note Popup and Rendering](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.2) - 2023-02-20 - -https://nostr.build/i/nostr.build_0cd188713619b44b8f9c214617130db7f11c427dcdc03872fac736d74a594b5a.png - -- Popup to Boost/Quote Notes -- Rendering Notes instead of just presenting the Note ID -- Fixes Movement of the post when Show More is pressed -- Notification Background is now opens note -- Changes keyboard layout to Decimal in Zap amount Setup -- Fixes erasing edited Zap amounts on recomposing -- Resets mentions after writing a reply utxo -- Fixes the "Show Anyway" button in the first element of the threadview. - -[Changes][v0.17.2] - - -<a id="v0.17.1"></a> -# [Release v0.17.1: Custom Zap Amounts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.1) - 2023-02-20 - - - -[Changes][v0.17.1] - - -<a id="v0.17.0"></a> -# [Release v0.17.0: Anti-spam de-dup filters](https://github.com/vitorpamplona/amethyst/releases/tag/v0.17.0) - 2023-02-19 - -v0.17.0: Anti-spam filters -- Filters out the duplicated-message type of Spam -- Fixes Reply not showing up in the Thread View after user Posts a new one -- Adds Reports Tab to Profile Page -- Significantly improves the speed of the Follower calculation -- Decreased User/Note object size -> 4x more objects can fit in memory. -- Prunes memory to 1000 messages per public channel -- Improves the speed of rendering long threads and thread level calculation -- Fixes Quoted Post appearing as a Reply -- Fixes Mutex state when Android kills threads. -- Refactors current filter classes into Relay filters and Screen filters -- Moves Notification Dot calculation to background threads. -- Loads new Posts on the Thread view on the fly - -[Changes][v0.17.0] - - -<a id="v0.16.2"></a> -# [Release v0.16.2](https://github.com/vitorpamplona/amethyst/releases/tag/v0.16.2) - 2023-02-17 - - - -[Changes][v0.16.2] - - -<a id="v0.16.1"></a> -# [Release v0.16.1: BugFix Release](https://github.com/vitorpamplona/amethyst/releases/tag/v0.16.1) - 2023-02-17 - -1. Fixes the Relay List going back to defaults if the user hasn't setup their relay sets. -2. Fixes crashes from ChannelCreateEvent and ChannelMetadataEvent when the content is not a JSON structure. -3. Removes duplicated Report filters to relays. - -[Changes][v0.16.1] - - -<a id="v0.16.0"></a> -# [Release v0.16.0: Image Proxy Resizer + CDN](https://github.com/vitorpamplona/amethyst/releases/tag/v0.16.0) - 2023-02-15 - - - -[Changes][v0.16.0] - - -<a id="v0.15.9"></a> -# [Release v0.15.9: Profile Delay Fix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.9) - 2023-02-15 - - - -[Changes][v0.15.9] - - -<a id="v0.15.8"></a> -# [Release v0.15.8: Follower Count BugFix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.8) - 2023-02-15 - - - -[Changes][v0.15.8] - - -<a id="v0.15.7"></a> -# [Release v0.15.7: Performance Adjustments](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.7) - 2023-02-14 - - - -[Changes][v0.15.7] - - -<a id="v0.15.6"></a> -# [Release v0.15.6: Performance Adjustments](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.6) - 2023-02-14 - - - -[Changes][v0.15.6] - - -<a id="v0.15.5"></a> -# [Release v0.15.5: Performance Adjustments](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.5) - 2023-02-14 - - - -[Changes][v0.15.5] - - -<a id="v0.15.4"></a> -# [Release v0.15.4: Performance Adjustments](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.4) - 2023-02-14 - - - -[Changes][v0.15.4] - - -<a id="v0.15.3"></a> -# [Release v0.15.3: NewPost adjusts to keyboard in long posts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.3) - 2023-02-13 - - - -[Changes][v0.15.3] - - -<a id="v0.15.2"></a> -# [Release v0.15.2: Image Uploading Feedback](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.2) - 2023-02-13 - -Image Uploading Feedback by [@Radiokot](https://github.com/Radiokot) -Performance Updates -Quick Fix for keeping the Edit Text with the available screen size in case of long posts. - -[Changes][v0.15.2] - - -<a id="v0.15.1"></a> -# [Release v0.15.1: Zaps](https://github.com/vitorpamplona/amethyst/releases/tag/v0.15.1) - 2023-02-13 - - - -[Changes][v0.15.1] - - -<a id="v0.14.3"></a> -# [Release v0.14.3: Deactivates Proguard](https://github.com/vitorpamplona/amethyst/releases/tag/v0.14.3) - 2023-02-10 - - - -[Changes][v0.14.3] - - -<a id="v0.14.2"></a> -# [Release v0.14.2: Activates Proguard / Fixes Image Upload](https://github.com/vitorpamplona/amethyst/releases/tag/v0.14.2) - 2023-02-10 - -1. Fixes Image Upload Permissions for some phones -2. Allows unsecured (`ws://`) relay additions for Umbrel users. -3. Allows the user to leave and join their own groups -4. Fixes bad URI on Users Profile crashing the app -5. Fixes QR Code display and Scan for smaller screens. -6. Fixes a translation not showing up in old phones (dependency issue) -7. Activates ProGuard to minimize code automatically (random bugs expected) -8. Improved Relay-based user search performance. - -[Changes][v0.14.2] - - -<a id="v0.14.1"></a> -# [Release v0.14.1: Save Images / LNURL edit](https://github.com/vitorpamplona/amethyst/releases/tag/v0.14.1) - 2023-02-09 - -- Save image by [Oleg Koretsky](https://github.com/Radiokot) -- LNURL edit in the profile -- Bugfix crashing the Lightning Tips with nonnumeric values in the amount. - -[Changes][v0.14.1] - - -<a id="v0.14.0"></a> -# [Release v0.14.0: Lightning Tips](https://github.com/vitorpamplona/amethyst/releases/tag/v0.14.0) - 2023-02-08 - - - -[Changes][v0.14.0] - - -<a id="v0.13.3"></a> -# [Release v0.13.3: Auto-translation options](https://github.com/vitorpamplona/amethyst/releases/tag/v0.13.3) - 2023-02-08 - - - -[Changes][v0.13.3] - - -<a id="v0.13.2"></a> -# [Release v0.13.2: Tracks and Displays Relay Info per Event](https://github.com/vitorpamplona/amethyst/releases/tag/v0.13.2) - 2023-02-07 - -Amethyst v0.13.2: Tracks and Displays Relay Info per Event - -1. Users can now see which relays are sending posts, DMs, and public chat messages. This uses the relay's website favicon. Please update your favicons to something relevant to users :) - -2. Changes the default relay set to Paid relays for Chats and Global, making spam less impactful for new users. - -3. Timeline posts are cropped at 350 chars with Show More to expand - -4. Full Profile Pages by Oleg Koretsky - -5. Search improvements by Habib Okanla - -6. Devs can now have 2 Amethysts running by greenart7c3 - -7. Removes the trailing / on relay's setup by middlingphys - -[Changes][v0.13.2] - - -<a id="v0.13.1"></a> -# [Release v0.13.1: Relay Sets (home, dm, chat, global)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.13.1) - 2023-02-06 - - - -[Changes][v0.13.1] - - -<a id="v0.13.0"></a> -# [Release v0.13.0: Auto-translate](https://github.com/vitorpamplona/amethyst/releases/tag/v0.13.0) - 2023-02-05 - - - -[Changes][v0.13.0] - - -<a id="v0.12.1"></a> -# [Release v0.12.1: More Performance!](https://github.com/vitorpamplona/amethyst/releases/tag/v0.12.1) - 2023-02-03 - - - -[Changes][v0.12.1] - - -<a id="v0.12.0"></a> -# [Release v0.12.0: Performance Upgrades](https://github.com/vitorpamplona/amethyst/releases/tag/v0.12.0) - 2023-02-03 - - - -[Changes][v0.12.0] - - -<a id="v0.11.7"></a> -# [Release v0.11.7: Relay List View](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.7) - 2023-01-31 - -Relay list view in profile, clickable nostr: elements, and several small improvements across the board. - -[Changes][v0.11.7] - - -<a id="v0.11.6"></a> -# [Release v0.11.6: Bugfix: Invalid URIs in the Relay list crashing the app](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.6) - 2023-01-30 - - - -[Changes][v0.11.6] - - -<a id="v0.11.5"></a> -# [Release v0.11.5: NIP 56 Support](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.5) - 2023-01-30 - - - -[Changes][v0.11.5] - - -<a id="v0.11.4"></a> -# [Release v0.11.4: User QR Code Display & Scan (any NIP21 works)](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.4) - 2023-01-29 - - - -[Changes][v0.11.4] - - -<a id="v0.11.3"></a> -# [Release v0.11.3: Hide User on Profile page](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.3) - 2023-01-28 - - - -[Changes][v0.11.3] - - -<a id="v0.11.2"></a> -# [Release v0.11.2: Search includes Public Chats and Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.2) - 2023-01-28 - - - -[Changes][v0.11.2] - - -<a id="v0.11.1"></a> -# [Release v0.11.1: Accept Terms Login Checkbox](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.1) - 2023-01-27 - - - -[Changes][v0.11.1] - - -<a id="v0.11.0"></a> -# [Release v0.11.0: Notification Bubbles and Verified Follows](https://github.com/vitorpamplona/amethyst/releases/tag/v0.11.0) - 2023-01-27 - - - -[Changes][v0.11.0] - - -<a id="v0.10.7"></a> -# [Release v0.10.7](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.7) - 2023-01-26 - - - -[Changes][v0.10.7] - - -<a id="v0.10.6"></a> -# [Release v0.10.6: Relay List BugFix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.6) - 2023-01-26 - -Fixes: -- Multiple like events -- Relay List loading issue -- Case Sensitive Search box -- Proper RTL ([@KotlinGeekDev](https://github.com/KotlinGeekDev)) -- Proper Navigation in the Thread Views ([@KotlinGeekDev](https://github.com/KotlinGeekDev)) - -[Changes][v0.10.6] - - -<a id="v0.10.5"></a> -# [Release v0.10.5: BugFix Release](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.5) - 2023-01-25 - - - -[Changes][v0.10.5] - - -<a id="v0.10.4"></a> -# [Release v0.10.4: NIP-21 and Split home feeds](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.4) - 2023-01-25 - -Support for [NIP-21](https://github.com/nostr-protocol/nips/blob/urlscheme/21.md), split home screen feed between Follows and Follows + Replies and some improvements in Chatroom navigation and Network use. - -[Changes][v0.10.4] - - -<a id="v0.10.3"></a> -# [Release v0.10.3: Block Users, Report Posts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.3) - 2023-01-24 - - - -[Changes][v0.10.3] - - -<a id="v0.10.2"></a> -# [Release v0.10.2: BugFix for certain RelayList payloads](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.2) - 2023-01-24 - - - -[Changes][v0.10.2] - - -<a id="v0.10.1"></a> -# [Release v0.10.1: Version Code bugfix](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.1) - 2023-01-23 - - - -[Changes][v0.10.1] - - -<a id="v0.10.0"></a> -# [Release v0.10.0: Relay List View/Edit](https://github.com/vitorpamplona/amethyst/releases/tag/v0.10.0) - 2023-01-23 - - - -[Changes][v0.10.0] - - -<a id="v0.9.6"></a> -# [Release v0.9.6: Unique View Counts](https://github.com/vitorpamplona/amethyst/releases/tag/v0.9.6) - 2023-01-22 - - - -[Changes][v0.9.6] - - -<a id="v0.9.5"></a> -# [Release v0.9.5: Spam Filter for Private Messages](https://github.com/vitorpamplona/amethyst/releases/tag/v0.9.5) - 2023-01-21 - -- Spam Filter for Private Messages -- Improvements in Image Caching -- Improvements in Navigation - -[Changes][v0.9.5] - - -<a id="v0.9.4"></a> -# [Release v0.9.4: Bug Fix Release](https://github.com/vitorpamplona/amethyst/releases/tag/v0.9.4) - 2023-01-21 - -Fixes Image Profile Loading - -[Changes][v0.9.4] - - -<a id="v0.9.3"></a> -# [Release v0.9.3: Profile Edits](https://github.com/vitorpamplona/amethyst/releases/tag/v0.9.3) - 2023-01-21 - -- Profile Edit -- Navigation Improvements (Back Button) by https://github.com/KotlinGeekDev -- ~75% smaller apk size by https://github.com/KotlinGeekDev - -[Changes][v0.9.3] - - -<a id="v0.9.2"></a> -# [Release v0.9.2: Bug Fix Release](https://github.com/vitorpamplona/amethyst/releases/tag/v0.9.2) - 2023-01-20 - -fixes a user tagging bug and a like count bug (turns out relays don't do hex id crop in tag search) - -[Changes][v0.9.2] - - -<a id="v0.9.1"></a> -# [Release v0.9.1: Quote Reply in Chat + small fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v0.9.1) - 2023-01-19 - - - -[Changes][v0.9.1] - - -<a id="v0.9"></a> -# [Release v0.9: Public Chat Management](https://github.com/vitorpamplona/amethyst/releases/tag/v0.9) - 2023-01-19 - -Demo: https://nostr.build/i/6112.gif - - -[Changes][v0.9] - - -<a id="v0.8.2"></a> -# [Release v0.8.2: Performance Improvements and Searchbar](https://github.com/vitorpamplona/amethyst/releases/tag/v0.8.2) - 2023-01-18 - - - -[Changes][v0.8.2] - - -<a id="v0.8.1"></a> -# [Release v0.8.1: Performance Improvements](https://github.com/vitorpamplona/amethyst/releases/tag/v0.8.1) - 2023-01-18 - - - -[Changes][v0.8.1] - - -<a id="v0.8"></a> -# [Release v0.8: User Tags / New Logo](https://github.com/vitorpamplona/amethyst/releases/tag/v0.8) - 2023-01-18 - -- New Logo -- User/Search tagging on composing -- Thread safe improvements - - -[Changes][v0.8] - - -<a id="v0.7"></a> -# [Release v0.7: Public Chats](https://github.com/vitorpamplona/amethyst/releases/tag/v0.7) - 2023-01-16 - -Support for: -- Anigma's Public Chat (NIP-28) -- Gifs and SVG Images. -- User Tags on Posts. - - -[Changes][v0.7] - - -<a id="v0.6"></a> -# [Release v0.6: User Profiles](https://github.com/vitorpamplona/amethyst/releases/tag/v0.6) - 2023-01-16 - -Demo: https://i.imgur.com/Kdvl78r.jpg - -Support for: -- User Profiles -- Follow/Unfollow -- Start Message -- List of Followers and Follows -- User banner - -[Changes][v0.6] - - -<a id="v0.5"></a> -# [Release v0.5: Private Messages](https://github.com/vitorpamplona/amethyst/releases/tag/v0.5) - 2023-01-14 - -Demo: https://i.imgur.com/f5vptAv.mp4 - -Amethyst 0.5: -- Adds support for Private Messages -- Removes autoplay from videos (have to figure out how to mute first) -- Click on Image to Zoom -- Blocks double likes - -[Changes][v0.5] - - -<a id="v0.4"></a> -# [Release v0.4: LnInvoices](https://github.com/vitorpamplona/amethyst/releases/tag/v0.4) - 2023-01-13 - -- Lightning Invoice cards -- Url Preview Caching -- Long press to copy Text/NoteID/UserID - -[Changes][v0.4] - - -<a id="v0.3"></a> -# [Release v0.3: Video/Image/Url Previews](https://github.com/vitorpamplona/amethyst/releases/tag/v0.3) - 2023-01-13 - -- Video playback in the timeline -- Direct image upload -- Image and URL previews when writing new posts - -[Changes][v0.3] - - -<a id="v0.2"></a> -# [Release v0.2: Thread View](https://github.com/vitorpamplona/amethyst/releases/tag/v0.2) - 2023-01-12 - -- Post and Thread Views - -[Changes][v0.2] - - -<a id="v0.1"></a> -# [Release v0.1: Basic Social App](https://github.com/vitorpamplona/amethyst/releases/tag/v0.1) - 2023-01-11 - -First public version with: -- Account Management -- Home Feed -- Notifications Feed -- Global Feed -- Reactions (like, boost, reply) -- Image Preview -- Url Preview - -[Changes][v0.1] - - -[v1.03.0]: https://github.com/vitorpamplona/amethyst/compare/v1.02.1...v1.03.0 -[v1.02.1]: https://github.com/vitorpamplona/amethyst/compare/v1.02.0...v1.02.1 -[v1.02.0]: https://github.com/vitorpamplona/amethyst/compare/v1.01.0...v1.02.0 -[v1.01.0]: https://github.com/vitorpamplona/amethyst/compare/v1.00.5...v1.01.0 -[v1.00.5]: https://github.com/vitorpamplona/amethyst/compare/v1.00.4...v1.00.5 -[v1.00.4]: https://github.com/vitorpamplona/amethyst/compare/v1.00.3...v1.00.4 -[v1.00.3]: https://github.com/vitorpamplona/amethyst/compare/v1.00.2...v1.00.3 -[v1.00.2]: https://github.com/vitorpamplona/amethyst/compare/v1.00.1...v1.00.2 -[v1.00.1]: https://github.com/vitorpamplona/amethyst/compare/v1.00.0...v1.00.1 -[v1.00.0]: https://github.com/vitorpamplona/amethyst/compare/v0.94.3...v1.00.0 -[v0.94.3]: https://github.com/vitorpamplona/amethyst/compare/v0.94.2...v0.94.3 -[v0.94.2]: https://github.com/vitorpamplona/amethyst/compare/v0.94.1...v0.94.2 -[v0.94.1]: https://github.com/vitorpamplona/amethyst/compare/v0.94.0...v0.94.1 -[v0.94.0]: https://github.com/vitorpamplona/amethyst/compare/v0.93.1...v0.94.0 -[v0.93.1]: https://github.com/vitorpamplona/amethyst/compare/v0.93.0...v0.93.1 -[v0.93.0]: https://github.com/vitorpamplona/amethyst/compare/v0.92.7...v0.93.0 -[v0.92.7]: https://github.com/vitorpamplona/amethyst/compare/v0.92.6...v0.92.7 -[v0.92.6]: https://github.com/vitorpamplona/amethyst/compare/v0.92.5...v0.92.6 -[v0.92.5]: https://github.com/vitorpamplona/amethyst/compare/v0.92.4...v0.92.5 -[v0.92.4]: https://github.com/vitorpamplona/amethyst/compare/v0.92.3...v0.92.4 -[v0.92.3]: https://github.com/vitorpamplona/amethyst/compare/v0.92.2...v0.92.3 -[v0.92.2]: https://github.com/vitorpamplona/amethyst/compare/v0.92.1...v0.92.2 -[v0.92.1]: https://github.com/vitorpamplona/amethyst/compare/v0.92.0...v0.92.1 -[v0.92.0]: https://github.com/vitorpamplona/amethyst/compare/v0.91.0...v0.92.0 -[v0.91.0]: https://github.com/vitorpamplona/amethyst/compare/v0.90.6...v0.91.0 -[v0.90.6]: https://github.com/vitorpamplona/amethyst/compare/v0.90.5...v0.90.6 -[v0.90.5]: https://github.com/vitorpamplona/amethyst/compare/v0.90.4...v0.90.5 -[v0.90.4]: https://github.com/vitorpamplona/amethyst/compare/v0.90.3...v0.90.4 -[v0.90.3]: https://github.com/vitorpamplona/amethyst/compare/v0.90.2...v0.90.3 -[v0.90.2]: https://github.com/vitorpamplona/amethyst/compare/v0.90.1...v0.90.2 -[v0.90.1]: https://github.com/vitorpamplona/amethyst/compare/v0.90.0...v0.90.1 -[v0.90.0]: https://github.com/vitorpamplona/amethyst/compare/v0.89.10...v0.90.0 -[v0.89.10]: https://github.com/vitorpamplona/amethyst/compare/v0.89.9...v0.89.10 -[v0.89.9]: https://github.com/vitorpamplona/amethyst/compare/v0.89.8...v0.89.9 -[v0.89.8]: https://github.com/vitorpamplona/amethyst/compare/v0.89.7...v0.89.8 -[v0.89.7]: https://github.com/vitorpamplona/amethyst/compare/v0.89.6...v0.89.7 -[v0.89.6]: https://github.com/vitorpamplona/amethyst/compare/v0.89.5...v0.89.6 -[v0.89.5]: https://github.com/vitorpamplona/amethyst/compare/v0.89.4...v0.89.5 -[v0.89.4]: https://github.com/vitorpamplona/amethyst/compare/v0.89.2...v0.89.4 -[v0.89.2]: https://github.com/vitorpamplona/amethyst/compare/v0.89.1...v0.89.2 -[v0.89.1]: https://github.com/vitorpamplona/amethyst/compare/v0.89.0...v0.89.1 -[v0.89.0]: https://github.com/vitorpamplona/amethyst/compare/v0.88.7...v0.89.0 -[v0.88.7]: https://github.com/vitorpamplona/amethyst/compare/v0.88.6...v0.88.7 -[v0.88.6]: https://github.com/vitorpamplona/amethyst/compare/v0.88.5...v0.88.6 -[v0.88.5]: https://github.com/vitorpamplona/amethyst/compare/v0.88.4...v0.88.5 -[v0.88.4]: https://github.com/vitorpamplona/amethyst/compare/v0.88.3...v0.88.4 -[v0.88.3]: https://github.com/vitorpamplona/amethyst/compare/v0.88.2...v0.88.3 -[v0.88.2]: https://github.com/vitorpamplona/amethyst/compare/v0.88.1...v0.88.2 -[v0.88.1]: https://github.com/vitorpamplona/amethyst/compare/v0.88.0...v0.88.1 -[v0.88.0]: https://github.com/vitorpamplona/amethyst/compare/v0.87.7...v0.88.0 -[v0.87.7]: https://github.com/vitorpamplona/amethyst/compare/v0.87.6...v0.87.7 -[v0.87.6]: https://github.com/vitorpamplona/amethyst/compare/v0.87.5...v0.87.6 -[v0.87.5]: https://github.com/vitorpamplona/amethyst/compare/v0.87.4...v0.87.5 -[v0.87.4]: https://github.com/vitorpamplona/amethyst/compare/v0.87.3...v0.87.4 -[v0.87.3]: https://github.com/vitorpamplona/amethyst/compare/v0.87.2...v0.87.3 -[v0.87.2]: https://github.com/vitorpamplona/amethyst/compare/v0.87.1...v0.87.2 -[v0.87.1]: https://github.com/vitorpamplona/amethyst/compare/v0.87.0...v0.87.1 -[v0.87.0]: https://github.com/vitorpamplona/amethyst/compare/v0.86.5...v0.87.0 -[v0.86.5]: https://github.com/vitorpamplona/amethyst/compare/v0.86.4...v0.86.5 -[v0.86.4]: https://github.com/vitorpamplona/amethyst/compare/v0.86.3...v0.86.4 -[v0.86.3]: https://github.com/vitorpamplona/amethyst/compare/v0.86.2...v0.86.3 -[v0.86.2]: https://github.com/vitorpamplona/amethyst/compare/v0.86.1...v0.86.2 -[v0.86.1]: https://github.com/vitorpamplona/amethyst/compare/v0.86.0...v0.86.1 -[v0.86.0]: https://github.com/vitorpamplona/amethyst/compare/v0.85.3...v0.86.0 -[v0.85.3]: https://github.com/vitorpamplona/amethyst/compare/v0.85.1...v0.85.3 -[v0.85.1]: https://github.com/vitorpamplona/amethyst/compare/v0.85.0...v0.85.1 -[v0.85.0]: https://github.com/vitorpamplona/amethyst/compare/v0.84.3...v0.85.0 -[v0.84.3]: https://github.com/vitorpamplona/amethyst/compare/v0.84.2...v0.84.3 -[v0.84.2]: https://github.com/vitorpamplona/amethyst/compare/v0.84.1...v0.84.2 -[v0.84.1]: https://github.com/vitorpamplona/amethyst/compare/v0.83.13...v0.84.1 -[v0.83.13]: https://github.com/vitorpamplona/amethyst/compare/v0.83.12...v0.83.13 -[v0.83.12]: https://github.com/vitorpamplona/amethyst/compare/v0.83.10...v0.83.12 -[v0.83.10]: https://github.com/vitorpamplona/amethyst/compare/v0.83.9...v0.83.10 -[v0.83.9]: https://github.com/vitorpamplona/amethyst/compare/v0.83.8...v0.83.9 -[v0.83.8]: https://github.com/vitorpamplona/amethyst/compare/v0.83.7...v0.83.8 -[v0.83.7]: https://github.com/vitorpamplona/amethyst/compare/v0.83.5...v0.83.7 -[v0.83.5]: https://github.com/vitorpamplona/amethyst/compare/v0.83.4...v0.83.5 -[v0.83.4]: https://github.com/vitorpamplona/amethyst/compare/v0.83.3...v0.83.4 -[v0.83.3]: https://github.com/vitorpamplona/amethyst/compare/v0.83.1...v0.83.3 -[v0.83.1]: https://github.com/vitorpamplona/amethyst/compare/v0.82.3...v0.83.1 -[v0.82.3]: https://github.com/vitorpamplona/amethyst/compare/v0.82.2...v0.82.3 -[v0.82.2]: https://github.com/vitorpamplona/amethyst/compare/v0.82.1...v0.82.2 -[v0.82.1]: https://github.com/vitorpamplona/amethyst/compare/v0.81.5...v0.82.1 -[v0.81.5]: https://github.com/vitorpamplona/amethyst/compare/v0.81.3...v0.81.5 -[v0.81.3]: https://github.com/vitorpamplona/amethyst/compare/v0.80.7...v0.81.3 -[v0.80.7]: https://github.com/vitorpamplona/amethyst/compare/v0.80.4...v0.80.7 -[v0.80.4]: https://github.com/vitorpamplona/amethyst/compare/v0.80.2...v0.80.4 -[v0.80.2]: https://github.com/vitorpamplona/amethyst/compare/v0.80.1...v0.80.2 -[v0.80.1]: https://github.com/vitorpamplona/amethyst/compare/v0.79.13...v0.80.1 -[v0.79.13]: https://github.com/vitorpamplona/amethyst/compare/v0.79.12...v0.79.13 -[v0.79.12]: https://github.com/vitorpamplona/amethyst/compare/v0.79.10...v0.79.12 -[v0.79.10]: https://github.com/vitorpamplona/amethyst/compare/v0.79.9...v0.79.10 -[v0.79.9]: https://github.com/vitorpamplona/amethyst/compare/v0.79.7...v0.79.9 -[v0.79.7]: https://github.com/vitorpamplona/amethyst/compare/v0.79.6...v0.79.7 -[v0.79.6]: https://github.com/vitorpamplona/amethyst/compare/v0.79.5...v0.79.6 -[v0.79.5]: https://github.com/vitorpamplona/amethyst/compare/v0.79.2...v0.79.5 -[v0.79.2]: https://github.com/vitorpamplona/amethyst/compare/v0.79.0...v0.79.2 -[v0.79.0]: https://github.com/vitorpamplona/amethyst/compare/v0.78.2...v0.79.0 -[v0.78.2]: https://github.com/vitorpamplona/amethyst/compare/v0.78.1...v0.78.2 -[v0.78.1]: https://github.com/vitorpamplona/amethyst/compare/v0.78.0...v0.78.1 -[v0.78.0]: https://github.com/vitorpamplona/amethyst/compare/v0.77.8...v0.78.0 -[v0.77.8]: https://github.com/vitorpamplona/amethyst/compare/v0.77.7...v0.77.8 -[v0.77.7]: https://github.com/vitorpamplona/amethyst/compare/v0.77.5...v0.77.7 -[v0.77.5]: https://github.com/vitorpamplona/amethyst/compare/v0.77.3...v0.77.5 -[v0.77.3]: https://github.com/vitorpamplona/amethyst/compare/v0.77.1...v0.77.3 -[v0.77.1]: https://github.com/vitorpamplona/amethyst/compare/v0.77.0...v0.77.1 -[v0.77.0]: https://github.com/vitorpamplona/amethyst/compare/v0.76.2...v0.77.0 -[v0.76.2]: https://github.com/vitorpamplona/amethyst/compare/v0.76.1...v0.76.2 -[v0.76.1]: https://github.com/vitorpamplona/amethyst/compare/v0.76.0...v0.76.1 -[v0.76.0]: https://github.com/vitorpamplona/amethyst/compare/v0.75.14...v0.76.0 -[v0.75.14]: https://github.com/vitorpamplona/amethyst/compare/v0.75.13...v0.75.14 -[v0.75.13]: https://github.com/vitorpamplona/amethyst/compare/v0.75.12...v0.75.13 -[v0.75.12]: https://github.com/vitorpamplona/amethyst/compare/v0.75.11...v0.75.12 -[v0.75.11]: https://github.com/vitorpamplona/amethyst/compare/v0.75.10...v0.75.11 -[v0.75.10]: https://github.com/vitorpamplona/amethyst/compare/v0.75.8...v0.75.10 -[v0.75.8]: https://github.com/vitorpamplona/amethyst/compare/v0.75.7...v0.75.8 -[v0.75.7]: https://github.com/vitorpamplona/amethyst/compare/v0.75.6...v0.75.7 -[v0.75.6]: https://github.com/vitorpamplona/amethyst/compare/v0.75.3...v0.75.6 -[v0.75.3]: https://github.com/vitorpamplona/amethyst/compare/v0.75.2...v0.75.3 -[v0.75.2]: https://github.com/vitorpamplona/amethyst/compare/v0.75.1...v0.75.2 -[v0.75.1]: https://github.com/vitorpamplona/amethyst/compare/v0.75.0...v0.75.1 -[v0.75.0]: https://github.com/vitorpamplona/amethyst/compare/v0.74.5...v0.75.0 -[v0.74.5]: https://github.com/vitorpamplona/amethyst/compare/v0.74.4...v0.74.5 -[v0.74.4]: https://github.com/vitorpamplona/amethyst/compare/v0.74.3...v0.74.4 -[v0.74.3]: https://github.com/vitorpamplona/amethyst/compare/v0.74.2...v0.74.3 -[v0.74.2]: https://github.com/vitorpamplona/amethyst/compare/v0.73.3...v0.74.2 -[v0.73.3]: https://github.com/vitorpamplona/amethyst/compare/v0.73.2...v0.73.3 -[v0.73.2]: https://github.com/vitorpamplona/amethyst/compare/v0.73.1...v0.73.2 -[v0.73.1]: https://github.com/vitorpamplona/amethyst/compare/v0.72.2...v0.73.1 -[v0.72.2]: https://github.com/vitorpamplona/amethyst/compare/v0.72.1...v0.72.2 -[v0.72.1]: https://github.com/vitorpamplona/amethyst/compare/v0.72.0...v0.72.1 -[v0.72.0]: https://github.com/vitorpamplona/amethyst/compare/v0.71.0...v0.72.0 -[v0.71.0]: https://github.com/vitorpamplona/amethyst/compare/v0.70.8...v0.71.0 -[v0.70.8]: https://github.com/vitorpamplona/amethyst/compare/v0.70.7...v0.70.8 -[v0.70.7]: https://github.com/vitorpamplona/amethyst/compare/v0.70.6...v0.70.7 -[v0.70.6]: https://github.com/vitorpamplona/amethyst/compare/v0.70.5...v0.70.6 -[v0.70.5]: https://github.com/vitorpamplona/amethyst/compare/v0.70.4...v0.70.5 -[v0.70.4]: https://github.com/vitorpamplona/amethyst/compare/v0.70.3...v0.70.4 -[v0.70.3]: https://github.com/vitorpamplona/amethyst/compare/v0.70.2...v0.70.3 -[v0.70.2]: https://github.com/vitorpamplona/amethyst/compare/v0.70.1...v0.70.2 -[v0.70.1]: https://github.com/vitorpamplona/amethyst/compare/v0.70.0...v0.70.1 -[v0.70.0]: https://github.com/vitorpamplona/amethyst/compare/v0.69.3...v0.70.0 -[v0.69.3]: https://github.com/vitorpamplona/amethyst/compare/v0.69.2...v0.69.3 -[v0.69.2]: https://github.com/vitorpamplona/amethyst/compare/v0.69.1...v0.69.2 -[v0.69.1]: https://github.com/vitorpamplona/amethyst/compare/v0.69.0...v0.69.1 -[v0.69.0]: https://github.com/vitorpamplona/amethyst/compare/v0.68.4...v0.69.0 -[v0.68.4]: https://github.com/vitorpamplona/amethyst/compare/v0.68.3...v0.68.4 -[v0.68.3]: https://github.com/vitorpamplona/amethyst/compare/v0.68.2...v0.68.3 -[v0.68.2]: https://github.com/vitorpamplona/amethyst/compare/v0.68.1...v0.68.2 -[v0.68.1]: https://github.com/vitorpamplona/amethyst/compare/v0.68.0...v0.68.1 -[v0.68.0]: https://github.com/vitorpamplona/amethyst/compare/v0.67.1...v0.68.0 -[v0.67.1]: https://github.com/vitorpamplona/amethyst/compare/v0.67.0...v0.67.1 -[v0.67.0]: https://github.com/vitorpamplona/amethyst/compare/v0.66.7...v0.67.0 -[v0.66.7]: https://github.com/vitorpamplona/amethyst/compare/v0.66.6...v0.66.7 -[v0.66.6]: https://github.com/vitorpamplona/amethyst/compare/v0.66.5...v0.66.6 -[v0.66.5]: https://github.com/vitorpamplona/amethyst/compare/v0.66.4...v0.66.5 -[v0.66.4]: https://github.com/vitorpamplona/amethyst/compare/v0.66.3...v0.66.4 -[v0.66.3]: https://github.com/vitorpamplona/amethyst/compare/v0.66.2...v0.66.3 -[v0.66.2]: https://github.com/vitorpamplona/amethyst/compare/v0.66.1...v0.66.2 -[v0.66.1]: https://github.com/vitorpamplona/amethyst/compare/v0.66.0...v0.66.1 -[v0.66.0]: https://github.com/vitorpamplona/amethyst/compare/v0.65.1...v0.66.0 -[v0.65.1]: https://github.com/vitorpamplona/amethyst/compare/v0.65.0...v0.65.1 -[v0.65.0]: https://github.com/vitorpamplona/amethyst/compare/v0.64.4...v0.65.0 -[v0.64.4]: https://github.com/vitorpamplona/amethyst/compare/v0.64.3...v0.64.4 -[v0.64.3]: https://github.com/vitorpamplona/amethyst/compare/v0.64.2...v0.64.3 -[v0.64.2]: https://github.com/vitorpamplona/amethyst/compare/v0.64.0...v0.64.2 -[v0.64.0]: https://github.com/vitorpamplona/amethyst/compare/v0.63.0...v0.64.0 -[v0.63.0]: https://github.com/vitorpamplona/amethyst/compare/v0.62.8...v0.63.0 -[v0.62.8]: https://github.com/vitorpamplona/amethyst/compare/v0.62.7...v0.62.8 -[v0.62.7]: https://github.com/vitorpamplona/amethyst/compare/v0.62.6...v0.62.7 -[v0.62.6]: https://github.com/vitorpamplona/amethyst/compare/v0.62.5...v0.62.6 -[v0.62.5]: https://github.com/vitorpamplona/amethyst/compare/v0.62.4...v0.62.5 -[v0.62.4]: https://github.com/vitorpamplona/amethyst/compare/v0.62.3...v0.62.4 -[v0.62.3]: https://github.com/vitorpamplona/amethyst/compare/v0.62.2...v0.62.3 -[v0.62.2]: https://github.com/vitorpamplona/amethyst/compare/v0.62.1...v0.62.2 -[v0.62.1]: https://github.com/vitorpamplona/amethyst/compare/v0.62.0...v0.62.1 -[v0.62.0]: https://github.com/vitorpamplona/amethyst/compare/v0.61.4...v0.62.0 -[v0.61.4]: https://github.com/vitorpamplona/amethyst/compare/v0.61.3...v0.61.4 -[v0.61.3]: https://github.com/vitorpamplona/amethyst/compare/v0.61.2...v0.61.3 -[v0.61.2]: https://github.com/vitorpamplona/amethyst/compare/v0.61.1...v0.61.2 -[v0.61.1]: https://github.com/vitorpamplona/amethyst/compare/v0.61.0...v0.61.1 -[v0.61.0]: https://github.com/vitorpamplona/amethyst/compare/v0.60.2...v0.61.0 -[v0.60.2]: https://github.com/vitorpamplona/amethyst/compare/v0.60.1...v0.60.2 -[v0.60.1]: https://github.com/vitorpamplona/amethyst/compare/v0.60.0...v0.60.1 -[v0.60.0]: https://github.com/vitorpamplona/amethyst/compare/v0.59.1...v0.60.0 -[v0.59.1]: https://github.com/vitorpamplona/amethyst/compare/v0.58.3...v0.59.1 -[v0.58.3]: https://github.com/vitorpamplona/amethyst/compare/v0.58.2...v0.58.3 -[v0.58.2]: https://github.com/vitorpamplona/amethyst/compare/v0.58.1...v0.58.2 -[v0.58.1]: https://github.com/vitorpamplona/amethyst/compare/v0.58.0...v0.58.1 -[v0.58.0]: https://github.com/vitorpamplona/amethyst/compare/v0.57.0...v0.58.0 -[v0.57.0]: https://github.com/vitorpamplona/amethyst/compare/v0.56.5...v0.57.0 -[v0.56.5]: https://github.com/vitorpamplona/amethyst/compare/v0.56.4...v0.56.5 -[v0.56.4]: https://github.com/vitorpamplona/amethyst/compare/v0.56.3...v0.56.4 -[v0.56.3]: https://github.com/vitorpamplona/amethyst/compare/v0.56.2...v0.56.3 -[v0.56.2]: https://github.com/vitorpamplona/amethyst/compare/v0.56.1...v0.56.2 -[v0.56.1]: https://github.com/vitorpamplona/amethyst/compare/v0.56.0...v0.56.1 -[v0.56.0]: https://github.com/vitorpamplona/amethyst/compare/v0.55.4...v0.56.0 -[v0.55.4]: https://github.com/vitorpamplona/amethyst/compare/v0.55.3...v0.55.4 -[v0.55.3]: https://github.com/vitorpamplona/amethyst/compare/v0.55.2...v0.55.3 -[v0.55.2]: https://github.com/vitorpamplona/amethyst/compare/v0.55.1...v0.55.2 -[v0.55.1]: https://github.com/vitorpamplona/amethyst/compare/v0.55.0...v0.55.1 -[v0.55.0]: https://github.com/vitorpamplona/amethyst/compare/v0.54.2...v0.55.0 -[v0.54.2]: https://github.com/vitorpamplona/amethyst/compare/v0.54.1...v0.54.2 -[v0.54.1]: https://github.com/vitorpamplona/amethyst/compare/v0.54.0...v0.54.1 -[v0.54.0]: https://github.com/vitorpamplona/amethyst/compare/v0.53.7...v0.54.0 -[v0.53.7]: https://github.com/vitorpamplona/amethyst/compare/v0.53.6...v0.53.7 -[v0.53.6]: https://github.com/vitorpamplona/amethyst/compare/v0.53.5...v0.53.6 -[v0.53.5]: https://github.com/vitorpamplona/amethyst/compare/v0.53.4...v0.53.5 -[v0.53.4]: https://github.com/vitorpamplona/amethyst/compare/v0.53.3...v0.53.4 -[v0.53.3]: https://github.com/vitorpamplona/amethyst/compare/v0.53.2...v0.53.3 -[v0.53.2]: https://github.com/vitorpamplona/amethyst/compare/v0.53.1...v0.53.2 -[v0.53.1]: https://github.com/vitorpamplona/amethyst/compare/v0.53.0...v0.53.1 -[v0.53.0]: https://github.com/vitorpamplona/amethyst/compare/v0.52.3...v0.53.0 -[v0.52.3]: https://github.com/vitorpamplona/amethyst/compare/v0.52.2...v0.52.3 -[v0.52.2]: https://github.com/vitorpamplona/amethyst/compare/v0.52.1...v0.52.2 -[v0.52.1]: https://github.com/vitorpamplona/amethyst/compare/v0.52.0...v0.52.1 -[v0.52.0]: https://github.com/vitorpamplona/amethyst/compare/v0.51.4...v0.52.0 -[v0.51.4]: https://github.com/vitorpamplona/amethyst/compare/v0.51.3...v0.51.4 -[v0.51.3]: https://github.com/vitorpamplona/amethyst/compare/v0.51.2...v0.51.3 -[v0.51.2]: https://github.com/vitorpamplona/amethyst/compare/v0.51.1...v0.51.2 -[v0.51.1]: https://github.com/vitorpamplona/amethyst/compare/v0.51.0...v0.51.1 -[v0.51.0]: https://github.com/vitorpamplona/amethyst/compare/v0.50.6...v0.51.0 -[v0.50.6]: https://github.com/vitorpamplona/amethyst/compare/v0.50.5...v0.50.6 -[v0.50.5]: https://github.com/vitorpamplona/amethyst/compare/v0.50.4...v0.50.5 -[v0.50.4]: https://github.com/vitorpamplona/amethyst/compare/v0.50.3...v0.50.4 -[v0.50.3]: https://github.com/vitorpamplona/amethyst/compare/v0.50.2...v0.50.3 -[v0.50.2]: https://github.com/vitorpamplona/amethyst/compare/v0.50.1...v0.50.2 -[v0.50.1]: https://github.com/vitorpamplona/amethyst/compare/v0.50.0...v0.50.1 -[v0.50.0]: https://github.com/vitorpamplona/amethyst/compare/v0.49.4...v0.50.0 -[v0.49.4]: https://github.com/vitorpamplona/amethyst/compare/v0.49.3...v0.49.4 -[v0.49.3]: https://github.com/vitorpamplona/amethyst/compare/v0.49.2...v0.49.3 -[v0.49.2]: https://github.com/vitorpamplona/amethyst/compare/v0.49.1...v0.49.2 -[v0.49.1]: https://github.com/vitorpamplona/amethyst/compare/v0.49.0...v0.49.1 -[v0.49.0]: https://github.com/vitorpamplona/amethyst/compare/v0.48.0...v0.49.0 -[v0.48.0]: https://github.com/vitorpamplona/amethyst/compare/v0.47.0...v0.48.0 -[v0.47.0]: https://github.com/vitorpamplona/amethyst/compare/v0.46.6...v0.47.0 -[v0.46.6]: https://github.com/vitorpamplona/amethyst/compare/v0.46.5...v0.46.6 -[v0.46.5]: https://github.com/vitorpamplona/amethyst/compare/v0.46.4...v0.46.5 -[v0.46.4]: https://github.com/vitorpamplona/amethyst/compare/v0.46.3...v0.46.4 -[v0.46.3]: https://github.com/vitorpamplona/amethyst/compare/v0.46.2...v0.46.3 -[v0.46.2]: https://github.com/vitorpamplona/amethyst/compare/v0.46.1...v0.46.2 -[v0.46.1]: https://github.com/vitorpamplona/amethyst/compare/v0.46.0...v0.46.1 -[v0.46.0]: https://github.com/vitorpamplona/amethyst/compare/v0.45.1...v0.46.0 -[v0.45.1]: https://github.com/vitorpamplona/amethyst/compare/v0.45.0...v0.45.1 -[v0.45.0]: https://github.com/vitorpamplona/amethyst/compare/v0.44.0...v0.45.0 -[v0.44.0]: https://github.com/vitorpamplona/amethyst/compare/v0.43.2...v0.44.0 -[v0.43.2]: https://github.com/vitorpamplona/amethyst/compare/v0.43.1...v0.43.2 -[v0.43.1]: https://github.com/vitorpamplona/amethyst/compare/v0.43.0...v0.43.1 -[v0.43.0]: https://github.com/vitorpamplona/amethyst/compare/v0.42.5...v0.43.0 -[v0.42.5]: https://github.com/vitorpamplona/amethyst/compare/v0.42.4...v0.42.5 -[v0.42.4]: https://github.com/vitorpamplona/amethyst/compare/v0.42.2...v0.42.4 -[v0.42.2]: https://github.com/vitorpamplona/amethyst/compare/v0.42.1...v0.42.2 -[v0.42.1]: https://github.com/vitorpamplona/amethyst/compare/v0.42.0...v0.42.1 -[v0.42.0]: https://github.com/vitorpamplona/amethyst/compare/v0.41.0...v0.42.0 -[v0.41.0]: https://github.com/vitorpamplona/amethyst/compare/v0.40.6...v0.41.0 -[v0.40.6]: https://github.com/vitorpamplona/amethyst/compare/v0.40.5...v0.40.6 -[v0.40.5]: https://github.com/vitorpamplona/amethyst/compare/v0.40.4...v0.40.5 -[v0.40.4]: https://github.com/vitorpamplona/amethyst/compare/v0.40.3...v0.40.4 -[v0.40.3]: https://github.com/vitorpamplona/amethyst/compare/v0.40.2...v0.40.3 -[v0.40.2]: https://github.com/vitorpamplona/amethyst/compare/v0.40.1...v0.40.2 -[v0.40.1]: https://github.com/vitorpamplona/amethyst/compare/v0.40.0...v0.40.1 -[v0.40.0]: https://github.com/vitorpamplona/amethyst/compare/v0.38.0...v0.40.0 -[v0.38.0]: https://github.com/vitorpamplona/amethyst/compare/v0.37.4...v0.38.0 -[v0.37.4]: https://github.com/vitorpamplona/amethyst/compare/v0.37.3...v0.37.4 -[v0.37.3]: https://github.com/vitorpamplona/amethyst/compare/v0.37.2...v0.37.3 -[v0.37.2]: https://github.com/vitorpamplona/amethyst/compare/v0.37.1...v0.37.2 -[v0.37.1]: https://github.com/vitorpamplona/amethyst/compare/v0.37.0...v0.37.1 -[v0.37.0]: https://github.com/vitorpamplona/amethyst/compare/v0.36.0...v0.37.0 -[v0.36.0]: https://github.com/vitorpamplona/amethyst/compare/v0.35.1...v0.36.0 -[v0.35.1]: https://github.com/vitorpamplona/amethyst/compare/v0.35.0...v0.35.1 -[v0.35.0]: https://github.com/vitorpamplona/amethyst/compare/v0.34.1...v0.35.0 -[v0.34.1]: https://github.com/vitorpamplona/amethyst/compare/v0.34.0...v0.34.1 -[v0.34.0]: https://github.com/vitorpamplona/amethyst/compare/v0.33.2...v0.34.0 -[v0.33.2]: https://github.com/vitorpamplona/amethyst/compare/v0.33.1...v0.33.2 -[v0.33.1]: https://github.com/vitorpamplona/amethyst/compare/v0.33.0...v0.33.1 -[v0.33.0]: https://github.com/vitorpamplona/amethyst/compare/v0.32.3...v0.33.0 -[v0.32.3]: https://github.com/vitorpamplona/amethyst/compare/v0.32.2...v0.32.3 -[v0.32.2]: https://github.com/vitorpamplona/amethyst/compare/v0.32.1...v0.32.2 -[v0.32.1]: https://github.com/vitorpamplona/amethyst/compare/v0.32.0...v0.32.1 -[v0.32.0]: https://github.com/vitorpamplona/amethyst/compare/v0.31.4...v0.32.0 -[v0.31.4]: https://github.com/vitorpamplona/amethyst/compare/v0.31.3...v0.31.4 -[v0.31.3]: https://github.com/vitorpamplona/amethyst/compare/v0.31.2...v0.31.3 -[v0.31.2]: https://github.com/vitorpamplona/amethyst/compare/v0.31.0...v0.31.2 -[v0.31.0]: https://github.com/vitorpamplona/amethyst/compare/v0.30.2...v0.31.0 -[v0.30.2]: https://github.com/vitorpamplona/amethyst/compare/v0.30.1...v0.30.2 -[v0.30.1]: https://github.com/vitorpamplona/amethyst/compare/v0.30.0...v0.30.1 -[v0.30.0]: https://github.com/vitorpamplona/amethyst/compare/v0.29.2...v0.30.0 -[v0.29.2]: https://github.com/vitorpamplona/amethyst/compare/v0.29.1...v0.29.2 -[v0.29.1]: https://github.com/vitorpamplona/amethyst/compare/v0.29.0...v0.29.1 -[v0.29.0]: https://github.com/vitorpamplona/amethyst/compare/v0.28.1...v0.29.0 -[v0.28.1]: https://github.com/vitorpamplona/amethyst/compare/v0.28.0...v0.28.1 -[v0.28.0]: https://github.com/vitorpamplona/amethyst/compare/v0.27.2...v0.28.0 -[v0.27.2]: https://github.com/vitorpamplona/amethyst/compare/v0.27.1...v0.27.2 -[v0.27.1]: https://github.com/vitorpamplona/amethyst/compare/v0.27.0...v0.27.1 -[v0.27.0]: https://github.com/vitorpamplona/amethyst/compare/v0.26.2...v0.27.0 -[v0.26.2]: https://github.com/vitorpamplona/amethyst/compare/v0.26.1...v0.26.2 -[v0.26.1]: https://github.com/vitorpamplona/amethyst/compare/v0.26.0...v0.26.1 -[v0.26.0]: https://github.com/vitorpamplona/amethyst/compare/v0.25.3...v0.26.0 -[v0.25.3]: https://github.com/vitorpamplona/amethyst/compare/v0.25.2...v0.25.3 -[v0.25.2]: https://github.com/vitorpamplona/amethyst/compare/v0.25.1...v0.25.2 -[v0.25.1]: https://github.com/vitorpamplona/amethyst/compare/v0.25.0...v0.25.1 -[v0.25.0]: https://github.com/vitorpamplona/amethyst/compare/v0.24.2...v0.25.0 -[v0.24.2]: https://github.com/vitorpamplona/amethyst/compare/v0.24.1...v0.24.2 -[v0.24.1]: https://github.com/vitorpamplona/amethyst/compare/v0.24.0...v0.24.1 -[v0.24.0]: https://github.com/vitorpamplona/amethyst/compare/v0.23.1...v0.24.0 -[v0.23.1]: https://github.com/vitorpamplona/amethyst/compare/v0.23.0...v0.23.1 -[v0.23.0]: https://github.com/vitorpamplona/amethyst/compare/v0.22.3...v0.23.0 -[v0.22.3]: https://github.com/vitorpamplona/amethyst/compare/v0.22.2...v0.22.3 -[v0.22.2]: https://github.com/vitorpamplona/amethyst/compare/v0.22.1...v0.22.2 -[v0.22.1]: https://github.com/vitorpamplona/amethyst/compare/v0.22.0...v0.22.1 -[v0.22.0]: https://github.com/vitorpamplona/amethyst/compare/v0.21.2...v0.22.0 -[v0.21.2]: https://github.com/vitorpamplona/amethyst/compare/v0.21.1...v0.21.2 -[v0.21.1]: https://github.com/vitorpamplona/amethyst/compare/v0.20.5...v0.21.1 -[v0.20.5]: https://github.com/vitorpamplona/amethyst/compare/v0.20.4...v0.20.5 -[v0.20.4]: https://github.com/vitorpamplona/amethyst/compare/v0.20.3...v0.20.4 -[v0.20.3]: https://github.com/vitorpamplona/amethyst/compare/v0.20.2...v0.20.3 -[v0.20.2]: https://github.com/vitorpamplona/amethyst/compare/v0.20.1...v0.20.2 -[v0.20.1]: https://github.com/vitorpamplona/amethyst/compare/v0.20.0...v0.20.1 -[v0.20.0]: https://github.com/vitorpamplona/amethyst/compare/v0.19.1...v0.20.0 -[v0.19.1]: https://github.com/vitorpamplona/amethyst/compare/v0.19.0...v0.19.1 -[v0.19.0]: https://github.com/vitorpamplona/amethyst/compare/v0.18.3...v0.19.0 -[v0.18.3]: https://github.com/vitorpamplona/amethyst/compare/v0.18.2...v0.18.3 -[v0.18.2]: https://github.com/vitorpamplona/amethyst/compare/v0.18.1...v0.18.2 -[v0.18.1]: https://github.com/vitorpamplona/amethyst/compare/v0.18.0...v0.18.1 -[v0.18.0]: https://github.com/vitorpamplona/amethyst/compare/v0.17.11...v0.18.0 -[v0.17.11]: https://github.com/vitorpamplona/amethyst/compare/v0.17.10...v0.17.11 -[v0.17.10]: https://github.com/vitorpamplona/amethyst/compare/v0.17.9...v0.17.10 -[v0.17.9]: https://github.com/vitorpamplona/amethyst/compare/v0.17.8...v0.17.9 -[v0.17.8]: https://github.com/vitorpamplona/amethyst/compare/v0.17.7...v0.17.8 -[v0.17.7]: https://github.com/vitorpamplona/amethyst/compare/v0.17.6...v0.17.7 -[v0.17.6]: https://github.com/vitorpamplona/amethyst/compare/v0.17.5...v0.17.6 -[v0.17.5]: https://github.com/vitorpamplona/amethyst/compare/v0.17.4...v0.17.5 -[v0.17.4]: https://github.com/vitorpamplona/amethyst/compare/v0.17.3...v0.17.4 -[v0.17.3]: https://github.com/vitorpamplona/amethyst/compare/v0.17.2...v0.17.3 -[v0.17.2]: https://github.com/vitorpamplona/amethyst/compare/v0.17.1...v0.17.2 -[v0.17.1]: https://github.com/vitorpamplona/amethyst/compare/v0.17.0...v0.17.1 -[v0.17.0]: https://github.com/vitorpamplona/amethyst/compare/v0.16.2...v0.17.0 -[v0.16.2]: https://github.com/vitorpamplona/amethyst/compare/v0.16.1...v0.16.2 -[v0.16.1]: https://github.com/vitorpamplona/amethyst/compare/v0.16.0...v0.16.1 -[v0.16.0]: https://github.com/vitorpamplona/amethyst/compare/v0.15.9...v0.16.0 -[v0.15.9]: https://github.com/vitorpamplona/amethyst/compare/v0.15.8...v0.15.9 -[v0.15.8]: https://github.com/vitorpamplona/amethyst/compare/v0.15.7...v0.15.8 -[v0.15.7]: https://github.com/vitorpamplona/amethyst/compare/v0.15.6...v0.15.7 -[v0.15.6]: https://github.com/vitorpamplona/amethyst/compare/v0.15.5...v0.15.6 -[v0.15.5]: https://github.com/vitorpamplona/amethyst/compare/v0.15.4...v0.15.5 -[v0.15.4]: https://github.com/vitorpamplona/amethyst/compare/v0.15.3...v0.15.4 -[v0.15.3]: https://github.com/vitorpamplona/amethyst/compare/v0.15.2...v0.15.3 -[v0.15.2]: https://github.com/vitorpamplona/amethyst/compare/v0.15.1...v0.15.2 -[v0.15.1]: https://github.com/vitorpamplona/amethyst/compare/v0.14.3...v0.15.1 -[v0.14.3]: https://github.com/vitorpamplona/amethyst/compare/v0.14.2...v0.14.3 -[v0.14.2]: https://github.com/vitorpamplona/amethyst/compare/v0.14.1...v0.14.2 -[v0.14.1]: https://github.com/vitorpamplona/amethyst/compare/v0.14.0...v0.14.1 -[v0.14.0]: https://github.com/vitorpamplona/amethyst/compare/v0.13.3...v0.14.0 -[v0.13.3]: https://github.com/vitorpamplona/amethyst/compare/v0.13.2...v0.13.3 -[v0.13.2]: https://github.com/vitorpamplona/amethyst/compare/v0.13.1...v0.13.2 -[v0.13.1]: https://github.com/vitorpamplona/amethyst/compare/v0.13.0...v0.13.1 -[v0.13.0]: https://github.com/vitorpamplona/amethyst/compare/v0.12.1...v0.13.0 -[v0.12.1]: https://github.com/vitorpamplona/amethyst/compare/v0.12.0...v0.12.1 -[v0.12.0]: https://github.com/vitorpamplona/amethyst/compare/v0.11.7...v0.12.0 -[v0.11.7]: https://github.com/vitorpamplona/amethyst/compare/v0.11.6...v0.11.7 -[v0.11.6]: https://github.com/vitorpamplona/amethyst/compare/v0.11.5...v0.11.6 -[v0.11.5]: https://github.com/vitorpamplona/amethyst/compare/v0.11.4...v0.11.5 -[v0.11.4]: https://github.com/vitorpamplona/amethyst/compare/v0.11.3...v0.11.4 -[v0.11.3]: https://github.com/vitorpamplona/amethyst/compare/v0.11.2...v0.11.3 -[v0.11.2]: https://github.com/vitorpamplona/amethyst/compare/v0.11.1...v0.11.2 -[v0.11.1]: https://github.com/vitorpamplona/amethyst/compare/v0.11.0...v0.11.1 -[v0.11.0]: https://github.com/vitorpamplona/amethyst/compare/v0.10.7...v0.11.0 -[v0.10.7]: https://github.com/vitorpamplona/amethyst/compare/v0.10.6...v0.10.7 -[v0.10.6]: https://github.com/vitorpamplona/amethyst/compare/v0.10.5...v0.10.6 -[v0.10.5]: https://github.com/vitorpamplona/amethyst/compare/v0.10.4...v0.10.5 -[v0.10.4]: https://github.com/vitorpamplona/amethyst/compare/v0.10.3...v0.10.4 -[v0.10.3]: https://github.com/vitorpamplona/amethyst/compare/v0.10.2...v0.10.3 -[v0.10.2]: https://github.com/vitorpamplona/amethyst/compare/v0.10.1...v0.10.2 -[v0.10.1]: https://github.com/vitorpamplona/amethyst/compare/v0.10.0...v0.10.1 -[v0.10.0]: https://github.com/vitorpamplona/amethyst/compare/v0.9.6...v0.10.0 -[v0.9.6]: https://github.com/vitorpamplona/amethyst/compare/v0.9.5...v0.9.6 -[v0.9.5]: https://github.com/vitorpamplona/amethyst/compare/v0.9.4...v0.9.5 -[v0.9.4]: https://github.com/vitorpamplona/amethyst/compare/v0.9.3...v0.9.4 -[v0.9.3]: https://github.com/vitorpamplona/amethyst/compare/v0.9.2...v0.9.3 -[v0.9.2]: https://github.com/vitorpamplona/amethyst/compare/v0.9.1...v0.9.2 -[v0.9.1]: https://github.com/vitorpamplona/amethyst/compare/v0.9...v0.9.1 -[v0.9]: https://github.com/vitorpamplona/amethyst/compare/v0.8.2...v0.9 -[v0.8.2]: https://github.com/vitorpamplona/amethyst/compare/v0.8.1...v0.8.2 -[v0.8.1]: https://github.com/vitorpamplona/amethyst/compare/v0.8...v0.8.1 -[v0.8]: https://github.com/vitorpamplona/amethyst/compare/v0.7...v0.8 -[v0.7]: https://github.com/vitorpamplona/amethyst/compare/v0.6...v0.7 -[v0.6]: https://github.com/vitorpamplona/amethyst/compare/v0.5...v0.6 -[v0.5]: https://github.com/vitorpamplona/amethyst/compare/v0.4...v0.5 -[v0.4]: https://github.com/vitorpamplona/amethyst/compare/v0.3...v0.4 -[v0.3]: https://github.com/vitorpamplona/amethyst/compare/v0.2...v0.3 -[v0.2]: https://github.com/vitorpamplona/amethyst/compare/v0.1...v0.2 -[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1 +When cutting a new release, copy +[`docs/changelog/TEMPLATE.md`](docs/changelog/TEMPLATE.md) to a new +`vMAJOR.MINOR.PATCH.md` file (zero-padded minor/patch, e.g. `v1.11.00.md`) and +add it to the top of the index. diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 0000000000..e65a25495d --- /dev/null +++ b/PLANS.md @@ -0,0 +1,82 @@ +# Amethyst plans index + +_Cross-module roll-up of every `plans/` folder. Surveyed 2026-06-30._ + +Plans in this repo are **decentralized**: each module keeps its own design docs +in `<module>/plans/YYYY-MM-DD-<slug>.md` (see `.claude/CLAUDE.md` → +"Plans per module"). There is no single plans directory — **this file is the +master index** that stitches the per-folder indexes together. + +Each plan carries a `Status:` header (shipped | in-progress | queued | +abandoned) backed by codebase evidence. **Shipped** plans are moved into each +folder's `archive/`; live work stays at the top level. For the full per-folder +listing (including archived plans), open that folder's `README.md`. + +> `docs/plans/` is the **frozen** legacy global folder — it is indexed here for +> completeness, but new plans must go in the owning module's `plans/` folder. + +## Totals + +**142 plans** across 10 folders: + +| Status | Count | +| ------ | ----: | +| shipped (archived) | 122 | +| in-progress | 9 | +| queued | 8 | +| abandoned | 3 | + +## By module + +| Module | Plans | Shipped | In-prog | Queued | Aband. | Index | +| ------ | ----: | ------: | ------: | -----: | -----: | ----- | +| amethyst | 21 | 19 | 1 | 1 | 0 | [amethyst/plans](amethyst/plans/README.md) | +| nestsClient | 26 | 23 | 1 | 2 | 0 | [nestsClient/plans](nestsClient/plans/README.md) | +| desktopApp | 13 | 10 | 2 | 1 | 0 | [desktopApp/plans](desktopApp/plans/README.md) | +| quartz | 9 | 7 | 0 | 2 | 0 | [quartz/plans](quartz/plans/README.md) | +| commons | 6 | 2 | 2 | 2 | 0 | [commons/plans](commons/plans/README.md) | +| cli | 6 | 5 | 1 | 0 | 0 | [cli/plans](cli/plans/README.md) | +| quic | 4 | 3 | 0 | 0 | 1 | [quic/plans](quic/plans/README.md) | +| quic/interop | 1 | 1 | 0 | 0 | 0 | [quic/interop/plans](quic/interop/plans/README.md) | +| geode | 4 | 4 | 0 | 0 | 0 | [geode/plans](geode/plans/README.md) | +| docs (frozen) | 52 | 48 | 2 | 0 | 2 | [docs/plans](docs/plans/README.md) | + +## Live work (not shipped) + +Everything still open, across all modules. Shipped plans are omitted here — find +them under each folder's `archive/` via the per-module index above. + +### In progress (9) + +| Module | Plan | Summary | +| ------ | ---- | ------- | +| amethyst | [ios-support](amethyst/plans/2026-05-24-ios-support.md) | KMP-to-iOS port; quartz/commons iOS targets configured (Phase 1) but no `iosApp` module yet. | +| commons | [custom-feeds-plan](commons/plans/2026-05-04-custom-feeds-plan.md) | Custom feeds; model + builder + kind 31890 + desktop UI shipped, but relay-filter layer, DVM marketplace, kind 10090 sync, list resolution pending. | +| commons | [nest-subscription-manager-extraction](commons/plans/2026-05-06-nest-subscription-manager-extraction.md) | Split per-speaker subscription state machine out of `NestViewModel`; only the `ActiveSubscription` stepping-stone extracted. | +| desktopApp | [wallet-zapping-test-coverage](desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md) | NWC handler + RPC round-trip tests shipped; wallet-column-state and zap-dialog-logic tests still missing. | +| desktopApp | [napplet-desktop-host](desktopApp/plans/2026-06-21-napplet-desktop-host.md) | Desktop NIP-5A/5D host; shared core extractions done, desktop engine/scheme-handler/transport/UI edge not built. | +| cli | [cashu-cli](cli/plans/2026-05-28-cashu-cli.md) | NIP-60/61/87 Cashu wallet verbs in amy; full command surface ships, production-mint interop harness pending. | +| nestsClient | [t16-closure-roadmap](nestsClient/plans/2026-05-07-t16-closure-roadmap.md) | Priorities 1 & 2 closed and suite passes, but CI gating deferred and framesPerGroup rerun + two upstream items open. | +| docs | [viewport-aware-metadata-loading](docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md) | Base preloader/rate-limiter infra exists but the LazyListState/snapshotFlow viewport selection isn't clearly wired. | +| docs | [macos-bunker-relogin](docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md) | PR 1 defense-in-depth shipped, but the cold-boot root cause is still open/unidentified. | + +### Queued (8) + +| Module | Plan | Summary | +| ------ | ---- | ------- | +| amethyst | [napplet-inter-applet](amethyst/plans/2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not built. | +| quartz | [local-headers-explorer](quartz/plans/2026-05-08-local-headers-explorer.md) | Headers-only Bitcoin P2P client to verify NIP-03 OTS attestations without a trusted block explorer. | +| quartz | [giftwrap-deletion-requests](quartz/plans/2026-06-12-giftwrap-deletion-requests.md) | Let a recipient-authored kind-5 delete/block a gift wrap (kind 1059) addressed to them. | +| commons | [event-renderer](commons/plans/2026-04-21-event-renderer.md) | Cross-platform UI-agnostic `RenderedEvent` subsystem shared by Amy, Desktop, Android; not started. | +| commons | [amethyst-to-commons-migration](commons/plans/2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. | +| desktopApp | [embedded-wallet-phase2-research](desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md) | Research for an embedded self-custodial Lightning wallet (Breez/ldk-node/lightning-kmp); parked, no code. | +| nestsClient | [cross-stack-interop-ci-gating](nestsClient/plans/2026-05-07-cross-stack-interop-ci-gating.md) | CI gating for the cross-stack interop suite; infra built then removed over wallclock cost, kept as a ready revisit target. | +| nestsClient | [framespergroup-production-rerun](nestsClient/plans/2026-05-07-framespergroup-production-rerun.md) | Re-run the two-phone field tests to settle the framesPerGroup test-pin (5) vs default (50); needs prod-rig access. | + +### Abandoned (3) + +| Module | Plan | Summary | +| ------ | ---- | ------- | +| quic | [congestion-control](quic/plans/2026-05-05-congestion-control.md) | NewReno congestion control parked indefinitely; the real concern was solved by the smaller `SendBuffer.bestEffort` fix instead. | +| docs | [desktop-relay-config-single-source](docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md) | Single `DesktopRelayConfig` class never built; relay state landed as `DesktopRelayCategories`/`LocalRelayCategories` instead. | +| docs | [macos-vlc-bundled-discovery](docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md) | macOS bundled-VLC `setenv` discovery fix; moot after VLC/VLCJ was removed entirely in the kdroidFilter migration. | diff --git a/README.md b/README.md index 5f72d7de3e..9148b6b1be 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Join the social network you control. [![PlayStore downloads](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.vitorpamplona.amethyst%26gl%3DUS%26hl%3Den%26l%3DPlayStore%26m%3D%24shortinstalls)](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst) [![Last Version](https://img.shields.io/github/release/vitorpamplona/amethyst.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/vitorpamplona/amethyst) -[![JitPack version](https://jitpack.io/v/vitorpamplona/amethyst.svg)](https://jitpack.io/#vitorpamplona/amethyst) +[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz) +[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst) [![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml) [![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst) @@ -269,18 +270,16 @@ For the Play build: ## Deploying -Full release + bootstrap runbooks (Android AAB upload, desktop packaging, -Homebrew cask, Winget manifest, Apple Developer signing budget time-box) live -in [BUILDING.md § Release runbook](BUILDING.md#release-runbook) and -[BUILDING.md § Bootstrap runbook (one-time)](BUILDING.md#bootstrap-runbook-one-time). +A release is one tag push. Bump `app` and `appCode` in +`gradle/libs.versions.toml`, then `git tag -s vX.Y.Z && git push --tags` — the +`Create Release Assets` workflow builds and signs every Android, desktop, CLI, +and Maven artifact, and Homebrew + Winget auto-bump on stable tags. -TL;DR for cutting a release: - -1. Bump `app` in `gradle/libs.versions.toml` (e.g. `"1.08.1"`) -2. Bump `versionCode` in `amethyst/build.gradle` -3. `git commit -am "chore(release): 1.08.1" && git tag -s v1.08.1 && git push --tags` -4. Wait for `Create Release Assets` workflow — 20 Android assets + 8 desktop assets go live on GH Release; Homebrew + Winget auto-bump on stable tags -5. Upload AAB to Play Store manually (existing step) +- **[BUILDING.md](BUILDING.md)** — everything any fork needs: build commands, + the CI pipeline, the secrets it requires, and the distribution channels. +- **[RELEASE_OPS.md](RELEASE_OPS.md)** — the Amethyst maintainers' + ship checklist: the manual Play Store upload, Zapstore `zsp publish`, F-Droid + pull, and release-notes publishing. ## Using the Quartz library @@ -298,20 +297,43 @@ repositories { Add the following line to your `commonMain` dependencies: ```gradle -implementation('com.vitorpamplona.quartz:quartz:1:05.0') +implementation('com.vitorpamplona.quartz:quartz:1.12.6') ``` Variations to each platform are also available: ```gradle -implementation('com.vitorpamplona.quartz:quartz-android:1:05.0') -implementation('com.vitorpamplona.quartz:quartz-jvm:1:05.0') -implementation('com.vitorpamplona.quartz:quartz-iosarm64:1:05.0') -implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1:05.0') +implementation('com.vitorpamplona.quartz:quartz-android:1.12.6') +implementation('com.vitorpamplona.quartz:quartz-jvm:1.12.6') +implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.12.6') +implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.12.6') ``` Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz) +#### Snapshots (JitPack) + +Tagged releases go to Maven Central. For **pre-release / snapshot** builds — +e.g. to test an unreleased fix straight from `main` or a feature branch — use +[JitPack](https://jitpack.io/#vitorpamplona/amethyst), which builds the module +on demand from any git ref: + +```gradle +repositories { + maven { url = uri("https://jitpack.io") } +} + +dependencies { + // version can be a tag, a commit hash, or "<branch>-SNAPSHOT" + implementation("com.github.vitorpamplona.amethyst:quartz:main-SNAPSHOT") +} +``` + +The resolvable refs and the exact module coordinates are listed on the +[JitPack page](https://jitpack.io/#vitorpamplona/amethyst). Prefer a Maven +Central release for anything shipping to production — JitPack snapshots are not +guaranteed stable. + ### How to use Manage logged in users with the `KeyPair` class diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md new file mode 100644 index 0000000000..1f0975423e --- /dev/null +++ b/RELEASE_OPS.md @@ -0,0 +1,235 @@ +# Release Ops (Amethyst maintainers) + +This is the **operational checklist the Amethyst team follows to ship a +release** — the account-specific, push-the-buttons side of cutting a version. +The *generic* build/release mechanics (how the CI pipeline works, the asset +naming contract, the secret names a fork must set, desktop packaging) live in +[`BUILDING.md`](BUILDING.md). Read that first; this doc only covers what is +specific to shipping the official Amethyst artifacts. + +> Forks: you do **not** need this file. `BUILDING.md` has everything you need to +> build and release your own fork. This describes our accounts and channels. + +--- + +## At a glance + +A release is one tag push that fans out to five distribution channels: + +| Channel | Mechanism | Who pushes | +|---|---|---| +| **GitHub Releases** | Automatic — the `Create Release Assets` workflow builds + signs everything on the `v*` tag | CI | +| **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer | +| **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) | +| **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer | +| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI | + +Maven Central (the `quartz` library) also publishes automatically from the same +workflow. + +--- + +## 1. Pre-tag checklist + +1. **Bump the version** in `gradle/libs.versions.toml` — both keys: + ```toml + app = "1.12.1" # semver, drives every module + the tag + appCode = "449" # Android versionCode, monotonic — must increment + ``` + That single edit propagates to Android (`versionName`/`versionCode`), + Desktop & CLI (`packageVersion`), `quartz` (Maven version) and `geode` + (`RelayInfo.VERSION`). Nothing else hardcodes the version. + +2. **Write the changelog** as `docs/changelog/vMAJOR.MINOR.PP.md` (zero-padded, + e.g. `v1.12.01.md`) and add it to `docs/changelog/README.md`. Follow the + house style: plain text, short verb-first sentences. + + For the `## Translations` section, generate the credits instead of writing + them by hand — no token needed: + ```bash + scripts/translators.sh + ``` + This runs offline. It reads `docs/changelog/translators.json` (kept next to + the changelogs), which CI keeps fresh: the Crowdin sync workflow's + `seed-translators` job records everyone who has translated since the last `v*` + tag, with their languages, in the file's `sinceLastTag` list, and accumulates + their npubs in the forever-growing `mappings` registry. The script resolves + that list to npubs and prints the `## Translations` block grouped by language. + Contributors with no npub yet are listed under `UNMAPPED` — credit them by + hand, then add their npub under `mappings` so future releases pick them up + automatically. (To re-query Crowdin live as a sanity check, run + `scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` / + `CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.) + +3. **Publish the release-notes note on Nostr** with Amethyst's account and paste + its event id into `amethyst/build.gradle.kts`: + ```kotlin + buildConfigField("String", "RELEASE_NOTES_ID", "\"<new-event-id-hex>\"") + ``` + This id is what the in-app drawer's "Release Notes" link and the donation + card open (`DrawerContent.kt`, `ShowDonationCard.kt`). It must point at the + note for *this* version, so publish the note **before** tagging and commit + the new id together with the version bump. + + <!-- TODO(maintainer): document the exact command/account used to publish the + release-notes note (which signer, which relays). --> + +4. **Sanity-build locally** (optional but cheap): `./gradlew assembleRelease` + and a desktop `packageDistributionForCurrentOS`, or run the workflow's + dry-run (see BUILDING.md § Dry-run). + +--- + +## 2. Cut the release + +Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook) +for the exact commands. The tag must equal `app` from the catalog (the workflow +asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is +classified **stable** and triggers the Homebrew/Winget bumps; anything with a +`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them. + +When the `Create Release Assets` workflow finishes (~25–30 min) the GH Release +holds, per the asset-name contract: + +- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + (`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`) +- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz) +- **CLI:** the `amy` artifacts +- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published + +--- + +## 3. Per-channel shipping + +### GitHub Releases — automatic +Nothing to do beyond pushing the tag. Verify the asset count and that Intel + +ARM DMGs are both present (BUILDING.md § Verify). + +### Google Play — manual upload +1. Download `amethyst-googleplay-<version>.aab` from the GH Release. +2. Play Console → app `com.vitorpamplona.amethyst` → **Production** (or the + staged-rollout track we're using) → create release → upload the AAB. +3. The release notes field can reuse the `docs/changelog` text. +4. Roll out. + +### F-Droid — pull / build-from-source +F-Droid does **not** accept an upload from us. Its build server polls the repo, +and when it sees the new `v*` tag it builds the **`fdroid` product flavor** from +source (reproducibly) per the recipe in the separate +[`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo +(`metadata/com.vitorpamplona.amethyst.yml`), then signs and publishes to the +F-Droid repo on its own cadence. + +What we own to keep that working: +- The **`fdroid` flavor** (`amethyst/src/fdroid/…`) must stay free of + proprietary deps — it swaps Firebase/Google services for UnifiedPush and + no-op/open implementations (ML Kit, writing assistant, push). Google-only + libraries live behind the `play` flavor. +- The fastlane metadata under `fastlane/metadata/android/` (descriptions, + images). F-Droid reads per-version changelogs from + `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` if present — + add one (e.g. `449.txt`) when we want a changelog shown on F-Droid; otherwise + none is displayed. +- The `AutoUpdateMode`/`UpdateCheckMode` in the fdroiddata recipe tracks tags, + so a correct `vX.Y.Z` tag + bumped `versionCode` is usually all F-Droid needs. + +After a release, just confirm F-Droid picked up the new version (it can lag a +few days): <https://f-droid.org/packages/com.vitorpamplona.amethyst/>. + +### Zapstore — `zsp publish` with Amethyst's nsec +[Zapstore](https://zapstore.dev/) is a Nostr-native app store. The `zsp` CLI +reads [`zapstore.yaml`](zapstore.yaml) at the repo root (name, summary, +description, tags, license, `icon`, screenshots, `supported_nips`, and the +`variants` regexes that match our `*-fdroid-*.apk` / `*-googleplay-*.apk` +GH-release assets), then publishes a signed software-release event to Nostr +relays. + +```bash +# from the repo root, after the GH Release assets exist +zsp publish +``` + +It signs with **Amethyst's nsec** — provide the key the way `zsp` expects +(`SIGN_WITH` env var / prompt / its own config), never commit it. + +**Relays.** `zsp` does *not* take relays from `zapstore.yaml`; it reads the +`RELAY_URLS` env var (comma-separated) and defaults to `wss://relay.zapstore.dev` +when unset. To fan the release event out to more relays for discoverability, +set `RELAY_URLS` for the run: + +```bash +RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vitor.nostr1.com" \ + SIGN_WITH=<amethyst-nsec> zsp publish +``` + +Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app +itself reads from. + +### Homebrew + Winget — automatic +`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs +against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and +`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails — +then see BUILDING.md § Bootstrap and § Incident response. + +--- + +## 4. Operated infrastructure + +### Push notification server + +The Google Play (FCM) flavor delivers push through a server we operate at +`push.amethyst.social`, built from +[`vitorpamplona/amethyst-push-notif-server`](https://github.com/vitorpamplona/amethyst-push-notif-server). +It registers devices, watches their NIP-65 inbox / NIP-17 DM relays, and sends +wake-up pushes. + +- **`play` flavor** → push via this server (Firebase/FCM). +- **`fdroid` flavor** → UnifiedPush through a distributor app the user installs + (e.g. ntfy); it does **not** use our server. +- Both are complemented by the on-device always-on `NotificationRelayService` + (see [`PULL_NOTIFICATION.md`](PULL_NOTIFICATION.md)), which keeps the user's + relay connections alive without any push server at all. + +The push server has its **own repo, deploy, and release cadence** — a normal app +release does **not** redeploy it. Coordinate a server deploy only when the app +changes the registration/push contract (token format, payload, or endpoint), so +the running server stays compatible with the shipped app. + +<!-- TODO(maintainer): document the push-server deploy steps + hosting, and + which app-side changes require a coordinated push-server deploy. --> + +--- + +## 5. Secrets ownership & rotation + +The workflow's required secrets and what they sign are inventoried generically +in [`BUILDING.md` § Secrets](BUILDING.md#secrets-the-ci-needs). Amethyst-specific +ownership: + +| Secret(s) | Protects | Rotation | +|---|---|---| +| `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) | +| `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise | +| `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry | +| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) | +| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise | + +Owner assignments and rotation reminders live with the team (issue tracker). + +<!-- TODO(maintainer): name the owner per secret and where backups live. --> + +--- + +## 6. Post-release verification + +- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane. +- [ ] Maven Central: `quartz:<version>` resolves (allow propagation time). +- [ ] Play Console: rollout started, no policy rejection. +- [ ] Zapstore: release event visible. +- [ ] F-Droid: new version detected (may lag days). +- [ ] Homebrew + Winget bump PRs opened (stable only). +- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`. +- [ ] Push still works on a `play` build (only if the push contract changed — + see § 4); UnifiedPush still works on an `fdroid` build. + +If anything ships broken, see [`BUILDING.md` § Incident response](BUILDING.md#incident-response). diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 5c99450c07..1c6dc4e336 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -76,9 +76,12 @@ android { libs.versions.android.targetSdk .get() .toInt() - versionCode = 447 + versionCode = + libs.versions.appCode + .get() + .toInt() versionName = generateVersionName(libs.versions.app.get(), rootDir) - buildConfigField("String", "RELEASE_NOTES_ID", "\"8ec0d94550b5538115226c6858159b1115713c9c6ed942173bd4fd5d292d8ba6\"") + buildConfigField("String", "RELEASE_NOTES_ID", "\"40e817712e397c07ba31784a92fa474aa095896a828c0e2dea0d09c60d49ee1e\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -261,6 +264,23 @@ android { resValues = true } + // Reproducible builds: keep AGP from embedding the dependency-metadata blob + // in the APK/AAB. That blob is a protobuf of the resolved dependency tree + // encrypted with a Google public key; the ciphertext is non-deterministic, + // so its presence makes every release artifact impossible to reproduce + // bit-for-bit. Dropping it (F-Droid's documented recommendation) lets + // F-Droid / Zapstore independently rebuild and verify our developer-signed + // APKs. + // + // Play-channel trade-off: with includeInBundle = false the uploaded .aab no + // longer carries this metadata, so Play Console's app-dependency insights / + // known-vulnerability SDK alerts go unpopulated. Uploads still succeed; only + // that advisory feature is lost. + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + packaging { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib") @@ -282,7 +302,7 @@ android { unitTests.all { test -> test.systemProperty( "java.library.path", - "${projectDir}/src/test/native-libs/x86_64-linux", + "$projectDir/src/test/native-libs/x86_64-linux", ) project .findProperty("amethyst.arti.integration") @@ -328,12 +348,31 @@ composeCompiler { dependencies { implementation(platform(libs.androidx.compose.bom)) + // Compose composition tracing — DEBUG ONLY, profiling aid (not shipped). Makes each + // recomposition show up as a NAMED slice in Perfetto system traces so we can see which + // composable recomposes (e.g. during the cold-start feed first-paint). All Apache-2.0. + // Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source: + // adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \ + // -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver + debugImplementation("androidx.compose.runtime:runtime-tracing") + debugImplementation("androidx.tracing:tracing-perfetto:1.0.0") + debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.0") + implementation(project(":quartz")) implementation(project(":commons")) implementation(project(":nestsClient")) + implementation(project(":nappletHost")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) + // Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge). + implementation(libs.androidx.webkit) + + // Client side of the cross-process UI embedding: renders the sandboxed browser surface (hosted in + // the keyless `:napplet` process) inside a Compose component in the main app. + implementation(libs.androidx.privacysandbox.ui.core) + implementation(libs.androidx.privacysandbox.ui.client) + implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) @@ -365,6 +404,9 @@ dependencies { // Background Work implementation(libs.androidx.work.runtime.ktx) + // Reads workouts from Android Health Connect (Samsung Health, Google Fit, Fitbit, Garmin, …) + implementation(libs.androidx.health.connect.client) + // Websockets API implementation(libs.okhttp) implementation(libs.okhttpCoroutines) @@ -402,6 +444,9 @@ dependencies { implementation(libs.zxing) implementation(libs.zxing.embedded) + // OpenStreetMap tiles for road event location maps (kind 1315/1316) + implementation(libs.osmdroid.android) + // Markdown // implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0" // implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0" @@ -412,6 +457,14 @@ dependencies { implementation(libs.markdown.ui.material3) implementation(libs.markdown.commonmark) + // Syntax highlighting for the git repository code browser (Apache-2.0) + implementation(libs.highlights) + + // LaTeX math rendering ($...$ and $$...$$ inline equations) + implementation(libs.jlatexmath.android) + implementation(libs.jlatexmath.font.greek) + implementation(libs.jlatexmath.font.cyrillic) + // Language picker and Theme chooser implementation(libs.androidx.appcompat) @@ -469,9 +522,6 @@ dependencies { // EXIF metadata stripping implementation(libs.androidx.exifinterface) - // Voice anonymization DSP - implementation(libs.tarsosdsp) - // WebRTC for voice/video calls implementation(libs.stream.webrtc.android) diff --git a/amethyst/plans/2026-05-24-ios-support.md b/amethyst/plans/2026-05-24-ios-support.md index 340b2eeb24..2c73f93df7 100644 --- a/amethyst/plans/2026-05-24-ios-support.md +++ b/amethyst/plans/2026-05-24-ios-support.md @@ -1,5 +1,8 @@ # iOS Support for Amethyst +> **Status:** in-progress — `iosArm64`/`iosSimulatorArm64` targets are configured in `quartz` and `commons` (Phase 1), but no `iosApp` module exists yet — later phases not built. +> _Audited 2026-06-30._ + **Date:** 2026-05-24 **Status:** Phase 1 complete; Phase 2 in flight **Owner:** TBD diff --git a/amethyst/plans/2026-06-20-napplet-inter-applet.md b/amethyst/plans/2026-06-20-napplet-inter-applet.md new file mode 100644 index 0000000000..b5cb6ed6e4 --- /dev/null +++ b/amethyst/plans/2026-06-20-napplet-inter-applet.md @@ -0,0 +1,97 @@ +# Napplet inter-applet communication (NAP-INC / NAP-INTENT) — design notes + +> **Status:** queued — Explicitly deferred; no `MESSAGING` capability exists in `NappletCapability` and the prerequisites (multi-applet hosting, archetype registry) are unbuilt. +> _Audited 2026-06-30._ + +**Date:** 2026-06-20 +**Status:** Deferred — design only. Prereqs not yet built (see below). +**Parent:** `amethyst/plans/2026-06-19-napplet-sandbox-host.md` + +## Why this is deferred (not just "next") + +Inter-applet messaging is the one napplet capability that needs **new +architecture**, not just a new broker op + gateway. Two hard prerequisites are +missing today: + +1. **Multiple applets running at once.** `NappletHostActivity` is declared + `launchMode="singleTask"` and hosts exactly one applet. True live A↔B + messaging (the full NAP-INC request/result transport) requires either + multi-applet hosting (several iframes in one host, or several host processes) + plus a routing layer — none of which exists. +2. **An archetype / handler registry.** NAP-INTENT dispatches by *archetype* + (`note`, `feed`, `profile`, …) to a default-handler napplet. Our + `NappletManifest` has no `handles`/archetype declaration and there is no + "which napplet is the default handler for X" registry. + +Both are sizeable subsystems. Shipping a half-version would also risk **forking +the wire format** from upstream while it is still being defined. + +## Upstream model (napplet/naps survey, 2026-06-20) + +Inter-applet is split into two shell-mediated specs (applets never reach each +other directly — every message crosses the shell/broker): + +- **NAP-INC** (`inc`) — the transport. Messages are `{ type: "domain.action", + id, … }`, request/result correlated by `id`. Addressing is direct + (napplet→napplet) *or* archetype-mediated by the runtime. +- **NAP-INTENT** (`intent`) — invoke a napplet by **archetype** via + default-handler dispatch (`shell.supports("intent")`). The shell launches the + handler; napplets cannot invoke directly. +- **NAP-1…5** — concrete protocols on top of NAP-INC: `profile:*` (NAP-1), + `stream:*` (NAP-2), `chat:*` (NAP-3), `note:open` (NAP-4), `feed:*` (NAP-5). + Producer/consumer model. + +Discovery is capability-probe based: `shell.supports("inc")`, +`shell.supports("inc", "NAP-N")`. + +## How it would map onto our boundary + +The broker model fits "shell-mediated" naturally — every message would cross +`NappletBrokerService` exactly like every other capability, gated by the ledger. +The pieces: + +1. **Capability.** Add `NappletCapability.MESSAGING` mapped from NAP domains + `inc` / `intent` (default-deny like every other domain). Consent is a *link* + grant ("Applet A may message / open Applet B"), distinct from per-op consent. +2. **Protocol.** New `NappletRequest`/`NappletResponse` variants under + `MESSAGING`, shaped to mirror NAP-INC (`type = "domain.action"`, `id` + correlation) so we don't fork the wire format. +3. **Addressing.** Direct by napplet coordinate first; archetype dispatch only + after the registry (below) exists. + +### Two viable implementation shapes (pick at build time) + +- **NAP-INTENT, direct coordinate** — `napplet.intent({ target, payload })` → + consent → broker resolves `target` to a manifest in `LocalCache` → launches it + via a `NappletIntentLauncher` gateway, passing an initial payload the target + reads on startup (`napplet.intent` / `onIntent`). Fits the single-applet model + (you switch to the target). No simultaneous hosting needed. **Lowest lift; most + aligned with NAP-INTENT.** Result-return across the switch is awkward (fire-and- + forget, or a callback event). +- **NAP-INC brokered mailbox** — `napplet.sendTo(coordinate, msg)` / + `pollMessages()` with broker-persisted per-napplet inboxes, consent per link. + Works with no simultaneous hosting and is fully unit-testable, but it is async + fire-and-collect, not the request/result transport upstream describes. + +Full live NAP-INC (simultaneous A↔B, request/result) needs the multi-applet +hosting prereq regardless. + +## Prerequisites to build first + +1. **Multi-applet hosting** — either N iframes in one `NappletHostActivity` with + per-iframe origin isolation + routing, or a host-per-applet process model and + a cross-process router in the broker. Decide the model before coding NAP-INC. +2. **Archetype registry** — a manifest `handles`/archetype tag (align with + upstream naps), an index over installed napplets, and a user-set default + handler per archetype (mirror NIP-89 handler selection, which Amethyst already + models for app recommendations). +3. **Link-consent UX** — distinct from capability consent: "Allow *Chess* to open + *Wallet*?", revocable per pair in a permissions screen. + +## Recommendation + +When picked up: start with **NAP-INTENT direct-coordinate** (smallest, aligned, +no new hosting), build the archetype registry next (unlocks default-handler +dispatch + reuses NIP-89 patterns), and only then tackle live NAP-INC once +multi-applet hosting lands. Keep the wire `type`/`id` shape identical to upstream +NAP-INC throughout to avoid a fork. diff --git a/amethyst/plans/README.md b/amethyst/plans/README.md new file mode 100644 index 0000000000..ce70e7b511 --- /dev/null +++ b/amethyst/plans/README.md @@ -0,0 +1,36 @@ +# amethyst plans + +_Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0 abandoned._ + +## In progress +| Plan | Summary | +| ---- | ------- | +| [2026-05-24-ios-support.md](2026-05-24-ios-support.md) | Incremental KMP-to-iOS port; quartz/commons iOS targets are configured (Phase 1) but no `iosApp` module exists yet. | + +## Queued +| Plan | Summary | +| ---- | ------- | +| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. | + +## Archived (shipped) +| Plan | Summary | +| ---- | ------- | +| [archive/2026-05-14-onchain-zaps.md](archive/2026-05-14-onchain-zaps.md) | NIP-BC (kind 8333) onchain Bitcoin zaps — hand-rolled `quartz/nipBCOnchainZaps/` consensus layer plus Android send/receive/display. | +| [archive/2026-05-25-appfunctions-signer-prompts.md](archive/2026-05-25-appfunctions-signer-prompts.md) | How AppFunctions write verbs acquire signatures across the three signer types; write verbs now ship. | +| [archive/2026-05-26-appfunctions-gemini-discovery.md](archive/2026-05-26-appfunctions-gemini-discovery.md) | Verifying Gemini-side discovery of Amethyst's AppFunctions; description-based (`isDescribedByKDoc`) discovery shipped. | +| [archive/2026-05-26-appfunctions-screens-as-verbs.md](archive/2026-05-26-appfunctions-screens-as-verbs.md) | Map every Amethyst screen's feed filter to an AppFunction/MCP verb; 46 verbs now ship. | +| [archive/2026-05-26-avif-implementation-plan.md](archive/2026-05-26-avif-implementation-plan.md) | Task-by-task plan for AVIF support via a `MediaMimeTypes` helper across the upload pipeline. | +| [archive/2026-05-26-avif-support.md](archive/2026-05-26-avif-support.md) | Comprehensive design making AVIF a first-class image format on every Amethyst surface. | +| [archive/2026-05-27-avif-instrumented-tests-design.md](archive/2026-05-27-avif-instrumented-tests-design.md) | Design for on-device AVIF upload-pipeline regression tests with committed fixtures. | +| [archive/2026-05-27-avif-instrumented-tests-plan.md](archive/2026-05-27-avif-instrumented-tests-plan.md) | Implementation plan for the AVIF instrumented + JVM unit tests and their fixtures. | +| [archive/2026-06-01-dm-live-tail-and-history-slices.md](archive/2026-06-01-dm-live-tail-and-history-slices.md) | DM loading split into a fixed live tail plus per-relay backward history paging (`WindowLoadTracker` / `RelayLoadingCursors`). | +| [archive/2026-06-19-napplet-sandbox-host.md](archive/2026-06-19-napplet-sandbox-host.md) | Keyless `:napplet`-process WebView host with brokered, consent-gated capabilities for NIP-5A/5D content. | +| [archive/2026-06-20-napplet-ecosystem-audit.md](archive/2026-06-20-napplet-ecosystem-audit.md) | Audit of our napplet shell against the upstream `@napplet` SDK; wire-compat gaps subsequently closed. | +| [archive/2026-06-21-napplet-code-audit.md](archive/2026-06-21-napplet-code-audit.md) | Code audit of the napplet subsystem recording correctness/perf/refactor fixes and deferred items. | +| [archive/2026-06-21-napplet-sdk-conformance-audit.md](archive/2026-06-21-napplet-sdk-conformance-audit.md) | Feature-by-feature SDK conformance audit; the four conformance breakers were fixed and pinned by tests. | +| [archive/2026-06-22-napplet-nsite-security.md](archive/2026-06-22-napplet-nsite-security.md) | Security review of the nsite/napplet attack surface; launch-token identity and per-applet origins landed. | +| [archive/2026-06-23-napplet-nap-theme-notify-inc.md](archive/2026-06-23-napplet-nap-theme-notify-inc.md) | Add the `theme`, `notify`, and `inc` NAP domains so demo napplets boot; capabilities + `NappletIncBus` shipped. | +| [archive/2026-06-24-napplet-embedded-tabs.md](archive/2026-06-24-napplet-embedded-tabs.md) | Embedded warm bottom-bar napplet/nsite/browser tabs via `SurfaceControlViewHost`, in the new `:nappletHost` module. | +| [archive/2026-06-25-embed-text-selection-native-parity.md](archive/2026-06-25-embed-text-selection-native-parity.md) | Host-drawn text selection (handles, magnifier, toolbar, IME proxy) for embedded sandboxed surfaces. | +| [archive/2026-06-25-web-app-naming.md](archive/2026-06-25-web-app-naming.md) | Naming overhaul for web-app / nApplet / nSite / favorite surfaces (route, screen, controller renames). | +| [archive/2026-06-26-nsite-napplet-favorite-icons.md](archive/2026-06-26-nsite-napplet-favorite-icons.md) | Derive favorited nSite/nApplet bottom-nav icons from verified manifest blobs (`NappletIconPath`). | diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/archive/2026-05-14-onchain-zaps.md similarity index 98% rename from amethyst/plans/2026-05-14-onchain-zaps.md rename to amethyst/plans/archive/2026-05-14-onchain-zaps.md index d2725b58e5..4e06e084b3 100644 --- a/amethyst/plans/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/archive/2026-05-14-onchain-zaps.md @@ -1,5 +1,8 @@ # Onchain Zaps in Amethyst +> **Status:** shipped — Full `quartz/nipBCOnchainZaps/` consensus + builder + verify layer ships with Android model/UI wiring (`OnchainZapResolver`, `OnchainZapEvent` view, `ReusableZapButton`). +> _Audited 2026-06-30._ + **Date:** 2026-05-14 **Status:** Active diff --git a/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md b/amethyst/plans/archive/2026-05-25-appfunctions-signer-prompts.md similarity index 98% rename from amethyst/plans/2026-05-25-appfunctions-signer-prompts.md rename to amethyst/plans/archive/2026-05-25-appfunctions-signer-prompts.md index 787b30bf26..370deb22c8 100644 --- a/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md +++ b/amethyst/plans/archive/2026-05-25-appfunctions-signer-prompts.md @@ -1,5 +1,8 @@ # AppFunctions signer prompts — design +> **Status:** shipped — `AmethystAppFunctions.kt` ships 46 `@AppFunction` verbs including write verbs that acquire signatures across signer types. +> _Audited 2026-06-30._ + **Date:** 2026-05-25 **Status:** Draft — no code yet diff --git a/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md b/amethyst/plans/archive/2026-05-26-appfunctions-gemini-discovery.md similarity index 96% rename from amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md rename to amethyst/plans/archive/2026-05-26-appfunctions-gemini-discovery.md index e66007e512..f44577c064 100644 --- a/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md +++ b/amethyst/plans/archive/2026-05-26-appfunctions-gemini-discovery.md @@ -1,5 +1,8 @@ # Verifying Gemini-side AppFunctions discovery +> **Status:** shipped — Verification doc; verbs ship with `@AppFunction(isDescribedByKDoc = true)` description-based discovery as the doc concludes. +> _Audited 2026-06-30._ + **Date:** 2026-05-26 **Status:** Active — answers the open question from `2026-05-25-appfunctions-signer-prompts.md` diff --git a/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md b/amethyst/plans/archive/2026-05-26-appfunctions-screens-as-verbs.md similarity index 98% rename from amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md rename to amethyst/plans/archive/2026-05-26-appfunctions-screens-as-verbs.md index f149d00818..a90f8456db 100644 --- a/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md +++ b/amethyst/plans/archive/2026-05-26-appfunctions-screens-as-verbs.md @@ -1,5 +1,8 @@ # All Amethyst screens as AppFunctions / MCP endpoints +> **Status:** shipped — 46 `@AppFunction` verbs ship in `AmethystAppFunctions.kt`, exceeding the screen-to-verb surface this plan proposed. +> _Audited 2026-06-30._ + **Date:** 2026-05-26 **Status:** Active — informs the v1 read-verb surface and guides future MCP work diff --git a/amethyst/plans/2026-05-26-avif-implementation-plan.md b/amethyst/plans/archive/2026-05-26-avif-implementation-plan.md similarity index 99% rename from amethyst/plans/2026-05-26-avif-implementation-plan.md rename to amethyst/plans/archive/2026-05-26-avif-implementation-plan.md index f7bf781771..2de23e1922 100644 --- a/amethyst/plans/2026-05-26-avif-implementation-plan.md +++ b/amethyst/plans/archive/2026-05-26-avif-implementation-plan.md @@ -1,5 +1,8 @@ # AVIF Support Implementation Plan +> **Status:** shipped — `service/uploads/MediaMimeTypes.kt` and the rest of the AVIF upload pipeline are present in `amethyst/`. +> _Audited 2026-06-30._ + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make AVIF (still and animated) work as a first-class image format in Amethyst across every user-visible surface — uploads (Blossom + NIP-96), feeds, profiles, DMs, emoji packs, reactions, gallery, and caching — with graceful no-crash fallback on Android API < 31. diff --git a/amethyst/plans/2026-05-26-avif-support.md b/amethyst/plans/archive/2026-05-26-avif-support.md similarity index 99% rename from amethyst/plans/2026-05-26-avif-support.md rename to amethyst/plans/archive/2026-05-26-avif-support.md index 54317b3601..b1c3689ac1 100644 --- a/amethyst/plans/2026-05-26-avif-support.md +++ b/amethyst/plans/archive/2026-05-26-avif-support.md @@ -1,5 +1,8 @@ # AVIF support — comprehensive design +> **Status:** shipped — AVIF upload/display support landed — `MediaMimeTypes.kt` plus the compressor/stripper/preview changes are in tree. +> _Audited 2026-06-30._ + **Issue:** [vitorpamplona/amethyst#837](https://github.com/vitorpamplona/amethyst/issues/837) **Date:** 2026-05-26 **Branch:** `feat/avif-support` (based on `d1610bf97`, origin/main = upstream/main) diff --git a/amethyst/plans/2026-05-27-avif-instrumented-tests-design.md b/amethyst/plans/archive/2026-05-27-avif-instrumented-tests-design.md similarity index 98% rename from amethyst/plans/2026-05-27-avif-instrumented-tests-design.md rename to amethyst/plans/archive/2026-05-27-avif-instrumented-tests-design.md index ed23d66e81..d3230ad6c9 100644 --- a/amethyst/plans/2026-05-27-avif-instrumented-tests-design.md +++ b/amethyst/plans/archive/2026-05-27-avif-instrumented-tests-design.md @@ -1,5 +1,8 @@ # Design — AVIF instrumented tests +> **Status:** shipped — The designed test files and AVIF fixtures exist under `amethyst/src/androidTest/` and `src/test/`. +> _Audited 2026-06-30._ + **Date:** 2026-05-27 **Status:** Design (pre-implementation) **Owning module:** `amethyst/` diff --git a/amethyst/plans/2026-05-27-avif-instrumented-tests-plan.md b/amethyst/plans/archive/2026-05-27-avif-instrumented-tests-plan.md similarity index 99% rename from amethyst/plans/2026-05-27-avif-instrumented-tests-plan.md rename to amethyst/plans/archive/2026-05-27-avif-instrumented-tests-plan.md index f066305208..6a03f510ac 100644 --- a/amethyst/plans/2026-05-27-avif-instrumented-tests-plan.md +++ b/amethyst/plans/archive/2026-05-27-avif-instrumented-tests-plan.md @@ -1,5 +1,8 @@ # AVIF Instrumented Tests — Implementation Plan +> **Status:** shipped — `AvifUploadPipelineInstrumentedTest.kt`, `MediaMimeTypesTest.kt`, and the three `.avif` fixtures are committed. +> _Audited 2026-06-30._ + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an Android on-device test layer that catches regressions in the AVIF upload pipeline and the Phase E bugs fixed manually (`C2`–`C8` plus follow-ups in commits `df84475d4`, `5a760c046`, `b9112550b`, `1db110cdf`). diff --git a/amethyst/plans/archive/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/archive/2026-06-01-dm-live-tail-and-history-slices.md new file mode 100644 index 0000000000..106c94cf20 --- /dev/null +++ b/amethyst/plans/archive/2026-06-01-dm-live-tail-and-history-slices.md @@ -0,0 +1,335 @@ +# DM loading: live tail + per-relay history paging + +> **Status:** shipped — Live-tail + per-relay history layers exist (`WindowLoadTracker`, `RelayLoadingCursors`, `AccountGiftWrapsHistoryEoseManager`); doc marks itself authoritative-as-of-code. +> _Audited 2026-06-30._ + +> **Status:** authoritative as of 2026-06-05. The "Current architecture" +> section below describes the code as it actually stands. The original +> time-slice design and the round-model history are kept at the bottom under +> **Design evolution (historical)** — they are superseded and no longer match +> the code; don't trust them for how it works today. + +## Problem + +The DM loaders used a single subscription whose `since` floor grew as the user +scrolled (`loadMore`: 7d → 14d → 28d → …). The filter carried **only `since`, +no `until`**, so every widen re-requested the whole window and the relay +re-streamed the entire history from the new floor. Traces showed this directly: + +``` +[giftwrap] load summary: 589 event(s) (14d) +[giftwrap] load summary: 1486 event(s) (28d) +[giftwrap] load summary: 2609 event(s) (56d) +``` + +Each step re-downloaded everything it already had plus the new slice — the +"getting all events over and over again" the owner reported. It also cascaded: +a few pixels of scroll walked the window to the 10-year backstop, because +widening pulls older *messages* but the rooms list is keyed by *conversation*, +so a handful of busy correspondents flood thousands of events without adding a +single new row, and the "scrolled near the oldest room" trigger never clears. + +--- + +## Current architecture + +### Two layers per protocol + +Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs +(kind 4) — is split into two independent responsibilities: + +1. **Live tail** — a fixed ~1-week floor with **no `until`**, open to the + future. Never widens. New messages always arrive here. Backed by the + **round model** (`WindowLoadTracker`): one REQ fanned to every relay, "done" + when all settle, drives the boot spinner. +2. **History** — everything *older* than the week floor, paged **backward by + `until`+`limit`, per relay, on demand**. Backed by the **per-relay model** + (`RelayLoadingCursors` + `PerRelayLoadTracker`), driven by on-screen markers. + +The two are disjoint in time, so re-issuing a history page never re-streams the +live tail, and consecutive history pages never re-stream each other. + +| Surface | Live-tail manager (round) | History manager (per-relay) | +|---|---|---| +| Account gift wraps (NIP-17) | `AccountGiftWrapsEoseManager` | `AccountGiftWrapsHistoryEoseManager` | +| Conversation NIP-04 | `ChatroomNip04SubAssembler` | `ChatroomNip04HistorySubAssembler` | +| Rooms-list NIP-04 | `ChatroomListNip04SubAssembler` | `ChatroomListNip04HistorySubAssembler` | + +Accessed from the UI via `accountViewModel.dataSources()` as +`.account.giftWrapsHistory`, `.chatroom.nip04History`, +`.chatroomList.nip04History`. + +### The history paging primitive: `RelayLoadingCursors` + +The time-window model can't tell "this relay is empty" from "this is a gap" — a +`since`/`until` slice that returns nothing might just be a quiet stretch above +older messages. Paging by `until`+`limit` removes that ambiguity: a relay +returns up to `limit` (**10000**) of its newest events older than the cursor, +**skipping gaps**, so an **empty page + EOSE is a gap-proof "nothing older"**. + +Per relay, two cursors are kept deliberately decoupled: + +- `requestedUntil` — the `until` the REQ carries. Moves **only** in `advance()`. + Leaving it untouched on EOSE is what makes paging demand-driven: a relay that + finished a page just **parks** at the same filter (no re-REQ) until advanced. +- `reachedUntil` — the oldest `created_at` actually delivered. Moves on EOSE. + The in-stream markers sit here; the next page starts at `reachedUntil − 1`. + +Stop signals: an empty page marks the relay **`done`**. A relay returning fewer +than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay +that returns events but none older than already reached (echoing its newest +events) is also treated as the bottom, so its marker can't re-request the same +window forever. Tested in `RelayLoadingCursorsTest.kt`. + +### The two completion models (and where each lives) + +**Round model — `WindowLoadTracker` (live tail only).** One REQ is fanned to all +relays; the window is "done" only when **every** expected relay reaches a +terminal signal (`settled ⊇ expected`), with backstops for stragglers (idle / +silence / connect-grace / absolute cap). `loading` starts **`true`**. This is a +*barrier*: nobody moves on until the cohort answers. It is the right shape for +the one-shot fixed-window backfill the live tail does. + +> Note: the silence + connect-grace backstops are gated behind `tracksReqSends`, +> and **none of the three live-tail managers pass `tracksReqSends = true`**, so +> in current use only the settle / idle / cap paths ever fire. The REQ-aware +> machinery is dormant in production — see "Things to scrutinize". + +**Per-relay model — `RelayLoadingCursors` + `PerRelayLoadTracker` (all history).** +Each relay advances to its next page the instant *it* EOSEs, independent of the +others; the subscription layer diffs per relay, so re-issuing only re-REQs the +relay whose cursor moved. `loading` starts **`false`** (a `true` start would +wedge the scroll loader's `!loading` gate on first open). Fast relays race to +the bottom in back-to-back pages; slow / auth-walled relays catch up at their +own pace and **none are abandoned** — a stalled relay keeps its subscription +open and resumes when re-advanced. This removes the round model's +slowest-relay coupling, which matters most on the conversation screen where the +fan-out includes correspondents' (often auth-walled, slow) relays. + +`exhausted` (per history manager) flips true when **every relay is `done` OR +`stalled`** — "nothing more reachable right now". A merely *parked* relay (more +to load, just not advancing) keeps it false. + +> All three history managers (`AccountGiftWrapsHistoryEoseManager`, +> `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`) +> were structurally the same per-relay loader, so that bookkeeping is now a +> single reusable engine — **`BackwardRelayPager`** (keyless, single-active). It +> does **not** hold the cursors: the per-relay `RelayLoadingCursors` live on the +> scope's own domain object (a `Chatroom` per conversation, a `ChatroomList` per +> account), so they share the cached messages' lifetime and survive an account +> switch. The orchestrator owns only the transient bits — in-flight + silence +> tracking, the stalled set, and the display flows — and +> `bind(cursors, scope, relaysFor)`s to whichever scope is active. Each manager +> supplies its REQ-filter builder, a `relaysFor` lookup, and the subscription +> wiring (forwards relay callbacks via `onEvent`/`onEose`/`onClosed`/`onCannotConnect`, +> re-issues filters after `advance`/`advanceAll`). The earlier round-model history +> (and the rooms-list "stall-gate") was fully removed — see Design evolution. + +### What drives `advance()`: on-screen markers, off viewport visibility + +History paging is demand-driven by **per-relay window-limit markers** placed in +the message stream, not by a scroll-position trigger: + +- **`RelayReachCursor`** — one per (protocol, relay): its `reachedUntil` depth, + its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the + `advance()` that pulls *that relay's* next page. Built in the feed views from + each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a + protocol drops out of the list once `exhausted`). +- **`RelayReachSentinels`** — the load *driver*, **hoisted above the + `LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit + gets one stable effect (keyed by `protocol:url`) that watches `listState` and + fires `advance()` when its gap is among the **currently visible rows** AND + either it just scrolled into view OR its `reachedUntil` moved (a page landed — + keep paging while visible). Driving off **viewport visibility** instead of row + composition is deliberate: an earlier version placed the sentinel *inside* the + hosting row, so any feed reorder (a live DM, a slow relay dribbling a page) + tore the effect down and re-fired `advance()` on a static screen — re-arming + stalled relays into a silence-watchdog storm. (commit `0394ec2a`) +- **`RelayReachMarkers` / `RelayReachMarker`** — pure UI (via the + `markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's + reached depth. Can be re-placed on every reorder without triggering paging. +- **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live + tail came back empty for a thread/list whose newest message is older than a + week) there are no rows to host markers, so this steps every relay one page at + a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until + messages appear and the markers take over, or the protocol exhausts. + +### NIP-04 per-relay filter scoping (`Nip04DmRelayRouting`) + +A conversation's NIP-04 filters previously named the whole participant set on +every relay, so a relay belonging to one correspondent was asked about all of +them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox +relays — which auth-walled relays reject outright ("all authors must be +authenticated"), stalling the load. + +`Nip04DmRelayRouting` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** +(`relay → which keys to name there`), built from the outbox model: + +- **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's + outbox carries only that correspondent. +- **from me** (`authors:[me]`) — my outbox carries the whole group; each + correspondent's inbox carries only that correspondent. + +So a relay only ever sees the keys that actually own it. The **conversation** +history manager scopes its REQ to the armed relays' key sets this way; the +**rooms-list** and **gift-wrap** history managers query only the account's *own* +relays (home outbox `from-me` + DM inbox `to-me`, via `filterNip04DMsFromMe` / +`filterNip04DMsToMe` and `filterGiftWrapsToPubkey`), which is why their fan-out +stays fast and reachable. + +### Status card terminal states (`DmHistoryLoadingCard`) + +One card per protocol at its oldest-loaded boundary. While paging it shows the +protocol tag, "N relays" being asked, and the reach-back date; it is tappable +into a per-relay popup (`DmHistoryRelayDialog`) listing every relay with +`✓` done / `…` stalled / `↓` reaching and how far back each paged. + +Because `exhausted` conflates `done` and `stalled`, the terminal state is split +on `stalledCount` so it can't overclaim (commit `813110cc`): + +- **caught up** (every relay `done`, `stalledCount == 0`) → "All caught up", + lingers ~2.2s then collapses. +- **incomplete** (≥1 stalled) → "Some relays didn't respond · N unreachable", + error-coloured `…`, **stays put** (no collapse), tappable to see which. + +### Reply placeholder (`LoadingReplyNote`) + +A reply whose target message hasn't been paged in yet isn't *missing*, it's +older than the loaded window (and for gift wraps the rumor id isn't even +queryable — only the outer 1059 wrap is). Instead of the generic `BlankNote` +("post not found"), `LoadingReplyNote` actively walks the relevant protocol's +history backward (kicking `advanceAll` each time a page settles) until the +target decrypts (the surrounding `WatchNoteEvent` crossfades the real message in +and disposes this) or the protocol exhausts. Its terminal state mirrors the +card: "Couldn't find this message" + an honest subtitle ("N relays unreachable · +tap to see which" when stalled, "Searched every relay · tap to see" when +genuinely done), tappable into the same per-relay popup. Wired via +`ChatMessageCompose.RenderReply` → `WatchNoteEvent(onBlank = …)`, with the pager +chosen by the parent event's protocol (`DmReplyProtocol.NIP17` / `NIP04`). + +### Diagnostics + +Everything logs under one tag, **`DMPagination`** (debug builds): +`DmRelayDiagnosticsLogger` folds the per-relay connection timeline (REQ sent, +connect/disconnect, CLOSED/NOTICE/OK-fail) into it; `DmRelayLog` prints the +"relays by source" breakdown (NIP-65 in/out, DM list, private storage, local) +per subscription so an unexpected relay can be traced to the list it leaks in +from; and each assembler logs its milestones (paging start, a relay reaching +the bottom / stalling with the reason, the "window settled" summary of +done-vs-still-trying, each marker fire). + +### Related fix: Tor guard-sample self-heal + +`TorService` gained a `noUsableGuards()` check that, on init, inspects Arti's +persisted `guards.json` and wipes the on-disk state if a non-empty guard set has +**zero** usable guards (all `disabled` / `unlisted`). This recovers the +long-standing "can't connect to Tor → relays permanently unreachable" wedge +(Arti disables guards past a 0.7 indeterminate-failure ratio, never re-enables +them, and can't replenish once the 60-slot sample is full). Orthogonal to +pagination, but it lived here because unreachable relays were part of the same +"DM history stuck / relays never answer" symptom this branch chased. + +--- + +## Component map (vs `origin/main`) + +**Paging primitives (quartz, `nip01Core/relay/client/paging/`, `commonMain`)** — +the pure, protocol-level paging *state*; iOS-clean, reusable by any KMP target. +- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor state + pinned floor; + held on the scope's domain object. *(+ `RelayLoadingCursorsTest` in amethyst; the + `until`+`limit` wire contract is covered by `UntilLimitPagingRelayTest` against + the quartz `jvmAndroidTest` geode relay)* +- `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay. + +**Paging orchestrators (commons, `relayClient/paging/`, `jvmAndroid`)** — the +StateFlow-backed, subscription-loading state holders. Moved out of amethyst **and** +out of quartz (per `commons/ARCHITECTURE.md`: the relay-subscription client + +`StateFlow` state holders live in commons) so desktop / CLI / any feed can reuse +them; `jvmAndroid` (uses `java.util.concurrent` + `@Synchronized`) → Android + +Desktop, not iOS. +- `BackwardRelayPager.kt` — the keyless single-active orchestrator the three + history managers `bind` to. *(+ `BackwardRelayPagerTest` state-machine in commons + `jvmTest`)* +- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. +- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ `WindowLoadTrackerIdleTest` in amethyst)* + +**Diagnostics (amethyst)** — `service/relayClient/eoseManagers/DmRelayLog.kt`, +`service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt` — the `DMPagination` logs. + +**Managers / assemblers** +- `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history). +- `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history). +- `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history). +- `FilterNip04DMs.kt` (per-relay `Nip04DmRelayRouting`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. +- `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers. + +**Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android + +Desktop (and any per-relay feed) render the same widgets; CMP `composeResources` +strings, no app-theme / `java.time` deps. +- `RelayReachMarker.kt` — `RelayReachCursor` + sentinels (the hoisted, visibility-driven + paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`. +- `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog + + `historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String` + so each platform supplies its locale date formatter. + +**Android UI (`amethyst/ui/screen/loggedIn/chats/`)** +- `feed/LoadingReplyNote.kt` — history-walking reply placeholder (uses the shared subtitle helpers/dialog). +- `feed/HistoryDateFormat.kt` — `formatHistoryReachDate`, the Android locale formatter passed into the shared card. +- `feed/ChatFeedView.kt` — `markersInGap` + `sentinels` slots. +- `feed/ChatMessageCompose.kt` — reply `onBlank` wiring. +- `privateDM/ChatroomView.kt`, `rooms/feed/ChatroomListFeedView.kt` — assemble cards/markers/sentinels, `BootstrapHistoryWhenEmpty`. +- `res/values/strings.xml` — `chats_reply_*` (the card's `chats_history_*` now live in commons). + +--- + +## Things to scrutinize (review notes) + +1. **`exhausted` conflates `done` + `stalled`** at the manager level. The cards + now distinguish them via `stalledCount`, but other consumers (the scroll + `!loading` gates, `LoadingReplyNote`'s advance loop) treat stalled as + terminal. Intentional (don't hammer dead relays), but confirm it's desired. +2. **`PerRelayLoadTracker.lastActivityMs` is global, not per-relay** — once the + chatty relays finish, a legitimately-slow relay gets the full 15s silence + window and can be marked stalled mid-delivery of a 10000-event page. +3. **"All caught up" can still be technically-true-but-misleading** when + `stalledCount == 0` yet a chat's messages live on a relay *not in the + account's NIP-17 inbox list* — an outbox-coverage gap the card can't detect. +4. **`WindowLoadTracker`'s REQ-aware backstops are dormant** in production + (no live-tail manager sets `tracksReqSends`). Either the live tail should + adopt them or the round model could be slimmer for its current role. +5. **`PAGE_LIMIT = 10000`** caps per-request volume but a single page can still + be a large payload on a dense relay. + +--- + +## Design evolution (historical — superseded, do not trust for current behavior) + +These sections describe earlier iterations, kept for context. The code has +moved past all of them. + +### v1 — time-slice history (superseded by `RelayLoadingCursors`) + +History was first loaded in bounded `since`+`until` **time slices** +(`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band +`[newFloor, previousFloor]`, with a NIP-17 ±2-day wrapper-timestamp margin on +the slice `since` for gift wraps. This bounded re-downloads but still couldn't +tell an empty relay from a gap (an empty slice might sit above older messages), +so the only stop was a 10-year `maxLookback`, and a wide late slice could pull a +20k-event firehose. Replaced by per-relay `until`+`limit` paging. + +### v2 — round-model history + rooms-list "stall-gate" (both removed) + +History paging once used the **round model** (`WindowLoadTracker`): each +`loadMore` issued one page to all active relays and waited for the slowest to +settle before the next — pacing every relay at the slowest one. The rooms list +additionally had a **stall-gate**: an auto-fill loop that widened only while it +brought in new private rooms, stopping once a widen added none (to avoid the +conversation-keyed cascade). + +Both are gone. All history paging is now per-relay independent +(`PerRelayLoadTracker`), and the rooms list pages to exhaustion off marker +visibility like the conversation (commit `98fb8720` dropped the stall-gate; +`60b8629a` / `9f0ecd54` moved gift-wrap and rooms-list history onto the per-relay +model). `WindowLoadTracker` survives **only** as the live-tail completion +barrier. An earlier revision of this doc ("Update 3") still claimed rooms-list +and gift-wrap history used the round model — that is no longer true. diff --git a/amethyst/plans/archive/2026-06-19-napplet-sandbox-host.md b/amethyst/plans/archive/2026-06-19-napplet-sandbox-host.md new file mode 100644 index 0000000000..30ac24cadb --- /dev/null +++ b/amethyst/plans/archive/2026-06-19-napplet-sandbox-host.md @@ -0,0 +1,274 @@ +# Napplet / nsite sandbox host — design + +> **Status:** shipped — Keyless sandbox host shipped: `:nappletHost` module, `NappletBrokerService`, `NappletLaunchRegistry` all present (rendering half superseded by embedded-tabs). +> _Audited 2026-06-30._ + +**Date:** 2026-06-19 +**Status:** Core (commons) implemented + tested; Android host (`:napplet` process, WebView, broker IPC, consent, DataStore) implemented and compiling — needs on-device verification +**Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`). + +> **Update (2026-06-24):** the *rendering* model below (full-screen-only +> `NappletHostActivity`, host living in `amethyst/androidMain/.../napplet/`) has +> moved on. The sandbox runtime now lives in its own **`:nappletHost`** module and +> renders three ways — full-screen, embedded warm bottom-bar tabs, and an arbitrary-URL +> browser — with a cross-process `SurfaceControlViewHost`, a soft-keyboard IME proxy, +> and per-site Tor routing. See **`amethyst/plans/2026-06-24-napplet-embedded-tabs.md`** +> for the current architecture. The **trust model** in this doc (keyless `:napplet` +> process, brokered consent-gated capabilities, verified-blob serving) is unchanged. + +## Goal + +Render NIP-5A static sites (nsites) and NIP-5D napplets *inside* Amethyst, with +the applet's HTML/CSS/JS running behind a **hard trust boundary**: it must never +be able to read the user's `nsec` or any other secret, read app storage, +`LocalCache`, or other accounts' data, or sign / encrypt / publish / zap without +explicit per-applet user consent. A napplet is untrusted third-party code served +from an untrusted Blossom CDN; we treat it accordingly. + +## Threat model + +**Adversary:** the applet bundle (HTML/CSS/JS), authored by an untrusted party, +delivered from an untrusted CDN. + +It must NOT be able to: + +1. Read the `nsec` / any `NostrSigner`-held secret, or decrypted key material. +2. Read app private storage, `LocalCache`, DataStore, other accounts, or another + applet's sandboxed storage. +3. Sign, encrypt/decrypt, publish, subscribe, or zap without explicit consent. +4. Escalate to native code, other installed apps, or the file system. +5. Reach the network directly to exfiltrate or fingerprint (network is a *brokered + capability*, default-deny). +6. Forge content past the signed manifest (already handled by `StaticSiteResolver`: + manifest is authority, CDN is untrusted, every blob is sha256-verified). + +**Trusted:** the main Amethyst process, the broker, the consent UI, quartz +verification. **Assumption:** the Android System WebView (Chromium) renderer +sandbox is sound — and we add an OS-process boundary on top so that even a full +WebView/renderer escape lands in a process that holds no secrets. + +## Why a separate OS process is the load-bearing decision + +Android processes have isolated address spaces. The decrypted `privKey`, the +`KeyPair`, and the `NostrSigner` instance live **only in the main process heap**. +The applet host runs in a separate process (`android:process=":napplet"`) that: + +- never constructs a `NostrSigner`, never touches `SecureKeyStorage`/Keystore, + never holds an `Account` or `LocalCache`; +- only holds an IPC handle to *request operations*, whose **results carry no key + material** (a signed event, a ciphertext, a pubkey — never the private key). + +So even arbitrary code execution inside the WebView renderer (already its own +sandboxed process) or inside the `:napplet` app process cannot read the main +process's memory where the secret lives. This is the guarantee the same-process +approach cannot make. + +## Process & component model + +``` +┌─────────────────────────────────────────┐ ┌───────────────────────────────────┐ +│ Main process (com.vitorpamplona.amethyst)│ │ Applet process (…:napplet) │ +│ │ │ │ +│ Account · NostrSigner · SecureKeyStorage │ │ NappletHostActivity │ +│ LocalCache · NostrClient · Blossom · NWC │ │ └─ WebView │ +│ │ │ ├─ shell page (trusted, │ +│ NappletBrokerService (bound, not exported)│◀──AIDL─▶│ │ app-asset origin) │ +│ └─ commons NappletBroker │ Binder │ │ exposes bridge → broker │ +│ └─ NappletPermissionLedger │ (UID │ └─ <iframe sandbox= │ +│ │ checked)│ "allow-scripts"> │ +│ NappletConsentActivity (consent UI) │ │ = applet, opaque origin│ +└─────────────────────────────────────────┘ └───────────────────────────────────┘ + holds secrets holds NO secrets +``` + +### Three transport hops (each a trust step-down) + +1. **applet iframe ↔ shell page** — `postMessage` with strict origin checks. The + shell injects a tiny `window.napplet.*` client shim into the applet that wraps + postMessage calls into promises (request-id correlation). This is the NIP-5D + capability surface the applet codes against. +2. **shell page ↔ `:napplet` native** — exactly one audited bridge, + `WebView.addWebMessageListener` restricted to the **shell origin only** (the + applet's opaque-origin iframe cannot reach it). The shell forwards validated + requests. +3. **`:napplet` process ↔ main-process broker** — AIDL/`Messenger`. The broker + checks `Binder.getCallingUid() == Process.myUid()` (reject anything not from + our own app), consults the permission ledger, runs the operation with the real + signer/client, returns only the result. + +### WebView hardening (`:napplet`) + +- Shell document served from app assets via `WebViewAssetLoader` at a fixed + internal origin (`https://napplet.localhost/`). The applet lives in a child + `<iframe sandbox="allow-scripts">` **without `allow-same-origin`** → unique + opaque origin: no access to the shell DOM, cookies, `localStorage`, + `IndexedDB`, or the bridge. +- Applet resources are served by `shouldInterceptRequest` from an **in-memory map + of already-verified blob bytes** (resolved + sha256-checked by + `StaticSiteResolver` *before* the WebView loads). Only manifest-declared paths + resolve; everything else → 404. No `file://`, no `content://`. +- `WebSettings`: `allowFileAccess=false`, `allowContentAccess=false`, + `allowFileAccessFromFileURLs=false`, `allowUniversalAccessFromFileURLs=false`, + `setGeolocationEnabled(false)`, `mediaPlaybackRequiresUserGesture=true`, + `safeBrowsingEnabled=true`, no insecure mixed content. `domStorageEnabled` + only for a partitioned per-applet store (or off in v1). +- **CSP**: the shell injects `connect-src 'none'` for the applet by default — the + applet has *no direct network*. Relay/Blossom/identity all go through the + broker. Direct network is itself a capability (`net`) that widens `connect-src` + to user-approved origins only. +- External navigation blocked in `shouldOverrideUrlLoading`; links open in the + system browser only after consent. + +## Capability / NAP-domain model + +`NappletManifest.requires()` already yields the bare NAP domains +(`identity`, `relay`, `storage`, …; see `quartz/.../tags/RequiresTag.kt`). Map +each to a `NappletCapability` with the concrete broker operations it unlocks: + +| NAP domain | Capability | Broker operations | +|---|---|---| +| `identity` | `IDENTITY` | `getPublicKey`, `signEvent`, `nip04/44 encrypt/decrypt` | +| `relay` | `RELAY` | `publish`, scoped `subscribe` (read) | +| `value` / `wallet` | `WALLET` | NIP-57 zap request / invoice; NWC pay (stricter, separate grant) | +| `storage` | `STORAGE` | per-applet sandboxed KV store, namespaced by applet identity — **never** app storage | +| `net` | `NET` | widen CSP `connect-src` to approved origins | +| *(unknown)* | — | **denied by default**, surfaced to the user | + +**Applet identity for the ledger** = author pubkey + `d` identifier (the +addressable coordinate), *not* the blob hash, so grants survive updates. We still +record the manifest aggregate hash (`computeAggregateHash()`) so a user who picks +"ask again on code change" is re-prompted when the bundle changes. + +## Permission ledger + +`NappletPermissionLedger` — per-applet-identity grants, each +`NappletCapability → GrantState` (`ASK` / `ALLOW_ONCE` / `ALLOW_SESSION` / +`ALLOW_ALWAYS` / `DENY`), persisted via a `NappletPermissionStore` interface +(DataStore actual on Android). The broker consults it on every request: + +- `DENY` → immediate `Denied` response. +- `ALLOW_*` → execute. +- `ASK` → suspend, launch `NappletConsentActivity` (main process), await the + user's decision, optionally persist, then execute or deny. + +Consent UI reuses the signer-prompt design +(`amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`) and +`commons/.../ui/signing`. + +### Capability-aware consent policy + +The uniform "once / session / always / deny" is refined per capability: + +- **Payments (`WALLET`)** — `requiresPerUseConsent = true`: every `payInvoice` + re-prompts with the decoded sats amount; the dialog never offers "Always allow" + and a stray always/session grant is downgraded to one-shot so nothing persists. +- **Identity (`IDENTITY`)** — gated by us **only when Amethyst holds the key** + (`NostrSignerInternal`). For remote (NIP-46) / external (NIP-55) signers the + broker defers to the signer's own per-request consent (no double-prompt), while + still honoring a standing per-napplet `DENY` and the `requires` declaration. The + sign prompt shows a kind + content preview. +- **Foreground-only execution** — the napplet WebView's JS/timers are paused when + the host isn't resumed (`onPause`/`onResume`), so a backgrounded applet cannot + fire a sign/decrypt/pay request whose prompt would surface over — and be + confused with — Amethyst's own UI. This is the precondition that makes deferring + identity to an external signer safe. + +## Module placement + +- **`quartz`** — protocol done. (Optional later: a canonical `NapDomain` constant + set once the upstream NAP list stabilizes — an open question in the resolver + plan.) +- **`commons/commonMain`** — new CLI-safe `napplet/` feature package: + - `napplet/NappletCapability.kt` — NAP-domain ↔ capability mapping. + - `napplet/protocol/` — `NappletRequest` / `NappletResponse` sealed families + JSON codec. + - `napplet/permissions/` — `NappletPermissionLedger`, `GrantState`, `NappletPermissionStore`. + - `napplet/NappletBroker.kt` — platform-agnostic broker: `(NostrSigner + + relay/blossom/zap handles + ledger) → (NappletRequest → NappletResponse)`. + The heart of the boundary; **fully unit-testable on the JVM**. + - `napplet/ui/` — shared consent composables. +- **`amethyst/androidMain`** — + - `NappletHostActivity` (`:napplet`) + WebView + `WebViewAssetLoader` + the + shell HTML/JS shim asset. + - `NappletBrokerService` (main process, bound, `exported=false`) wrapping the + commons broker; `Binder` UID check. + - `NappletConsentActivity` (main process) using the commons consent UI. + - AIDL/`Messenger` plumbing; OkHttp `BlobFetcher` wiring (the resolver plan's + named follow-up). + - AndroidManifest: `:napplet` process declaration, the bound service, the + consent activity. +- **`desktopApp`** — out of scope for v1 (the `:napplet` process model is + Android-specific; desktop needs a separate child-process/WebView strategy). + +## Inter-applet communication (v2) + +Napplets' differentiator is talking to each other. Model: applets address each +other by napplet coordinate; `napplet.send(target, msg)` is **routed through the +broker** (shell→broker→shell) so two opaque-origin iframes never share an origin +or memory, and the user (or a manifest-declared allowlist) consents to the link. +Deferred to v2; v1 nails the single-applet boundary first. + +## Testing & verification + +- **commons (now, JVM):** broker decision logic per capability + (granted/denied/ask), ledger state transitions + persistence semantics, + protocol JSON round-trips, and security cases — unknown NAP domain denied, + request for an undeclared path 404s without fetching, signer responses never + contain key bytes. +- **Android (needs emulator):** instrumented tests for the WebView host, the + opaque-origin iframe isolation, and the process boundary — flagged as on-device + verification. + +## Phasing (within the "full napplet" milestone) + +1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ done. +2. **Android host** — `:napplet` process + WebView + verified-blob serving via + `shouldInterceptRequest` + CSP. ✅ implemented (`NappletHostActivity`). +3. **Broker IPC + `identity` + `relay`** — Messenger broker + (`NappletBrokerService`), `window.napplet.*` shim, consent UI + (`NappletConsentActivity`), DataStore ledger. ✅ implemented. +4. **Capabilities beyond identity/publish:** + - **`relay` read** — `QueryEvents`: bounded live relay fetch (`fetchAll`, + EOSE/timeout) merged with `LocalCache`, newest-first. ✅ + - **`storage`** — per-applet sandboxed KV store (`DataStoreNappletStorage`). ✅ + - **`value`/`wallet`** — `PayInvoice` wired to the user's NWC wallet + (`sendZapPaymentRequestFor`); consent shows the decoded sats amount; throws + (→ `Failed`) on no-wallet/error/timeout. ✅ (needs on-device verification) + - **`net`** — CSP widening to approved origins. ⏳ +5. **Capability enforcement** — the broker refuses any request whose capability is + not in the manifest's `requires` (passed host→broker as `declared`), before any + consent prompt. ✅ +6. **Inter-applet (NAP-INC / NAP-INTENT)** — deferred; design + prerequisites in + `2026-06-20-napplet-inter-applet.md`. **`net`** capability and an install-style + up-front capability grant UI also remain. ⏳ + +### Implemented Android components (amethyst `…/napplet/`) + +| File | Process | Role | +|---|---|---| +| `NappletHostActivity` | `:napplet` | WebView host: hardened settings, opaque-origin iframe, verified-blob `shouldInterceptRequest`, CSP, `window.napplet` shim, Messenger client | +| `NappletBrokerService` | main | Bound Messenger service; runs the commons `NappletBroker` against the live account; `exported=false` + UID check | +| `NappletConsentActivity` / `NappletConsentCoordinator` | main | Capability-consent dialog + suspend bridge to the broker | +| `DataStoreNappletPermissionStore` | main | Persistent grant store (`NappletPermissionStore` actual) | +| `NappletProtocolJson` / `NappletIpc` | both | JSON codec + Messenger wire contract | +| `NappletLauncher` | caller | Packs a verified manifest into the host Intent | +| `assets/napplet/shell.html` | `:napplet` | Trusted shell page that sandboxes the applet iframe and relays messages | + +### Remaining before user-facing ship + +- **On-device verification (needs emulator/device):** opaque-origin iframe really + excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real + napplet renders and round-trips a `getPublicKey` / `signEvent` through consent. +- ✅ **UI entry point:** a "Napplets" drawer item → `NappletsScreen` that lists + cached napplet manifests (kinds 15129/35129) and launches the host. +- ✅ **Process isolation:** `Amethyst.onCreate` now skips `AppModules` entirely in + the `:napplet` process, so the account/signer are never loaded there. (Previously + `initiate()` loaded the account in every process — a real hole, now closed.) +- ✅ **Privacy:** the host routes blob fetches through the user's Tor SOCKS proxy + when active; the port is passed in by the launcher (main process) so the sandbox + process never touches the account-bound HTTP stack. +- ✅ **Relay discovery:** `NappletsFilterAssembler` (registered in + `RelaySubscriptionsCoordinator`, invoked by `NappletsScreen`) REQs kinds + 15129/35129 from the user's read relays while the screen is open, so manifests + flow into `LocalCache` for the list to render. +- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title + and a per-capability rationale; batch-grant on first run. diff --git a/amethyst/plans/archive/2026-06-20-napplet-ecosystem-audit.md b/amethyst/plans/archive/2026-06-20-napplet-ecosystem-audit.md new file mode 100644 index 0000000000..07b1ede586 --- /dev/null +++ b/amethyst/plans/archive/2026-06-20-napplet-ecosystem-audit.md @@ -0,0 +1,277 @@ +# Napplet implementation audit vs the upstream SDK / demo runtimes + +> **Status:** shipped — Audit doc; the wire-compatibility gaps it identified were subsequently closed (shell handshake, keys, upload now in the broker/codec). +> _Audited 2026-06-30._ + +**Date:** 2026-06-20 +**Sources:** `github.com/napplet/naps` (NAP specs), `github.com/napplet/web` +(`@napplet/shim` SDK), `github.com/kehto/web` + `kehto.github.io/web/playground` +(reference runtime). Audited against our branch `claude/awesome-pasteur-xwiwad`. + +## TL;DR + +Our shell is a **correct and security-hardened NIP-5A/5D renderer + capability +broker** — process isolation, verified-blob serving, default-deny CSP, +capability-aware + signer-aware consent, foreground-only, permissions UI. Those +are *shell-quality* properties the demo runtimes don't even specify, and we're +ahead there. + +**But it is not wire-compatible with the napplet ecosystem.** Real napplets are +built against `@napplet/shim`, which exposes a **namespaced** `window.napplet.*` +and a **`{type:"domain.action", id}`** postMessage envelope. We inject a **flat** +`window.napplet.*` and use a **`{id, payload:{op}}`** envelope. A napplet from the +kehto playground calling `window.napplet.relay.publish(...)` or +`window.napplet.shell.supports("relay")` hits `undefined` on our shell. So today: +**0 real ecosystem napplets run as-is.** + +## Reference: the upstream surface + +`window.napplet` namespaces (from `@napplet/web`): + +| Namespace | Methods (upstream) | +|---|---| +| `shell` | `supports(domain)` — **required of every runtime** | +| `identity` | `getPublicKey()`, `onChanged(handler)` | +| `keys` | signing / NIP-04 / NIP-44 (separate domain from `identity`) | +| `relay` | `subscribe(filters)` (live), `publish(event)`, `query(filters)`, `publishEncrypted` | +| `storage` | `get(key)`, `set(key, value)`, `remove(key)` | +| `inc` | `emit(type, payload)`, `on(type, handler)` (inter-napplet) | +| `resource` | `bytes(url)`, `bytesAsObjectURL(url)` — fetch https/blossom/nostr/data | +| `value` | shell-mediated value transfer + zaps (depends on `relay`) | +| `upload` | shell-mediated file/blob upload (Blossom; depends on `relay`) | +| `intent` | invoke a napplet by archetype | +| `theme`, `notify`, `media`, `config`, `outbox`, `ifc`, `cvm` | further domains | + +**Envelope:** `{ type: "<domain>.<action>", id, ...payload }` → +result `{ type: "<domain>.<action>.result", id, ok, ... }`. +**Sandbox:** `iframe sandbox="allow-scripts"` only (no `allow-same-origin`) — **we +match this exactly.** + +## Coverage scorecard (our broker vs upstream domains) + +| Domain | Status | Notes | +|---|---|---| +| `shell` | ✗ **missing** | `supports()` is foundational; without it well-behaved napplets bail early | +| `identity` | ◐ | `getPublicKey` ✓; `onChanged` ✗ (no push) | +| `keys` (sign/nip04/nip44) | ◐ | implemented, but lumped under our `IDENTITY` capability, not a `keys` domain | +| `relay` | ◐ | `publish` ✓, `query` ✓ (now live); `subscribe` (live) ✗, `publishEncrypted` ✗ | +| `storage` | ✓ | get/set/remove — shape matches (wire differs) | +| `value` | ◐ | we do `payInvoice` via NWC; upstream `value` is zaps/value-transfer (depends on relay) — different method shape | +| `resource` | ✗ | no `bytes(url)`; we only serve manifest subresources | +| `upload` | ✗ | no Blossom upload | +| `inc` | ✗ | deferred (see inter-applet plan) | +| `intent` | ✗ | deferred | +| `theme` `notify` `media` `config` `outbox` `ifc` `cvm` | ✗ | not modeled | + +Our extra `NET` domain has **no upstream equivalent** — upstream fetching is +`resource`. Our `fromNapDomain` recognizes `identity/relay/value/storage/net`; +everything else (`shell`, `resource`, `upload`, `inc`, `intent`, `keys`, …) maps +to *unknown → denied*. So a manifest `requires: ["relay","shell","resource"]` +would have two of three flagged unknown — and `shell` is mandatory. + +## The two interop blockers + +1. **Envelope.** Real napplets send `{type:"relay.publish", id, event}` and await + `{type:"relay.publish.result", id, ok}`. We expect `{id, payload:{op:"publish"}}` + and reply `{id, response:{type:"published"}}`. Incompatible. +2. **API surface.** Upstream is namespaced (`relay.publish`, `identity.getPublicKey`, + `keys.signEvent`, `shell.supports`, `resource.bytes`). Ours is flat + (`getPublicKey`, `signEvent`, `publish`, `queryEvents`, `payInvoice`) + a + namespaced `storage`. Only `storage` lines up. + +Both live in our **edge layer** (the injected JS shim + `NappletProtocolJson` + +`NappletIpc`) and the capability enum — *not* in the security core (broker, +ledger, process model), which is dialect-agnostic. So aligning is an edge rewrite, +not an architecture change. + +## What we have that the demos don't + +- Separate-OS-process sandbox (`:napplet`), not just an iframe. +- Verified-blob serving (manifest-authoritative, per-blob sha256) with default-deny + CSP (`connect-src 'none'`) and Tor-routed fetch. +- Capability **declaration enforcement** (manifest `requires` gate), **per-use** + payment consent, **signer-aware** identity deferral, **foreground-only** execution. +- A persisted permission ledger + a permissions-management UI. + +These are real-shell concerns the reference runtimes leave to the implementer; we +should keep them. + +## Recommended path to ecosystem compatibility (priority order) + +1. **Adopt the upstream envelope** `{type:"domain.action", id}` / `…​.result` in the + shim + `NappletProtocolJson` + `NappletIpc`. (Unblocks everything.) +2. **Re-shape the injected shim** to the namespaced `window.napplet.*` and add + **`shell.supports(domain)`** (cheap; derive from the granted capability set). +3. **Split capabilities** to match domains: `keys` (sign/nip04/nip44) distinct from + `identity` (getPublicKey/onChanged); rename `NET`→`resource`; add `SHELL`, + `UPLOAD`, `VALUE` semantics (zap-by-target, not raw invoice). +4. **Add the push channel**: `relay.subscribe` (live events), `identity.onChanged`, + later `inc.on` — the host already holds a `JavaScriptReplyProxy` we can push + unsolicited `…event` messages through. +5. **`resource.bytes`** (consented fetch of https/blossom/nostr/data) and + **`upload`** (Blossom), both brokered + consent-gated. +6. Lower priority / app-specific: `theme`, `notify`, `media`, `config`, `outbox`, + `intent`, `inc`, `ifc`, `cvm`. + +## Update (2026-06-20): ecosystem alignment landed + +Acted on #1–#5. The dialect mismatch is resolved: + +- **Envelope** is now `{type:"<domain>.<action>", id}` → `{type:"…​.result", id, ok, …}`, + matching upstream (codec + host shuttle + shim rewritten; round-trip unit-tested). +- **Namespaced `window.napplet.*`** shim: `shell.supports`, `identity.getPublicKey` + (+`onChanged` stub), `keys.{signEvent,nip04*,nip44*}`, `relay.{publish,query,subscribe}`, + `storage.{get,set,remove}`, `value.payInvoice`, `resource.{bytes,bytesAsObjectURL}`, + `upload.blob`. The applet's own SDK-targeted code now runs unchanged. +- **`shell.supports(domain)`** implemented (no consent; reflects declared+brokered domains). +- **Capabilities split** to the domain model: `SHELL`, `IDENTITY`, `KEYS`, `RELAY`, + `STORAGE`, `VALUE`, `RESOURCE`, `UPLOAD` (was `IDENTITY/RELAY/WALLET/STORAGE/NET`). +- **`resource.bytes`** implemented for `https`/`data` (broker-fetched, Tor-routed, + consent-gated); `blossom:`/`nostr:` are a follow-up. + +## Update (2026-06-21): return shapes verified against `@napplet/nap@0.15.0` + +Pulled the canonical message types (`@napplet/nap` `*/types.d.ts` + `value-types`) and corrected +the result field names — several were wrong guesses. The authoritative wire: + +| Method | Request `type` | Result field(s) | +|---|---|---| +| `identity.getPublicKey` | `identity.getPublicKey` | `pubkey: string` | +| `identity.getProfile` | `identity.getProfile` | `profile: ProfileData \| null` | +| `identity.getRelays` | `identity.getRelays` | `relays: Record<url, {read,write}>` | +| `identity.getFollows`/`getMutes`/`getBlocked` | same | `pubkeys: string[]` | +| `identity.getList` | `identity.getList` | `entries: string[]` | +| `identity.getZaps` / `getBadges` | same | `zaps[]` / `badges[]` | +| `storage.getItem/setItem/removeItem/keys` | **`storage.get`/`set`/`remove`/`keys`** | `value` / — / — / `keys: string[]` | +| `relay.publish` / `publishEncrypted` | same | template in the **`event`** field; result `{ok, event, eventId}` | +| `relay.query` | `relay.query` | `events: NostrEvent[]` | +| `resource.bytes` | `resource.bytes` | `blob: Blob`, `mime: string` | + +`ProfileData` is `{ name?, displayName?, about?, picture?, banner?, nip05?, lud16?, website? }` — +note **`displayName`** (camelCase), so the shell maps kind-0 `display_name` → `displayName` rather +than dumping raw content. Corrected in code: identity reads now emit method-specific fields; +`storage.*` wire types fixed (the *function* is `getItem`, the *envelope* is `storage.get`); +`storage.keys` returns `keys`; `relay.publish`/`publishEncrypted` read the template from `event`; +`getProfile` builds a `ProfileData` object. Locked by `NappletProtocolJsonTest`. + +**Transport: structured-clone objects + subscription push — landed.** `@napplet/core` posts +**structured-clone objects** (not JSON strings) via `target.postMessage(obj)` and validates +cloneability — that is how `resource.bytes` returns a real `Blob`, and why a stock napplet's +object messages were dropped before (our shell only forwarded strings). Fixed: + +- **`shell.html` bridges object↔string both ways.** applet→native serializes object envelopes to + the string the native bridge carries (requests carry no Blobs); native→applet parses the reply + to an object and posts a **structured-clone object** (what the SDK reads via `e.data.type`), not + a string. The injected shim accepts either form. +- **`resource.bytes` Blob.** The shell rebuilds a real `Blob` from the host's base64 `bytes`+`mime` + before delivering, so both the SDK and our shim resolve to a `Blob`. +- **`relay.subscribe` push channel.** Subscriptions are answered with `relay.event` (one per match) + then `relay.eose`, keyed by `subId` — no `.result`, matching the SDK. A new `MSG_PUSH` IPC frame + lets the broker push unsolicited envelopes the host forwards verbatim; `relay.close` is a + fire-and-forget no-op. Today this delivers the **initial snapshot then EOSE**. + +Still open: a **live subscription tail** (push as events arrive, not just the snapshot) plus +`identity.onChanged`/`inc.on`; multi-`filters` queries (we use the first filter); the Blossom +`upload` gateway; and **on-device verification** — the shell/shim changes are JS and not exercised +by the JVM unit tests. + +## Update (2026-06-21, even later): feed surfacing + nsite runtime hardening + +- **Feed surfacing (sandbox-preserving).** Napplets gained an inline feed card (nsites already had + one), both render via `NoteCompose`, are indexed in `LocalCache`, and a profile **"Apps & Sites"** + tab lists a user's manifests. The cards are inert (`Text`+`Button`, no WebView); execution begins + only on explicit tap, in the `:napplet` process. +- **SPA route fallback** — a document navigation (Accept: text/html) to a route not in the manifest + serves the verified `index.html`; missing sub-resources still 404. +- **External-link handoff** — a user-tapped off-origin http(s) link opens in the system browser + (gesture-gated so a hostile site can't auto-redirect); the sandbox WebView never navigates away. +- **`resource.bytes` `blossom:` scheme** — `blossom:<sha256>` fetches from the user's kind:10063 + Blossom servers and verifies the hash before returning. `nostr:` stays deferred (unspecified). +- **Content-type byte-sniffing** — when a manifest path has no/unknown extension, the resolver + sniffs magic bytes; text/markup is never sniffed, so HTML detection stays extension-driven. + Unit-tested in quartz. +- **kind:10063 fallback** — the launcher augments the manifest's `servers` with the author's + published Blossom list (best-effort); every blob is still sha256-verified. +- **Blob caching** — the host OkHttp client caches blobs on disk (forced-immutable, since they're + content-addressed); the resolver re-verifies every served blob, so a stale entry can't be served. + +Remaining: live subscription tail + `identity.onChanged`/`inc.on`, the Blossom `upload` gateway, +`getList`/`getZaps`/`getBadges`, the `nostr:` resource scheme, multi-`filters` queries — and +**on-device verification** of all the WebView-host behavior. + +## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected + +Pulled the authoritative SDK (`@napplet/shim` v0.16.0, npm/unpkg) and corrected the +implementation to its real contract. Commit `5ca44e27` had carried several wrong guesses; the +verified surface is: + +| Namespace | Verified methods (v0.16.0) | +|---|---| +| `shell` | `supports(domain, protocol?)` (sync), `ready()`, `onReady(cb)`, `services` | +| `identity` | `getPublicKey()`, `onChanged(h)`, + read API (`getRelays/getProfile/getFollows/getList/getZaps/getMutes/getBlocked/getBadges`) | +| `keys` | **keyboard/command actions** — `registerAction/unregisterAction/onAction` (NOT signing) | +| `relay` | `publish(template, options?)` → signed `NostrEvent`, `publishEncrypted(template, recipient, encryption?)`, `query(filters)`, `subscribe(filters, onEvent, onEose, options?)` | +| `storage` | `getItem/setItem/removeItem/keys` (512 KB quota; `instance.*` variant) | +| `resource` | `bytes(url)` → `Blob`, `bytesAsObjectURL(url)` | +| `inc` | `emit(topic, extraTags?, content?)`, `on(topic, cb)` | + +Crucial design fact, quoted: **"signing and encryption are mediated by the shell via +`relay.publish()` and `relay.publishEncrypted()`"** and *"no cryptographic dependencies — the +shim sends JSON envelope messages and the shell handles identity"*. **There is no `sign()` and no +raw nip04/44 in the napplet surface.** There is **no `value` or `upload` domain** in v0.16.0. + +Corrections landed (this commit): + +- **Signing model fixed (the big one).** Dropped the bogus `keys.signEvent` / `keys.nip04*` / + `keys.nip44*` napplet ops. `relay.publish` now takes an **unsigned template** (`kind/tags/content`) + and the broker signs it as the user and returns the signed event — exactly the upstream contract. + Added `relay.publishEncrypted` (broker encrypts to recipient with nip44/nip04, then signs + + publishes). The broker still defers the per-signature prompt to remote/external signers + (`signsAsUser` + non-internal signer) and honors standing DENY. +- **`keys` re-pointed to keyboard actions** (`registerAction/unregisterAction/onAction`), + implemented as client-side no-op stubs (not yet wired to the host keyboard) so action-using + napplets don't crash. They never cross the broker boundary. +- **`storage` renamed** to `getItem/setItem/removeItem` and **`storage.keys`** added end-to-end + (protocol + broker + DataStore + shim), matching upstream. +- **`resource.bytes` now returns a `Blob`** (shim builds it from `{bytes, mime}`); wire field + renamed `contentType`→`mime`. +- **`shell.supports(domain, protocol?)`** gained the optional protocol arg; added `shell.ready()`, + `onReady`, `services` stubs. +- **`relay.subscribe`** wired (initial matches; live tail still a follow-up). +- `value.payInvoice` and `upload.blob` are **kept as clearly-marked Amethyst-specific extensions** + (no upstream equivalent in v0.16.0) — a real `@napplet/shim` napplet never calls them, so they + can't conflict. + +Verified off-device: `commons:jvmTest` (broker + capability + ledger) and the amethyst codec +round-trip test (`NappletProtocolJsonTest`) both green. + +Still open (documented, not blocking basic napplets): +- **`upload`** — wired end-to-end (protocol/shim/capability) but the Android Blossom + gateway is unprovided (`Unsupported`): a correct upload needs a content Uri + signed + auth event + server selection, which needs on-device verification. +- **Live push** — `relay.subscribe` returns initial matches via `query`; a live tail and + `identity.onChanged`/`inc.on` need a push channel over the existing reply proxy. +- **Underspecified domains** — `inc`, `intent`, `theme`, `notify`, `media`, `config`, + `outbox`, `ifc`, `cvm` remain unknown→denied (no method spec available to build to). +- **Method-name fidelity** — ✅ resolved. All standard method names/shapes are now confirmed + against `@napplet/shim@0.16.0` (see the later update above), not guessed. +- **Identity read API** — ✅ partly landed. `identity.getProfile` (kind-0 content), `getRelays` + (NIP-65 read/write map), `getFollows` (kind-3 authors), `getMutes` and `getBlocked` (NIP-51 + decrypted user tags) now read from the active `Account` and return JSON, gated by the IDENTITY + consent (and deferred to remote/external signers). `getList`/`getZaps`/`getBadges` route through + but degrade to `Unsupported` for now; `identity.onChanged` is still a client-side no-op (needs + the live push channel). Return shapes still want on-device verification against a real napplet. +- **On-device verification** of the whole round-trip with a real playground napplet. + +Revised ecosystem-compatibility estimate: **~80%** — real request/response napplets using +identity(getPublicKey)/relay(publish/publishEncrypted/query/subscribe)/storage/resource + +`shell.supports` now run against the *verified* contract; remaining gaps are the identity read +API, keyboard-action wiring, live subscription tails, the niche domains, and device verification. + +## Verdict (original assessment, pre-update) + +- **As a secure NIP-5A/5D renderer + broker:** ~85% — the hard, security-critical + parts are done and tested; gaps are on-device verification and breadth of ops. +- **As an ecosystem-compatible napplet host (runs real napplets):** ~25% — blocked + by the envelope + namespaced-API mismatch and missing `shell`/`resource`. Until + #1–#2 land, existing napplets won't run regardless of how solid the core is. diff --git a/amethyst/plans/archive/2026-06-21-napplet-code-audit.md b/amethyst/plans/archive/2026-06-21-napplet-code-audit.md new file mode 100644 index 0000000000..0360ab2e84 --- /dev/null +++ b/amethyst/plans/archive/2026-06-21-napplet-code-audit.md @@ -0,0 +1,65 @@ +# Napplet subsystem code audit — bugs, performance, refactor, placement + +> **Status:** shipped — Audit recording completed fixes (LiveSub teardown, broker caching, shim extraction); referenced files exist. +> _Audited 2026-06-30._ + +**Date:** 2026-06-21. Scope: the napplet/nsite subsystem (`amethyst/.../napplet/`, +`commons/.../napplet/`, `quartz/.../nip5aStaticWebsites` + `nip5dNapplets`). + +## Fixed in this pass + +| # | Category | Issue | Fix | +|---|---|---|---| +| 1 | 🐛 correctness | **Live subscription unsubscribed from the wrong client after an account switch** — `closeLiveSubscription`/`onDestroy` used the *current* account's client, leaking the sub on the original. | `LiveSub` holder stores the exact `INostrClient` that opened the sub; teardown uses it. | +| 2 | 🐛 correctness | **Multi-relay subscriptions emitted N `relay.eose`** (one per relay) — the SDK expects one. | An `eoseSent` latch (`compareAndSet`) emits a single `relay.eose`. | +| 3 | ⚡ perf | **Broker rebuilt on every request** (new gateways/prompt each call). | `broker()` caches per account (reference identity), rebuilt only on switch. | +| 4 | ⚡ perf | **A fresh `OkHttpClient` per blob fetch** (no connection pooling). | `blobHttpClient()` caches the client keyed by Tor port (`@Synchronized`). | +| 5 | ♻️ refactor | **105-line `SHIM_JS` string constant** in `NappletHostActivity.kt` (no highlighting, hard to edit). | Moved to `assets/napplet/shim.js`, loaded once like `shell.html`. | +| 6 | 📝 docs | Stale shim comments (`onChanged` "follow-up", `subscribe` "snapshot…follow-up"). | Rewritten to match reality (no-op onChanged; live tail). | + +All compile; `commons:jvmTest` + the amethyst napplet suite stay green. + +## Deferred — with rationale (not silently dropped) + +- **Duplicate events across relays** — the same event id can arrive from multiple relays, so + `relay.event` is pushed more than once. Deduping needs a per-subscription seen-id set (unbounded + memory for long subs); napplets already dedupe by id. Left as-is; documented. +- **Background teardown of live subscriptions** — a backgrounded-but-alive napplet keeps its relay + subscription open (the WebView is paused, but the service keeps streaming). It is *not* a + permanent leak: the service is bind-only, so closing the napplet → `unbindService` → service + `onDestroy` → all subs torn down. A proper pause/resume (unsubscribe on background, re-`REQ` on + foreground) is a real optimization but needs an IPC pause/resume signal + device verification. +- **Request ordering** — requests are handled concurrently (`scope.launch` per message), so + `storage.set` then `storage.get` aren't guaranteed in-order. Matches the SDK's async model; + serializing would hurt throughput. Documented, not changed. +- **`runBlocking` in `shouldInterceptRequest`** — this runs on a WebView *background* worker thread + (not the UI thread), so blocking there during a blob fetch is acceptable; WebView fans out + resource loads across workers. Left as-is. +- **Over-flags from the sweep that aren't real:** `pendingRequests` / `bridgeReplyProxy` "races" — + both the `WebMessageListener` callback and the reply `Handler` run on the **main looper**, so + there is no cross-thread access. A null `bridgeReplyProxy` only drops a reply to an + already-gone WebView (the applet is gone too) — harmless. + +## Recommended moves to commons / quartz + +- **quartz (protocol-only): correct as-is.** NIP-5A/5D events, `NappletManifest`, + `StaticSiteResolver` + `StaticSitePathLookup` (`sniffContentType`), `SiteAggregateHash` are all in + `commonMain`. No app policy leaked in. +- **commons (shared logic): mostly correct.** Broker, capability, identity, request/response, + permissions ledger/store, and gateway interfaces are in `commonMain` — right home. +- **DONE: `NappletProtocolJson` → `commons/jvmAndroid`.** Moved to + `commons/.../napplet/protocol/` (next to the types it marshals) so the future **desktop** host + reuses the exact wire codec. Its tests stay in `amethyst` (JUnit4) for now and still exercise it + via the commons dependency; converting them to `kotlin.test` and moving to commons `jvmTest` is a + small follow-up. See `desktopApp/plans/2026-06-21-napplet-desktop-host.md`. +- **amethyst (Android-only): correctly platform-bound.** `NappletHostActivity` (WebView/process), + `NappletBrokerService` (Service/Messenger/account), the gateway *implementations* (account, + `BlossomUploader`, DataStore, NWC), `NappletLauncher`, consent UI, `NappletIpc`, and the screens + all belong here. + +## Lower-priority refactors (not done) + +- Extract a shared Tor-aware OkHttp builder (host `buildHttpClient` vs service `blobHttpClient` + duplicate the proxy logic) into a small util. +- `summaryFor`'s long `when` could become a per-request-type method, and `encodeResponse`'s big + `when` is repetitive — both are readability, not correctness. diff --git a/amethyst/plans/archive/2026-06-21-napplet-sdk-conformance-audit.md b/amethyst/plans/archive/2026-06-21-napplet-sdk-conformance-audit.md new file mode 100644 index 0000000000..c343cc7425 --- /dev/null +++ b/amethyst/plans/archive/2026-06-21-napplet-sdk-conformance-audit.md @@ -0,0 +1,131 @@ +# Napplet SDK conformance audit — feature by feature + +> **Status:** shipped — Conformance audit; the four breakers are marked fixed and pinned by `NappletSdkConformanceTest`. +> _Audited 2026-06-30._ + +**Date:** 2026-06-21 +**Authoritative sources (verified, not from memory):** +`@napplet/nap@0.15.0` (`dist/<domain>/types.d.ts` — the canonical wire message types), +`@napplet/shim@0.16.0` (the SDK napplets bundle), `@napplet/core@0.15.0` (base envelope + +shell handshake). Audited against branch `claude/awesome-pasteur-xwiwad`. + +Our edge layer: `NappletProtocolJson` (codec), `NappletRequest`/`NappletResponse` (commons), +`NappletHostActivity` (`SHIM_JS` + `shell.html` relay), `NappletBroker`, `NappletBrokerService`. + +## Update — the four 🔴 breakers are now implemented + +All four conformance breakers below are fixed (codec pinned by `NappletSdkConformanceTest`): + +1. **Shell handshake** — the host answers `shell.ready` with `shell.init { capabilities:{domains, + protocols}, services }` built from the declared domains (`NappletProtocolJson.encodeShellInit`), + so a stock napplet's cached environment is populated and `supports()` works. +2. **Id-less messages** — `onShellMessage` no longer drops messages without an `id`: `shell.ready` + is answered locally, and other fire-and-forget messages get a synthetic id so they reach the broker. +3. **keys** — `keys.registerAction`/`keys.unregisterAction` decode and the broker acknowledges them + (declared-gated, no consent) so `registerAction()` resolves; the shim dispatches the `keys.action` + push. (The actual global-key binding is still a follow-up — `keys.action` isn't emitted yet.) +4. **upload** — realigned to `upload.upload { request:{ data, mimeType, filename } }` → rich + `UploadResult { ok, uploadId, status, url, sha256, size, mimeType }`; `shell.html` inlines the + request `Blob` as base64 so it survives the bridge; the gateway uploads via the app's + `BlossomUploader` to the user's kind:10063 server with a signed auth event. + +Also now implemented (the ◐ follow-ups): +- **Live subscription tail** — `relay.subscribe` opens a real `client.subscribe` whose listener + streams `relay.event` (stored + live), `relay.eose`, and `relay.closed` pushes by `subId`; + `relay.close` unsubscribes (tracked in `liveSubs`, torn down in `onDestroy`). +- **Multi-`filters`** — `relay.query`/`subscribe` honor every filter in the `filters[]` array, + not just the first (`decodeFilterList`, gateway `query(List<Filter>)`). +- **`resource.cancel`** — accepted at the host edge as a no-op `Done`. + +Still open: identity `getList`/`getZaps`/`getBadges` + `onChanged` (object shapes / list-type +semantics underspecified), the `keys.action` push (needs a host command-palette UI to *trigger* +actions — registration already conforms), the `resource` `nostr:` scheme (unspecified bytes), +`inc`/`intent`/the niche domains, and **on-device verification** of the host/shell behavior. + +## Base envelope & error convention (verified) + +- `NappletMessage` carries only **`type`** (`"domain.action"`). **There is no universal `id`** — + request/response pairs add `id`; fire-and-forget and handshake/push messages have **no `id`**. +- **No universal `ok`.** Each domain picks its own: `relay.publish`/`publishEncrypted`, + `outbox.publish`, `upload`, `intent` use `ok: boolean`; identity/storage/query results omit `ok` + and signal success by the data field's presence + an optional `error?: string`. The SDK shim + rejects when `error` is present and otherwise reads the domain's data field. + - **Ours:** we set `ok` on *every* result. Harmless (the SDK reads the data field and ignores the + extra `ok`), and our own injected shim relies on `ok`. ✅ compatible, ⚠️ non-canonical. + +## Transport (verified) — two architectural gaps + +1. **Structured-clone objects, not strings.** `@napplet/core` posts cloneable **objects**. Our + `shell.html` now bridges object↔string both ways (done earlier). ✅ +2. **🔴 The host drops id-less messages.** `NappletHostActivity.onShellMessage` does + `id = optString("id").ifEmpty { return }` — so every message **without an `id`** is dropped: + `shell.ready`, `inc.emit`, `keys.unregisterAction`. This silently breaks the shell handshake and + all fire-and-forget messages. **Must fix** to forward/handle id-less messages. +3. **🔴 Blob-carrying requests can't cross.** `upload.upload`'s request payload contains a `Blob` + (`data: Blob | ArrayBuffer`). Our applet→native bridge does `JSON.stringify`, which turns a Blob + into `{}`. Real-napplet uploads lose their bytes. Needs a Blob-aware request path. + +## Shell handshake (verified) — 🔴 not implemented + +The SDK does **not** send a `shell.supports` message. Instead: +- napplet posts **`shell.ready`** (no payload), and +- the shell replies **once** with **`shell.init`** = `{ capabilities: { domains: string[], + protocols: Record<string,string[]> }, services: string[] }`. +- `shell.supports(capability, protocol?)` is then answered **synchronously and locally** from that + cached environment. + +**Ours:** we implement a `shell.supports` *request* (`ShellSupports` → `Supported`) and our injected +shim calls it async. A real napplet never sends `shell.supports`; it sends `shell.ready` — which our +host **drops** (no id) — so its cached environment stays empty and `supports()` returns `false` for +everything, likely making well-behaved napplets bail early. **Highest-impact gap.** +Fix: host answers `shell.ready` with a `shell.init` carrying the declared domains. + +## Per-domain conformance matrix + +Legend: ✅ conformant · ◐ partial · 🔴 mismatch/missing · ➖ not modeled. + +| Domain | SDK surface (wire) | Ours | Verdict | +|---|---|---|---| +| **shell** | `shell.ready`→`shell.init{capabilities,services}`; `supports()` local | `shell.supports` request→`{supported}` | 🔴 wrong model (no handshake) | +| **identity** | `getPublicKey`→`{pubkey}`; `getProfile`→`{profile}`; `getRelays`→`{relays}`; `getFollows`/`getMutes`/`getBlocked`→`{pubkeys}`; `getList`→`{entries}`; `getZaps`→`{zaps}`; `getBadges`→`{badges}`; `onChanged` push | getPublicKey ✅; profile/relays/follows/mutes/blocked ✅ (exact fields); getList/getZaps/getBadges → Unsupported; onChanged no-op | ◐ reads ✅, push/3 methods missing | +| **relay** | `publish{event}`→`{ok,event,eventId}`; `publishEncrypted{event,recipient,encryption}`; `query{filters[]}`→`{events}`; `subscribe{subId,filters,relay?}`; `close{subId}`; pushes `relay.event{subId,event,resources?}`, `relay.eose{subId}`, `relay.closed{subId,reason?}` | publish/publishEncrypted ✅ (read `event`); query ◐ (first filter only); subscribe ✅ + event/eose push ✅ (snapshot); close ✅ (no-op); `relay.closed` not emitted | ◐ strong; multi-filter + live tail + `relay.closed` open | +| **storage** | `get`→`{value}`; `set`; `remove`; `keys`→`{keys}` (512 KB quota) | ✅ exact (`get/set/remove/keys`, `value`/`keys` fields) | ✅ (quota not enforced) | +| **resource** | `bytes{url}`→`{blob,mime}`; `cancel`; https/blossom/nostr/data | bytes ✅ (shell builds Blob); https/data/blossom ✅; nostr ➖; cancel ➖ | ◐ nostr + cancel missing | +| **keys** | `registerAction{action}`→`{actionId,binding?}`; `unregisterAction{actionId}`; pushes `keys.action{actionId}`, `keys.bindings{bindings[]}` | client-side no-op stubs only; **host rejects** `keys.*` (decodes to null) | 🔴 real napplets' `registerAction` rejects | +| **upload** | `upload.upload{request:{data:Blob,mimeType?,...}}`→`{ok,uploadId,status,url?,sha256?,...}`; `upload.status` | type `upload` + `{bytes(base64),contentType}`→`{url}`; gateway null→Unsupported | 🔴 wrong type + shape + Blob transport | +| **inc** | `inc.emit`; `inc.subscribe`/`.result`; `inc.unsubscribe`; `inc.event` push; channel mode (`inc.channel.*`) | ➖ (deferred; maps to null→denied) | ➖ not modeled | +| **intent** | invoke a napplet by archetype | ➖ | ➖ | +| **theme/notify/media/config/outbox/ifc/cvm** | further domains | ➖ | ➖ | + +## Inconsistencies, ranked + +1. **🔴 Shell handshake missing** (`shell.ready`→`shell.init`). Real napplets get an empty + capability environment → `supports()` false → likely bail. *Fix: host emits `shell.init`.* +2. **🔴 Id-less messages dropped** by the host. Breaks the handshake + every fire-and-forget + (`inc.emit`, `keys.unregisterAction`). *Fix: forward/handle id-less messages.* +3. **🔴 `keys.*` rejects** for real napplets (we only stub client-side). *Fix: decode + `keys.registerAction`/`unregisterAction`, answer a stub `{actionId}`; later wire `keys.action`.* +4. **🔴 `upload` non-conformant** (`upload` vs `upload.upload`, flat base64 vs `request:{data:Blob}}`, + `{url}` vs rich `UploadResult`) AND the Blob can't cross our string bridge. *Fix: realign the + wire + a Blob-aware request path when the gateway lands.* +5. **◐ `relay.query`/`subscribe` use only the first of `filters[]`.** Multi-filter napplets get + partial results. *Fix: honor all filters.* +6. **◐ Identity `getList`/`getZaps`/`getBadges` + `onChanged`** unimplemented. +7. **◐ `resource` `nostr:` scheme + `resource.cancel`** unimplemented. +8. **◐ `relay.closed` push** not emitted; **live subscription tail** absent (snapshot only). +9. **⚠️ Non-canonical `ok` on every result** (harmless, but not how the SDK signals success for + identity/storage/query). + +## What the conformance tests assert + +`NappletSdkConformanceTest` (amethyst, JVM) pins the codec to the **exact SDK wire** for the +methods we support, so a regression that drifts from `@napplet/nap` fails CI: + +- **Requests:** the SDK's exact envelope (`relay.publish{event}`, `storage.get`, `identity.*`, + `resource.bytes`, multi-`filters`) decodes to the right `NappletRequest`. +- **Results:** `encodeResponse` emits the SDK's exact field names (`pubkey`, `profile`, `relays`, + `pubkeys`, `entries`, `value`, `keys`, `events`, `event`/`eventId`, `bytes`/`mime`). +- **Pushes:** `relay.event{subId,event}` / `relay.eose{subId}` match the SDK push shapes. +- **Gap guards:** tests that *document current behavior* for the known gaps (`shell.ready`, + `keys.registerAction`, `upload.upload`, `inc.emit` currently decode to `null`), each annotated + with the audit item so the day we fix them the guard flips intentionally. diff --git a/amethyst/plans/archive/2026-06-22-napplet-nsite-security.md b/amethyst/plans/archive/2026-06-22-napplet-nsite-security.md new file mode 100644 index 0000000000..d9a14ff27d --- /dev/null +++ b/amethyst/plans/archive/2026-06-22-napplet-nsite-security.md @@ -0,0 +1,114 @@ +# Napplet / nsite security review (2026-06-22) + +> **Status:** shipped — Security review recording completed hardening; `NappletLaunchRegistry` token model and per-applet origins are in tree. +> _Audited 2026-06-30._ + +A review of the attack surface for NIP-5A static sites (nsites) and NIP-5D napplets, +the protections in place, and the residual risks — with what was fixed in this pass +and what remains as future work. + +## Trust model (what holds) + +- **Keys never enter the sandbox.** The `:napplet` process holds no account/signer. + Signing happens only in the main process (`signer` fixes `pubkey`, host clock + prevents backdating). *"Even a full WebView/renderer escape into this process yields + no secret."* +- **Content integrity.** Every blob is sha256-verified against the **signed** manifest + before serving (`StaticSiteResolver`); cache is content-addressed + re-verified, so a + poisoned/stale cache can't be served. nsites launch with **zero** capabilities. +- **Network containment.** App CSP `connect-src 'none'`; egress only via the brokered, + consent-gated `resource.bytes`, Tor-routed. Bridge is origin-restricted + main-frame. +- **IPC binding** is `exported=false` + same-UID (`onBind` UID check). Payments always + prompt per-use with the amount. + +## Fixed in this pass + +1. **Cross-napplet identity/storage spoofing (was: per-message identity).** The broker + used to trust `author`/`identifier`/`declared` sent on **every** IPC message from the + `:napplet` process. A WebView→native escape could forge another napplet's coordinate + and read/act as it. Now the **main process** mints a random launch token + (`NappletLaunchRegistry`), hands only that token to the sandbox, and the broker + resolves it back to the trusted identity + declared set. A compromised sandbox can act + as nothing but the napplet it was launched as (it holds only its own token). + +2. **Private mute/block leak via `identity.getMutes`/`getBlocked`.** These read + `muteList.flow` / `blockPeopleList.flow`, which contain **decrypted private** entries. + Now they read the events' **public** tags only (`MuteListEvent.publicMutes()`, + `PeopleListEvent.publicUsersIdSet()`). + +3. **Silent "allow-always" actions + UI-redress.** Added persistent **trusted chrome** + (a sandbox bar the applet can't draw over: shield + name + tap-to-see "what it can + access") and a **live toast** when a granted RELAY/UPLOAD/VALUE op runs — so an + allow-always grant can't act completely silently. Also fixes the host drawing under + the status/navigation bars (edge-to-edge insets). + +4. **Real per-applet storage origin (was: opaque sandbox → broken apps).** The applet + ran in an `allow-scripts`-only iframe served under `napplet.local/app/`, i.e. an + **opaque ("null") origin**. That has no `localStorage`/`IndexedDB`/service worker + (reads throw `SecurityError`), and module scripts / asset fetches are CORS-blocked, so + essentially every bundled SPA rendered **blank** or **crash-looped** ("cache version + 0 → reset → reload" forever, because IndexedDB never persisted the version). Each + applet now loads on its **own** internal origin `https://<id>.napplet.local` — + `id = sha256(author + ":" + identifier)`, truncated + letter-prefixed to a DNS label — + with `allow-scripts allow-same-origin`, served at the **origin root** (bundlers emit + absolute `/assets/...` URLs). A real origin restores DOM storage / IndexedDB / SW and + makes the applet's own assets same-origin (no CORS shim needed). **Isolation is + preserved precisely because the applet origin is distinct from the shell's:** the + bridge stays origin-restricted to the shell (`napplet.local`), so the cross-origin + applet still cannot reach it or read the shell DOM — it talks only via `postMessage`, + which the shell relays. Per-applet subdomains keep applets' storage isolated from one + another; CSP `connect-src 'none'` is unchanged; the app CSP was **tightened** to + `'self'` (it no longer grants the shell origin). + +## Residual risks / future work + +- **`allow-same-origin` is safe only while the applet origin ≠ the shell/bridge origin.** + A frame carrying *both* `allow-scripts` and `allow-same-origin` can strip its own + sandbox **iff it is same-origin with its embedder**; here it never is (applet on a + per-applet subdomain, shell on `napplet.local`), so it cannot. **Load-bearing + invariant — do not break:** never serve the applet on the shell origin, and never add + an applet origin (`*.napplet.local`) to the bridge's `addWebMessageListener` allowlist + (kept to `setOf(ORIGIN)`). Collapsing the two origins would hand the applet the bridge. +- **Persistent client storage is now a persistence/exfil surface.** The applet keeps + `localStorage`/`IndexedDB` across launches (origin-scoped, per applet, key-free and + isolated from other applets). A consented applet can build durable local state and, + combined with allow-always RESOURCE, a durable profile. Tie to the session-scoped-grant + proposal above; consider a "clear this applet's data" affordance. +- **Per-applet origin id.** `sha256(author:identifier)` is stable per applet and + collision-resistant; root/replaceable applets (kinds 15128/15129, no `d` tag) collapse + to one origin per author — fine, since there's one root per author. Changing the id + scheme later resets an applet's storage (new origin); acceptable. +- **Service workers are reachable but unwired.** With a real origin the applet *can* now + register a service worker; the host does not yet route SW fetches + (`ServiceWorkerControllerCompat`), so registration currently fails gracefully (a + warning, not a crash). If SW support is wired later, SW-originated fetches MUST go + through the same verified content server, never the network. + +- **Coarse, persistent grants.** Non-payment capabilities persist as ALLOW_ALWAYS. A + consented napplet can still, thereafter, publish as you (RELAY), read your social graph + (IDENTITY), and make arbitrary network calls (RESOURCE) without re-prompting. The new + chrome + toasts make this *visible*, but the model is still allow-forever. **Proposed:** + a session-scoped grant ("Allow while open", cleared on close) in `GrantState` + + consent dialog, defaulted for RESOURCE/RELAY; and per-origin consent for cross-origin + `resource.bytes` https fetches. +- **`resource.bytes` as exfil channel.** Once RESOURCE is allow-always, the applet can + encode data into arbitrary https URLs through the Tor proxy. Tor hides the IP, not the + payload. Tie to the per-origin/session proposal above. +- **`'unsafe-inline'` in app `script-src`.** Required to inject the shim; the applet is + the author's own (content sha256-pinned), so it's not an escalation. `connect-src + 'none'` remains the real boundary. Residual, accepted. +- **Trust pivots on the author key.** A compromised author key lets an attacker push a + new *signed* manifest and own the app — expected Nostr trust model. Aggregate `x` hash + is enforced when present (only *recommended* by the spec); per-path hashes always + protect. +- **Sandbox isolation is now structural.** The `:napplet` runtime + (`NappletHostActivity`, content server, IPC, key actions) lives in its own + `:nappletHost` module that depends only on `:commons` + `:quartz` — so it is + *compile-time incapable* of importing `Amethyst`/`LocalCache`/`Account`. The + broker-side (signer, gateways, `NappletLaunchRegistry`) stays in `:amethyst`; + the activity binds the broker service by class-name string and the two halves + communicate only over Messenger IPC. +- **Launch-token lifecycle.** Tokens are capped (LRU, 128) rather than explicitly + unregistered on sandbox close (the sandbox is a separate process and can't reach the + main-process registry). A long-backgrounded napplet whose token was evicted would need + relaunch. Acceptable; revisit if it bites. diff --git a/amethyst/plans/archive/2026-06-23-napplet-nap-theme-notify-inc.md b/amethyst/plans/archive/2026-06-23-napplet-nap-theme-notify-inc.md new file mode 100644 index 0000000000..218ce94f31 --- /dev/null +++ b/amethyst/plans/archive/2026-06-23-napplet-nap-theme-notify-inc.md @@ -0,0 +1,55 @@ +# Napplet NAP domains: theme, notify, inc + +> **Status:** shipped — `NappletCapability` has `THEME`/`NOTIFY`/`INC` and `NappletIncBus` is wired into `NappletBrokerService`. +> _Audited 2026-06-30._ + +Date: 2026-06-23 +Status: in progress + +## Problem + +Real-world demo napplets (e.g. kehto/web's `apps/playground/napplets/*`) hard-gate +their own boot: each reads `window.napplet.shell.supports(<domain>)` for every +domain in its manifest `requires` and aborts ("unavailable") if any is missing. +**Every** kehto demo `requires: theme`; most also need `inc`; toaster needs +`notify`. Amethyst's `fromNapDomain` returns `null` for `theme/notify/inc/cvm`, +so `shell.supports()` is false for them and all demos fail at boot. + +These are real NAP service domains (kehto ships reference handlers). We add the +three the demos need (theme, notify, inc); `cvm` is deferred (its own design). + +## Wire contracts (verified against kehto reference services + demos) + +- **theme** — `theme.get` → `theme.get.result { theme: { colors: { background, text, primary } } }`. + Optional host push `theme.changed { theme }` (we skip the push for v1; the + app theme rarely changes while a napplet is foreground). Read-only, **no consent**. +- **notify** — `notify.create { title, body }` → `notify.created { id }` (past-tense, + **not** the generic `.result`), `notify.list` → `notify.listed { notifications }`, + `notify.dismiss { notificationId }` fire-and-forget. Consent-gated (ask once). + Host shows a system notification + tracks a per-coordinate store for list/dismiss. +- **inc** — a topic pub/sub bus. `inc.emit { topic, args, payload }` (fire-and-forget) + delivers `inc.event { topic, payload }` to **other** subscribed napplet sessions + (no echo to the sender). `inc.subscribe`/`inc.unsubscribe` register interest. + Gated on the INC declaration at the router edge (like `identity.watch`), no + per-call consent. NOTE: Amethyst runs napplets **foreground-only, one at a time**, + so cross-napplet delivery is usually a no-op in practice — but the bus is correct + if/when multiple sessions overlap, and it lets the demos boot + emit without error. + +## Capability mapping & consent + +`NappletCapability` gains `THEME`, `NOTIFY`, `INC`; `fromNapDomain` maps the bare +domains. `requiresConsent` is false for `SHELL` and `THEME` (negotiation/cosmetic), +true otherwise. INC is authorized at the router (declared-only) and never reaches +the broker consent path. + +## Touch points + +- commons: `NappletCapability`, `NappletRequest`, `NappletResponse`, + `NappletBrokerCollaborators` (new gateways), `NappletBroker`, + `protocol/NappletProtocolJson` (decode + custom reply types + inc/theme pushes), + `NappletRequestRouter` (inc edge ops). +- amethyst: `gateways/AccountNappletGateways` (+ theme/notify gateways), an + app-wide `NappletIncBus`, and `NappletBrokerService` wiring (notify store + inc + push transport, like `NappletLiveSubscriptions`/`NappletIdentityWatch`). + +Staged commits: (1) theme, (2) notify, (3) inc. diff --git a/amethyst/plans/archive/2026-06-24-napplet-embedded-tabs.md b/amethyst/plans/archive/2026-06-24-napplet-embedded-tabs.md new file mode 100644 index 0000000000..9cbda24dfc --- /dev/null +++ b/amethyst/plans/archive/2026-06-24-napplet-embedded-tabs.md @@ -0,0 +1,183 @@ +# Embedded napplet / nsite / browser tabs — final architecture + +> **Status:** shipped — `EmbeddedTabLayer` and the `:nappletHost` module exist; embedded warm-tab + browser render paths are implemented (PR #3348). +> _Audited 2026-06-30._ + +**Date:** 2026-06-24 +**Status:** Implemented on `claude/webview-menu-custom-url-ikrgbz` (PR #3348); needs on-device verification. +**Supersedes the rendering half of** `amethyst/plans/2026-06-19-napplet-sandbox-host.md` +(full-screen-only `NappletHostActivity` in `amethyst/androidMain`). The trust +model is unchanged and still governed by +`amethyst/plans/2026-06-22-napplet-nsite-security.md`. + +## What changed since the sandbox-host plan + +The 2026-06-19 design rendered a napplet/nsite **only** full-screen, in its own +`:napplet` Activity/task. This branch adds: + +1. A second, **embedded** render path: warm bottom-bar "favorite" tabs that live + inside the main Amethyst window, swapped in place with no relaunch — built on + a cross-process `SurfaceControlViewHost` surface. +2. A **browser** host: the same keyless `:napplet` sandbox now also renders an + arbitrary user-typed URL (not just a verified-blob nsite/napplet), as both a + full-screen activity and an embedded tab. +3. A working **soft keyboard** inside the embedded surface (the cross-process + surface window cannot itself be an IME target). +4. The sandbox runtime extracted into its **own `:nappletHost` module** (depends + only on `:commons` + `:quartz`; cannot import `Amethyst`/`LocalCache`/`Account`). +5. **Per-site Tor / open-web** routing memory, and a startup **preloader** that + warms every bottom-bar favorite so the first tap is instant. + +The keyless-sandbox guarantee is preserved throughout: keys live only in the main +process, every NIP-07 / `window.napplet` call is brokered + consent-gated, and the +embedded surface is z-ordered **below** the client window. + +## Two render paths, one sandbox + +| | Full-screen | Embedded tab | +|---|---|---| +| Container | `NappletHostActivity` / `NappletBrowserActivity` (`:napplet` task) | `SandboxedSdkView` in `EmbeddedTabLayer` (main window) | +| Surface | the Activity's own window | `SurfaceControlViewHost` shipped to the host `SandboxedSdkView` | +| Chrome (pull-down) | `NappletControlSheet` (native Android `View`s) | `TopControlSheet` (Compose) | +| Soft keyboard | the Activity's own window (native) | `RemoteImeView` proxy in the **main** window | +| Lifecycle | one session per task | many warm sessions, parked off-screen | + +Both paths run the **same** `NappletHostService` / `NappletBrowserService` and the +same `shell.html` + `shim.js`; only the host/embedding differs. + +## Persistent warm-surface layer (embedded) + +`EmbeddedTabLayer` is mounted once in the app shell, below the drawer/dialogs. It +renders **every** warm session's `SandboxedSdkView` and keeps it attached: + +- `EmbeddedTabHost` (object) owns the set of warm `sessions`, the active id, and + the reserved `contentBounds`. `setActive(id)` returns a token; `clearActiveIfOwner(token)` + only clears if the caller still owns it (so a fast tab A→B→A swap can't have B's + teardown clear A's just-set active id). `retainOnly(ids)` warm-keeps the + bottom-bar favorites (+ the momentarily-active tab) and evicts the rest. +- The active session is offset over the current tab's bounds; **inactive warm tabs + keep the same size and are merely shifted ~10 000 dp off-screen** (never resized + to 1 dp). Parking by translation rather than resize is what avoids the ~1 s black + flash on every tab switch (a resize forces a surface re-render). +- The surface is z-below, so Compose draws over it — that's how `TopControlSheet` + sits on top. Each surface is wrapped in `EmbeddedSurfaceTouchHolder` so a scroll + gesture isn't stolen by a host-side ancestor (the cross-process WebView can't + `requestDisallowInterceptTouchEvent` for itself). + +`EmbeddedTabFactory` builds/acquires the per-app controller (`EmbeddedBrowserController` +/ `EmbeddedNappletController`, both `EmbeddedSurfaceController` + `EmbeddedImeBridge`) +and gates preloading on Tor (won't warm a Tor-routed site over clearnet while Tor is +still connecting). + +## Per-session services (the multi-tab refactor) + +`NappletHostService` / `NappletBrowserService` are **single shared instances** (same +bind `Intent`); per-tab state lives in a `NappletTab` / `BrowserTab` map keyed by the +client-stamped `KEY_SESSION_ID` (`NappletEmbedContract` / `NappletBrowserContract`). +Each tab carries its **own** reply `Messenger`, so broker responses + pushes route to +the right tab (the broker echoes `replyTo`). Critical correctness rules baked in: + +- `contentServer` is `@Volatile` (read on the WebView worker thread in + `shouldInterceptRequest`, written on main). +- broker-reply delivery drops a reply whose tab was replaced (`tabs[id] !== tab`) + and wraps `postMessage` in `runCatching` (a torn-down WebView can't crash the relay). +- session close / `onDestroy` tears down **that tab's** content server + WebView only + (an earlier bug destroyed sibling tabs' WebViews → "open A, switch to B, back to A = + black"). + +## Embedded soft keyboard (IME proxy) + +The embedded surface is a `SurfaceControlViewHost` window: in z-below mode it forwards +touch but **cannot be an IME target**, so a focused field would get no keyboard. The fix +mirrors Flutter's `TextInputPlugin`: + +- `RemoteImeView` — an invisible, focusable `EditText` in the **main** app window takes + the keyboard. A real local `Editable` is the source of truth (the platform handles + composing regions, suggestions, autofill); edits are **coalesced across batch edits** + and the whole **editing state** (text + selection + composing) is shipped — not + individual ops. It flushes **synchronously** at the outermost `endBatchEdit` so a + compose-then-commit in one frame preserves the composing region. +- `shim.js` IME agent (gated on `window.__nappletImeProxy`) applies that state to the + focused field and synthesizes the matching DOM `input`/composition events so web + frameworks react as if typed natively. It is surrogate-pair-safe (emoji / CJK-supplement + never split into a lone surrogate) and supports `contenteditable` via Range-mapped char + offsets (in-place replacement, not a `textContent` overwrite), with selection-echo dedup. +- `EmbeddedTabLayer` shrinks the active surface to clear the keyboard using the **snapped** + `WindowInsets.imeAnimationTarget` (not the per-frame animated `ime`), so the expensive + cross-process surface resize happens once, not every animation frame. + +State + ops cross over `MSG_IME_EVENT` / `MSG_IME_OP` (`EmbeddedImeBridge`). Full-screen +hosts set neither IME flag (they have a native keyboard). + +## Per-site network routing + +`WebUrlNetworkRegistry` (keyed by site host) and `NappletNetworkRegistry` (keyed by +`author:identifier`) remember whether a site routes through **Tor** (default) or the +**open web** — some servers reject Tor exits, so a user can opt one out and it must +survive relaunch. Both hydrate from DataStore asynchronously and now expose +`awaitReady()`; the preloader awaits hydration **before** its first routing decision so a +cold start can't route an open-web-pinned site through Tor. Live in the **main** process +only; the keyless sandbox never reads them (the launcher stamps the choice into the +launch intent). + +## The two control-sheet twins + +`TopControlSheet` (Compose, `:amethyst`, drawn in the main process over the z-below +surface) and `NappletControlSheet` (hand-built Android `View`s, `:nappletHost`, in the +keyless sandbox process) are deliberate twins: the sandbox module is Compose-free and +can't depend on `:amethyst`, so the composable can't be shared. They are kept visually +identical by hand — same 10 dp row rhythm, same muted-icon + framework `Switch` Tor row. +Both are a top-center pull-down (collapsed = a small grabber out of the corner where a +site puts its own avatar/menu): route over Tor, reload, "what it can access" (sandboxed +apps), open full screen. + +## Bottom bar + +Built-ins and favorites are one ordered list (`BottomBarEntry`, polymorphic with +`@SerialName("builtIn")` / `@SerialName("favorite")`). `UISharedPreferences.decodeBottomBarItems` +migrates the old persisted discriminator (the kotlinx default fully-qualified class name) +to the short names, falling back to `DefaultBottomBarEntries` — fixing the one-time bottom +bar reset the `@SerialName` change would otherwise have caused. + +The **Browser** launcher (`BrowserScreen`) is an omnibox + the shared `FavoriteAppsGrid`; +each opened URL lands in its own full-screen `NappletBrowserActivity`. When the Browser is +reached from the drawer (pushed) rather than as a bottom-bar tab, its omnibox shows a back +arrow — the standard `nav.canPop()` rule (mirrors `NappletsTopBar`). + +## File map (final) + +**`:nappletHost`** (`com.vitorpamplona.amethyst.napplethost`, `:napplet` process): +`NappletHostService` / `NappletBrowserService` (per-session WebView hosts), +`NappletHostActivity` / `NappletBrowserActivity` (full-screen), +`NappletHostUiAdapter` / `NappletBrowserUiAdapter` (`SandboxedUiAdapter` for the embedded +surface), `NappletControlSheet` (native pull-down), `NappletContentServer` + +`NappletBlobHttp` + `NappletBlobCache` + `NappletBlobPrefetcher` (verified-blob serving, +Tor-routed, content-addressed), `NappletEmbedContract` / `NappletBrowserContract` / +`NappletHostContract` (Messenger wire keys), `NappletIpc`, `NappletKeyActions`, +`NappletWebViewInsets`. + +**`:amethyst` embed** (`ui/screen/loggedIn/embed`): `EmbeddedTabLayer`, `EmbeddedTabHost`, +`EmbeddedTabFactory`, `EmbeddedTabChrome`, `EmbeddedSurfaceController`, +`EmbeddedSurfaceTouchHolder`, `EmbeddedImeBridge`, `RemoteImeView`, `TopControlSheet`, +`EmbeddedTabPreloader`, `TorToggleButton`. + +**`:amethyst` main-process napplet** (`napplet/`): `NappletBrokerService`, +`NappletLauncher`, `NappletLaunchRegistry`, `WebUrlNetworkRegistry`, +`NappletNetworkRegistry`, `SandboxForegroundHold`, consent (`NappletConsent*`), gateways, +DataStore stores. + +**`:amethyst` launchers/screens**: `BrowserScreen`, `FavoriteWebAppScreen`, +`FavoriteNappletScreen`, `FavoriteAppsScreen` + `FavoriteAppsGrid`. + +**`:commons`**: `shell.html`, `shim.js` (`composeResources/files/napplet/`), +`FavoriteApp`/`FavoriteAppIcon`, `BottomBarEntry`. + +## Residual / on-device verification + +- All embedded-surface behavior (IME, scroll-gesture claiming, warm-keep across swaps, the + Tor shrink) needs emulator/device verification — `SurfaceControlViewHost` + the IME proxy + can't be unit-tested. +- The IME proxy runs a host-window `EditText`; it ships only editing **state** to the page, + never to any key material — the keyless-sandbox boundary is unaffected. +- Launch-token lifecycle, coarse persistent grants, and the `resource.bytes` exfil channel + remain as tracked in the 2026-06-22 security review. diff --git a/amethyst/plans/archive/2026-06-25-embed-text-selection-native-parity.md b/amethyst/plans/archive/2026-06-25-embed-text-selection-native-parity.md new file mode 100644 index 0000000000..7b2b6fd453 --- /dev/null +++ b/amethyst/plans/archive/2026-06-25-embed-text-selection-native-parity.md @@ -0,0 +1,259 @@ +# Embedded text selection — native-Android parity + +> **Status:** shipped — Doc states core feature-complete; host-drawn selection (handles, magnifier, IME proxy) landed in `EmbeddedTabLayer`/`RemoteImeView`. +> _Audited 2026-06-30._ + +**Status:** core feature-complete. The working set landed in `fix(embed): IME typing + +host-drawn text selection for embedded surfaces` (commit `e0a2a9ab81`); subsequent +sessions added the magnifier, the `SelectionUiState` refactor, hybrid word+char +handle-extend, and a run of polish/bug fixes (below). **The one open platform bug — +full-screen round-trip kills selection paint — is now FIXED** (root cause was +process-global `pauseTimers()` + an attached `WebView.destroy()`; see that section). + +**2026-06-26 fixes (branch `fix/embed-ime-selection`):** +- **No-blink word-select** — the overlay handles + toolbar blinked 2–3× on long-press + word-select; cause was the shim's selection-*reveal* scrolls (a `<textarea>` auto- + scrolling to show a forming/re-asserted range) tripping the hide-on-scroll path. The + shim now timestamps selection activity (`lastSelActivityAt`) and treats a scroll within + 350 ms as a reveal-scroll (reposition, don't hide, don't re-arm the timer). Plus a + `RemoteImeView` range-lost debounce as a safety net. (#2, #3) +- **Page selection clears on field focus** — focusing a field left the page-text handles + + Copy bar up (the shim's page `selectionchange` is muted once a field is focused), and + being z-above they STOLE the field handle's drag. Fixed: `focusin` emits `pagesel:false` + (+ resets the scroll state); host `ImeEvent.Focus` also drops the page overlay. (#2) +- **Caret handle drag** moved the loupe but not the caret — the unified `awaitEachGesture` + did `change.consume()` BEFORE `change.positionChange()`, and `positionChange()` returns + `Offset.Zero` once consumed, so `fp` never accumulated. Fixed with + `positionChangeIgnoreConsumed()` (also immune to the sandbox surface consuming the move). (#10) +- **Hybrid word+char handle-extend** completed (#5, below). +- **Full-screen round-trip corruption FIXED** (was the open bug; see section). +- **Embed WebViews follow the APP theme** — separate from selection, but same surfaces: + see `embed-webview-prefers-color-scheme-limitation` memo / `EmbedWebViewTheme.kt`. + +## Why we draw selection ourselves + +Editable fields inside an embedded napplet/nsite/browser tab live in the keyless +`:napplet` process and render through `SurfaceControlViewHost` / +`SandboxedSdkView` (privacy-sandbox UI). A WebView rendered into an off-window +surface like this **cannot host the soft keyboard and cannot present Chrome's +own text-selection UI** (handles, the floating action-mode toolbar, the +magnifier). Chrome detects it has nowhere to put that UI and collapses the +selection to the focus endpoint. + +So, exactly like Flutter's `TextInputPlugin` did for its virtual-display era, we +relay editing to the main process: an invisible `EditText` (`RemoteImeView`) +hosts the keyboard, `shim.js` mirrors DOM selection/caret geometry out over the +Messenger channel, and `EmbeddedTabLayer` draws the selection UI in Compose on +top of the surface. Everything we want for parity, we draw — the platform gives +us nothing here. + +## What native Android gives a text field (the parity target) + +This is the full feature inventory we are cloning, with activation/deactivation +rules, so we can check off coverage. Native impl lives in `android.widget.Editor` +(+ `SelectionActionModeHelper`, `android.widget.Magnifier`, +`PopupTouchHandleDrawable` on the Chrome side). + +| # | Native feature | Activates | Deactivates | Our status | +|---|----------------|-----------|-------------|------------| +| 1 | **Insertion handle** (the teardrop "blob" under the caret) | tap in editable text; tap again to re-show | typing, scroll start, focus loss, ~4s inactivity timeout | ✅ `InsertionHandle`. **Native availability rule now matched (2026-06-25):** only shown when the field is NON-EMPTY (`Editor` gates the handle behind `text.length() > 0`, via `SelectionUiState.fieldHasText`) — fixes it popping up on focus of an empty box; hides on typing (`onEdited`), scroll (`scrolling`), focus loss, and ~4s inactivity (`hideCaret` timeout), re-showing on the next tap — via an explicit `ime.carettap` shim signal (DOM `click`), so a tap that doesn't move the caret still re-shows it. Device-verified. | +| 2 | **Selection handles** (asymmetric left/right teardrops) | long-press word, double-tap word, drag-extend | tap-collapse, typing, new selection | ✅ `SelectionHandle(isStart)` + drag-to-extend, for BOTH plain page text (`pageExtend`) AND in-field `<input>`/`<textarea>` selections (`fieldExtend`, 2026-06-25). The shim reports the selection's caret feet (`sx/sb`,`ex/eb`, flagged `rng`) via the same mirror-div as the caret; the host holds the range geometry separately so Chrome's transient collapse-to-caret (the re-assert fight) doesn't yank the handles to the field edges. **Tap-to-collapse (2026-06-25):** a single tap inside a selection dismisses it to a caret at the tapped offset + insertion handle — the shim's `click` handler collapses explicitly via `offsetFromPoint` (off-window Chrome doesn't do it itself). Device-verified. | +| 3 | **Floating toolbar** (Cut/Copy/Paste/Select-All/Share/…) | selection made, or tap insertion handle (Paste/Select-All) | scroll/fling (hides, returns on settle), handle drag (hides), tap-collapse | ⚠️ `EmbeddedSelectionToolbar` (Cut/Copy/Paste/Select-All). **Hide-during-handle-drag ✅ device-verified.** Hide-during-scroll via `SelectionUiState.scrolling` (shim `ime.scroll` + re-report on settle). **2026-06-26: the scroll path was hardened** — selection-*reveal* scrolls (forming/re-asserting a range auto-scrolls a `<textarea>`) are no longer treated as user scrolls (they blinked the overlays); the shim guards them via `lastSelActivityAt` and the hide self-heals instead of re-arming. (User content-scroll-hide still wants a clean on-device pass.) Still missing: overflow, Share/Web-Search/process-text. | +| 4 | **Magnifier / loupe** (the zoom bubble above the finger while dragging a handle or the caret) | finger down + moving on a handle or the caret | finger up | ✅ **Built (2026-06-25).** `Magnifier` bubble in [EmbeddedMagnifier.kt] follows the dragged caret/selection handle, showing live magnified page pixels captured in the `:napplet` provider and shipped over IPC ([EmbeddedMagnifierProbe], option B). Both embed paths wired (browser + napplet); verified on device for the browser path. Capture Y is locked to the caret/selection line (X follows the finger). Possible further polish: RGB_565 to cut encode, themed crosshair, clamp capture X to the line so a fast drag past EOL doesn't show blank. | +| 5 | **Word-granularity long-press** then char-extend | long-press | — | ✅ HYBRID word+char in-field handle-extend (2026-06-25, completed): `fieldExtend` keeps per-drag state (`fieldDragWordEnd`/`fieldDragWordStart`, reset on a >250ms gap or edge switch). The drag baselines at the current selection edge; sweeping PAST that word's far boundary snaps to the next whole word (`wordEndAt`/`wordStartAt`), while moving within/back from the furthest-reached word gives CHARACTER precision — so you can fine-tune to a single character (the previously-missing "then char" mode). Page-text extend stays char (no offset model). | +| 6 | **Double-tap = word, long-press = word, (triple-tap/drag = paragraph)** | tap count | — | ✅ double-tap + long-press both select a word (2026-06-25). Chrome word-selects on the 2nd tap, then abandons it by collapsing to the end (off-window quirk); the host re-assert restores it. The shim `click` handler DEFERS its tap-to-collapse ~300ms and the real `dblclick` cancels that timer, so the word selection survives (a timing-only guard was flaky ~40%). Triple-tap/paragraph not done. | +| 7 | **Smart selection / entity expansion** (`TextClassifier`: phone, URL, address, date → entity actions in toolbar) | selection lands on an entity | — | ❌ not built (low priority) | +| 8 | **Drag selected text** (long-press a selection → drag-and-drop to move) | long-press on existing selection | drop | ❌ not built (low priority) | +| 9 | **Auto-scroll while dragging to a viewport edge** | handle dragged near top/bottom edge | finger leaves edge / up | ✅ works (2026-06-25, user-confirmed). The drag driver (`onMagnify`) detects the finger in the surface's top/bottom edge zone and sends `ime.autoscroll`; the shim scrolls the textarea (else the window) and re-reports geometry, flagged so the hide-on-scroll path doesn't fire. Scrolls per drag-move in the edge zone (not on a perfectly-held finger). **Fixed alongside:** the nav drawer's left-edge swipe was hijacking the edge drag — `EmbeddedSelectionDrag.dragging` (set by `onMagnify`) now suspends the drawer's `gesturesEnabled` while a handle is dragged. | +| 10 | **Caret snapping to character boundaries** | always during caret/handle drag | — | ✅ via `offsetFromPoint` binary search + Y-clamp. **2026-06-26 fix:** the insertion-handle drag stopped moving the caret (loupe showed, caret frozen) — the unified `awaitEachGesture` consumed the pointer change BEFORE reading `positionChange()`, which returns `Offset.Zero` once consumed, so the accumulated finger position never advanced. Now reads `positionChangeIgnoreConsumed()` first. | +| 11 | **Themed handle/caret drawables + blink** | always | — | ✅/⚠️ The host-drawn handles use `colorScheme.primary` — which IS the native `textSelectHandle`/accent color — so the handle drawables are themed (the actionable part). The caret bar + selection-highlight are drawn by Chrome inside the off-window surface: the caret already blinks natively, and theming its color/the highlight would mean injecting CSS into arbitrary third-party pages (intrusive; `::selection` was already ruled out as non-painting), so those are intentionally left to Chrome. | +| 12 | **Insertion-handle Paste/Select-All mini-popup** | tap the insertion handle | tap elsewhere | ✅ built (2026-06-25). Tapping the bare insertion handle toggles a Paste/Select-All bar above the caret (`SelectionUiState.insertionPopup`, toggled from the handle's unified tap/drag gesture); tap-elsewhere/typing/blur/selection/scroll dismiss it. Fixed a latent bug: toolbar items now consume the *down* (not just the up) so the tap doesn't bleed through to the surface and blur the field. Device-verified (Select-all selects all text, field stays focused). | + +Legend: ✅ done · ⚠️ partial · ❌ missing. + +### Activation/deactivation is the hard part + +Most of the bugs we already fixed were activation-timing bugs (cursor-jumps-to-end, +collapse-on-tap, focus-transfer races). The remaining features each carry their +own state machine. + +**Done (2026-06-25): `SelectionUiState`** (`SelectionUiState.kt`) now centralizes +what used to be scattered flags in `EmbeddedTabLayer` (`showInsertionHandle`, +`showSelectionToolbar`, `fieldGeometry`, `rangeFieldGeometry`, `pageSelection`). +It holds the three mutually-exclusive contexts (insertion caret / in-field range / +page-text range) plus the transient modifiers `dragging` and `scrolling`, and +exposes derived visibility (`insertionHandle`, `fieldHandles`, `fieldToolbar`, +`pageHandles`, `pageToolbar`) so the rules are expressed once: +- **toolbar hides while a handle is dragged** (`dragging`, set from the same + `OnMagnify` lifecycle that drives the loupe) — the dragged handle also hides its + own teardrop (the loupe stands in), like Android; the other handle stays. +- **all overlays hide while scrolling** (`scrolling`, from the shim's `ime.scroll`); + the shim re-reports geometry just before `active=false` so they reappear + repositioned. +Still emergent / TODO: insertion-handle auto-timeout, tap-to-re-show. + +**Selection-blink fix (2026-06-25).** A field selection — especially in a `<textarea>` — +flickered: off-window Chrome abandons the selection by collapsing the caret to an +endpoint every ~25 ms, and the FIELD re-assert round-tripped through the host EditText +(`RemoteImeView.onPageState` → `ime.set` → page), leaving a visible collapsed frame each +cycle. Fix: re-assert field selections **synchronously in the shim's `selectionchange` +handler** (mirror of the page-text path that never blinked) — `lastFieldRange`/`lastFieldAt` +tracked via `noteSel()`, and a collapse-to-endpoint within 1500 ms is reverted with `setSel` +(guarded, not re-reported) so it reverts before paint. The host re-assert stays as a +fallback. Device-verified: textarea selection is stable; input select still works. + +## Priority order for parity work + +1. **Magnifier (#4).** Biggest perceived gap. We already report caret/handle + geometry; the magnifier needs a *magnified pixel view of the surface* at the + drag point. Options: + - **A. Compose-side zoom of a surface snapshot. ❌ RULED OUT (spiked 2026-06-25).** + The surface is a `SurfaceControlViewHost` — we can't trivially `Bitmap`-grab a + remote surface from the main process. We spiked `PixelCopy.request(SurfaceView, …)` + against the live embedded surface (`SurfaceMagnifierProbe`, wired into + `EmbeddedTabLayer` behind `BuildConfig.DEBUG`, fired on field focus). The capture + target is the privacysandbox `ContentView extends SurfaceView` — the only real + child of `SandboxedSdkView` once the session opens. **Result on a clearly-painted + surface (1080×2088): every capture returns `ERROR_SOURCE_NO_DATA`** (center + region, repeated 3×). Cause: the WebView pixels live in a *child* `SurfaceControl` + reparented under the SurfaceView via `ContentView.setChildSurfacePackage(...)`; the + host SurfaceView's *own* buffer is never drawn into, so `PixelCopy` on the parent + reads an empty buffer. Host-side pixel capture of the sandboxed content is not + available. (A `PixelCopy.request(Window, …)` against the host window would also + miss it — the sandbox layer is a *separate* SurfaceControl z-ordered below the + window.) + - **B. Capture in `:napplet` and ship the loupe content. ✅ SPIKED & VIABLE + (2026-06-25).** Inside the keyless provider the WebView IS a real in-window view, so + `WebView.draw(Canvas)` into a software bitmap renders real DOM pixels. Spike added + `MSG_MAGNIFIER_REQUEST`/`MSG_MAGNIFIER_FRAME` to `NappletBrowserContract`: + `NappletBrowserService.onMagnifierRequest` draws a zoomed slice + (`canvas.scale(zoom); translate(-(cx-box/2), -(cy-box/2)); webView.draw(canvas)`), + PNG-encodes it, and ships the bytes back; `EmbeddedBrowserController` (now also an + `EmbeddedMagnifierProbe`) requests on focus and `EmbeddedTabLayer` logs the result. + **10-frame burst, 160px source × 1.5× zoom → 240×240 PNG, center `#FF111111` + (real opaque content):** provider draw 0.7–1.2 ms steady (≈5 ms cold), provider + total draw+PNG 3–4 ms steady (≈12 ms cold), client round-trip 4–8 ms steady + (occasional ~18 ms), payload 8–15 KB (far under the 1 MB Binder limit). Comfortably + within a frame budget if throttled to ~30 fps. + + **✅ Real loupe shipped (2026-06-25).** `MagnifierUiState` + `Magnifier` + (`EmbeddedMagnifier.kt`); the caret/selection handles call an `OnMagnify` callback + on drag start/move/end; `EmbeddedTabLayer` tracks the drag point, throttles capture + requests to one in flight (100 ms timeout), decodes each `MagnifierFrame` to an + `ImageBitmap`, and floats the bubble above the finger (clamped, flips below near the + top). Capture mirrored onto BOTH embed paths (browser: + `NappletBrowserContract`/`NappletBrowserService`/`EmbeddedBrowserController`; + napplet: `NappletEmbedContract`/`NappletHostService`/`EmbeddedNappletController`). + Verified on device (browser): drag → bubble shows live magnified "ello world" with + the caret, centered on the line → follows finger → hides on release. The dead + Option-A probe (`SurfaceMagnifierProbe`) was removed. **Polish done (2026-06-25):** + capture Y is locked to the authoritative caret/selection line (the handles pass a + `lineHalfPx` so the box centers on the line, not the finger or the caret foot); X + still follows the finger. Remaining nice-to-haves: RGB_565/raw to cut PNG encode, + themed crosshair, clamp capture X to the line so a fast drag past EOL isn't blank, + reuse one off-screen bitmap. + - **Gesture-routing caveat (found during the spike).** The host-drawn handles' + drag is fragile: the sandbox `ContentView.onTouchEvent` always returns `true`, so + via Compose's `AndroidView` interop it consumes the drag-move pointer and cancels + the overlay handle's `detectDragGestures` (synthetic `adb` drags on the handle + never produced an `onDrag`). The magnifier trigger should ride the existing + caret/selection-move path (which already round-trips through the shim), not a fresh + Compose drag layered over the surface. +2. **Toolbar state rules (#3 hide-during-drag/scroll) + insertion-handle popup + (#12).** Pure Compose/state work, no platform unknowns. Do alongside the + `SelectionUiState` refactor. +3. **Handle inactivity timeout + tap-to-re-show (#1).** Small. +4. **Double-tap-to-select (#6)** and **word-granularity drag (#5).** +5. **Auto-scroll (#9), themed drawables/blink (#11).** +6. Defer: smart selection (#7), drag-to-move (#8). + +## ✅ FIXED — full-screen round-trip corrupted the embedded WebViews (2026-06-26) + +**Symptom (was).** Open an embedded field's page in its own full-screen activity, +then `back` to the embedded version. From then on **every** embedded surface in the +`:napplet` process was broken — and it was far more than the selection highlight: DOM +reads returned empty (a field that visibly showed text reported `value == ""`, so typing +prepended at offset 0 and backspace did nothing), DNS died (`ERR_NAME_NOT_RESOLVED`), the +selection highlight stopped painting, and IME broke. The page showed a stale last frame +over a functionally-dead renderer. + +**Root cause — two process-global defects in the full-screen hosts** corrupting the +shared multiprocess WebView state the embedded surfaces rely on. (Diagnosed with temporary +logging: page console → logcat, all `onReceivedError`/`onReceivedHttpError`, and the shim's +focused-field type/caret. The user's own insight — "the activity is gone but the service +doesn't come back; am I using something from the activity?" — pointed straight at it.) + +1. **`pauseTimers()`/`resumeTimers()` are PROCESS-GLOBAL** (they pause JS, layout and + parsing timers for *every* WebView in the process). `NappletBrowserActivity` / + `NappletHostActivity` `onPause`/`onResume` and `NappletHostService`'s embed + pause/resume all called them on their own lifecycle — so returning from full-screen + *froze* the embedded surfaces, which had no resume of their own. **Fix:** removed ALL + process-global timer calls; rely only on per-WebView `onPause()`/`onResume()` (which + pause just that surface's JS/DOM — still meets the napplet background-security goal). +2. **`WebView.destroy()` while still attached to the window** corrupts the shared + multiprocess renderer (`cr_AwContents: "WebView.destroy() called while WebView is still + attached to window"`). **Fix:** `stopLoading()` + `(parent as ViewGroup).removeView(...)` + before `destroy()` in both full-screen activities' `onDestroy`. + +Device-verified: the round-trip no longer corrupts the embeds. This supersedes the old +"recreate the session on return" hypothesis and the ruled-out attempts (`::selection` CSS, +surface resize, focus-cycle, `MSG_WAKE`) — none were the real cause. + +## ✅ Embed WebViews follow the app theme (2026-06-26, separate concern) + +Not text-selection, but the same off-window surfaces: embedded (and full-screen) WebViews +rendered web content in the *device* theme, ignoring the app's DARK/LIGHT preference. +**Root cause:** WebView's dark decision (`prefers-color-scheme` via algorithmic darkening) +reads the context's **theme** (`?android:attr/isLightTheme`), NOT just `Configuration.uiMode` +— and the off-window `SurfaceControlViewHost` surface context carries neither. The old +`applyNightMode` used `UiModeManager.setNightMode` (permission-gated no-op). **Fix:** build +every WebView from `nightThemedContext()` — `ContextThemeWrapper(createConfigurationContext( +<night|day>), Theme.DeviceDefault.DayNight)` for the resolved theme — shared in +`nappletHost/.../EmbedWebViewTheme.kt`, used by both embed services + both full-screen +activities. The full debugging arc (config-only context fails; `setForceDark` gone at +targetSdk 37; `setApplicationNightMode` does nothing; it's the unthemed context, not the +process boundary) is in the `embed-webview-prefers-color-scheme-limitation` memo. Upstream +WebView is still buggy here (a cross-process SCVH WebView ignores `uiMode`); the +theme-wrapper is the app-side workaround. + +## How to test + +The on-device harness lives at **`tools/ime-test/`** (`index.html` + `README.md`). +It's a single page with an `<input>`, a `<textarea>`, and an on-page log that +timestamps focus/selection/input/composition events, **paint latency**, +long-tasks, and main-thread blocks — the instrumentation that pinned the erase, +caret-jump, and first-letter-freeze bugs, and exactly what we'll want when +profiling the magnifier. + +Run it (full details in `tools/ime-test/README.md`): + +1. `cd tools/ime-test && python3 -m http.server 8765` +2. Reach it: emulator → `http://10.0.2.2:8765`; USB device → + `adb reverse tcp:8765 tcp:8765` then `http://localhost:8765`. +3. Open that URL in the **in-app browser** to load it as an *embedded* tab. (Opening + the same URL full-screen and pressing `back` used to reproduce the + highlight/corruption bug — now fixed; it's still the regression test for it.) + +Console log lines are tagged `[ImeDiag]` and surface in `adb logcat` (the +`:napplet` process owns the WebView console). This is a dev tool — nothing under +`tools/` ships, which is why those diagnostic strings are kept out of `src/`. + +## Key files + +- `commons/src/commonMain/composeResources/files/napplet/shim.js` — DOM bridge. + Geometry sources: `caretCoords` (287), `offsetFromPoint` (318), `fieldGeom` + (336), `reportState` (360), `pageGeom` (462), `sendPageSel` (470), `pageExtend` + (494). A magnifier built via option B would add a loupe-render here. +- `amethyst/.../embed/EmbeddedTabLayer.kt` — the Compose overlay. `InsertionHandle` + (557), `SelectionHandle` (496), `EmbeddedSelectionToolbar` (616), + `PageSelectionOverlay` (460), the `showInsertionHandle`/`showSelectionToolbar` + state to be folded into a `SelectionUiState`. Magnifier popup (option A) lands + here. +- `amethyst/.../embed/RemoteImeView.kt` — invisible host `EditText`; selection + re-assert + copy/cut/paste/select-all + edit callbacks. +- `amethyst/.../embed/EmbeddedImeBridge.kt` — `SelectionGeometry` (caret + handle + feet + viewport), `ImeEvent.{Focus,State,PageSelection}`, `parseSelectionGeometry`. +- `amethyst/.../{browser/EmbeddedBrowserController,favorites/EmbeddedNappletController}.kt` + — parse `ime.pagesel` + geometry off the Messenger channel. +- Context: `amethyst/plans/2026-06-19-napplet-sandbox-host.md`, + `2026-06-24-napplet-embedded-tabs.md`. diff --git a/amethyst/plans/archive/2026-06-25-web-app-naming.md b/amethyst/plans/archive/2026-06-25-web-app-naming.md new file mode 100644 index 0000000000..b97bbb9163 --- /dev/null +++ b/amethyst/plans/archive/2026-06-25-web-app-naming.md @@ -0,0 +1,66 @@ +# Web-app naming overhaul (favorites / browser / app surfaces) + +> **Status:** shipped — Renames applied — `WebAppScreen`, `NostrAppScreen`, `EmbeddedWebAppController`, `FavoriteAppsScreen` all present. +> _Audited 2026-06-30._ + +## Problem + +The in-app "app" surfaces had colliding, sometimes inaccurate names: + +- `software_apps` (NIP-89 native Android apps, an install-from-a-store flow) showed as + **"Apps"** — colliding with the in-app favorites, which showed as **"Favorite apps"**. +- The **host screens** that render a single web client / nSite / nApplet were named + `FavoriteWebAppScreen` / `FavoriteNappletScreen`. But they open *any* url/coordinate, + favorited or not — "Favorite" described how they happened to be reached (a pinned + bottom-bar tab), not what they are. And `FavoriteNappletScreen` also renders **nSites** + (website-mode), so "Napplet" was narrower than reality. +- Three vocabularies for two model cases: model `WebUrl`/`NostrApp`, route + `FavoriteWebApp`/`FavoriteNostrApp`, screen `FavoriteWebApp`/`FavoriteNapplet`. + +## Taxonomy (decided with maintainer) + +User-facing terms, now distinct: + +| Concept | User-facing | What it is | +|---|---|---| +| Native app store | **App Store** | NIP-89 native Android apps you install off-device | +| Nostr web client | **Web app** | an `https://` client that runs in-app (WebView) | +| nApplet | **nApplet** | NIP-5D sandboxed JS app | +| nSite | **nSite** | NIP-5A static website | +| Pinned set | **Favorite** | a cross-cutting attribute (the star), *not* a screen | + +Code axis (favorites / route / screen / embedded-controller layer): **`WebApp`** (url-based, +no nostr identity) and **`NostrApp`** (coordinate-based nSite *or* nApplet). The cross-process +sandbox infra (`napplet/`, `nappletHost/`, `NappletHostService`, `NappletEmbedContract`) +keeps **"Napplet"** — that process genuinely is the napplet host (it serves nSites in +website-mode too, but the host *is* the napplet runtime). + +"Favorite" is reserved for the **grid of pinned apps** (`FavoriteAppsScreen` / +`Route.FavoriteApps`) and the star toggle — the only things that are actually about favorites. + +## Renames + +Routes: `FavoriteWebApp(url)` → `WebApp(url)`; `FavoriteNostrApp(coordinate)` → `NostrApp(coordinate)`. +Screens: `FavoriteWebAppScreen` → `WebAppScreen`; `FavoriteNappletScreen` → `NostrAppScreen`. +Controllers: `EmbeddedBrowserController` → `EmbeddedWebAppController`; +`EmbeddedNappletController` → `EmbeddedNostrAppController`. +Factory: `acquireBrowser`/`browserId` → `acquireWebApp`/`webAppId`; +`acquireNapplet`/`nappletId` → `acquireNostrApp`/`nostrAppId`. +Model: `FavoriteApp.WebUrl` → `FavoriteApp.WebApp`. Registry: `WebUrlNetworkRegistry` → +`WebAppNetworkRegistry`. + +Strings: `software_apps` "Apps" → "App Store"; `favorite_apps_empty` reworded to name +nApplet/nSite. + +## Stable (do NOT change — persistence / wire compat) + +- Favorite `id` prefixes `"url:"` / `"nostr:"` (persisted dedup + bottom-bar keys). +- DataStore names `"favorite_apps"`, `"weburl_network"`; serialized type tags `"url"` / `"nostr"`. +- `napplet/` + `nappletHost/` sandbox infra names and IPC contracts. + +## Follow-ups (not in this pass) + +- Move `NostrAppScreen` + `EmbeddedNostrAppController` out of the `ui/...favorites/` + package (they are no longer favorites-specific) into a host package alongside the web side. +- Recent nApplets / nSites (parallel to the browser's recent web apps), surfaced on the + discovery screens. diff --git a/amethyst/plans/archive/2026-06-26-nsite-napplet-favorite-icons.md b/amethyst/plans/archive/2026-06-26-nsite-napplet-favorite-icons.md new file mode 100644 index 0000000000..8cf5b9d1af --- /dev/null +++ b/amethyst/plans/archive/2026-06-26-nsite-napplet-favorite-icons.md @@ -0,0 +1,80 @@ +# nSite / nApplet favorite icons + +> **Status:** shipped — `quartz/.../NappletIconPath.kt` and `amethyst/.../favorites/NappletFavoriteIcon.kt` are in tree. +> _Audited 2026-06-30._ + +**Date:** 2026-06-26 +**Status:** implemented (pending on-device verification of the blob image-load path) + +## Problem + +When a user favorites a plain web app and pins it to the bottom nav, the generic globe +icon is replaced by the **site's favicon**. Favorited nSites (NIP-5A) and nApplets +(NIP-5D) did not get the same treatment — they fell back to the generic grid glyph. + +## Why the webapp trick doesn't carry over + +The webapp favicon is **captured live** from the WebView that loads the page +(`NappletBrowserActivity.onReceivedIcon` → IPC `MSG_RECORD_ICON` → +`BrowserIconRegistry`, keyed by host). That works because, for a plain webapp, the site +**is** the WebView's main frame. + +nSites/nApplets render differently: they always load inside a **cross-origin sandboxed +iframe** under a trusted shell document (`commons/.../composeResources/files/napplet/shell.html`, +`iframe.src = '__APP_ORIGIN__/'`). `WebChromeClient.onReceivedIcon` only reports the +**main frame's** favicon — i.e. the shell (`<title>Napplet`, no icon), never the +applet's iframe. So the live-capture approach is structurally blind to the app's own +favicon here, and mirroring it would silently show nothing. + +## Approach: derive the icon from the manifest's own bundled blobs + +An nSite/nApplet ships its files as `path → sha256` (`path` tags). Its icon is almost +always one of those blobs (a conventional `/favicon.png`, `/icon.png`, +`/apple-touch-icon.png`, …). We already download + sha256-verify every manifest blob into +a shared, content-addressed cache (`NappletBlobCache` / `NappletBlobPrefetcher`, +Tor-routed). So the icon can be resolved from the manifest itself — no WebView, no iframe +problem, content-addressed and verifiable, on the same private network path as everything +else. + +### Resolution priority (per favorite) + +1. **Captured/bundled blob** (`iconModel`) — the conventional icon path picked from the + manifest's blobs, loaded from the verified cache as a `file://` model. +2. **Manifest `icon` tag** (`FavoriteApp.iconUrl`) — the publisher-declared icon URL + (already wired before this change). +3. **Type glyph** — grid (nostr app) / globe (web), already the fallback in + `FavoriteAppIcon`. + +Blob beats the `icon` URL deliberately: the blob is verified and rides the site's +Tor-routed path, whereas a remote `icon` URL would be a clearnet fetch by Coil. Both still +beat the glyph. + +## Changes + +- **quartz** `nip5aStaticWebsites/NappletIconPath.kt` (new) — pure, unit-tested heuristic + that picks the best icon `PathTag` from a manifest's `path` tags (priority list of + conventional names + a loose raster fallback; prefers shallower paths; raster formats + over `.ico`/`.svg`). Tests in `NappletIconPathTest.kt`. +- **quartz** — `NappletManifest.iconBlob()` (covers nApplet kinds) and + `RootSiteEvent.iconBlob()` / `NamedSiteEvent.iconBlob()` (nSite kinds) delegate to it. +- **amethyst** `favorites/NappletFavoriteIcon.kt` (new) — `rememberNappletIconModel(coordinate)`: + re-resolves the live event from `LocalCache`, picks its icon blob, ensures it's in the + shared cache (prefetching on demand, off the composition thread), and returns a `file://` + Coil model. Returns null until the blob is on disk (icon appears on next recomposition). +- **amethyst** — `AppBottomBar` and `FavoriteAppsScreen` now resolve that model for + `FavoriteApp.NostrApp` and pass it as `iconModel`, exactly as they already did with the + captured favicon for `FavoriteApp.WebApp`. + +`FavoriteApp` is unchanged (no persistence migration): the icon is resolved from the live +manifest at render time, consistent with the existing rule that a `NostrApp` favorite is +only usable while its event is resolvable in `LocalCache`. + +## Follow-ups / not done + +- **On-device verification** of the blob → Coil image load (the heuristic + wiring are + verified by unit tests + compilation; the actual image render needs a device). +- **HTML `` parsing.** The heuristic matches by conventional file name. + A future pass could fetch + parse the index blob to honor a non-conventional icon path. +- **`.svg` / `.ico` decoding.** Listed as low-priority candidates; if Coil can't decode + them the `FavoriteAppIcon` error fallback shows the glyph, so it's harmless but not + guaranteed to render. diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt index 3c2d30ed82..af91b5ad3a 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.service.cashu.CashuParser -import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -48,12 +48,12 @@ class CashuBTest { assertEquals(2, parsed.proofs[0].amount) assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret) assertEquals("009a1f293253e41e", parsed.proofs[0].id) - assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C) + assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].c) assertEquals(8, parsed.proofs[1].amount) assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret) assertEquals("009a1f293253e41e", parsed.proofs[1].id) - assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C) + assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].c) } } @@ -68,18 +68,18 @@ class CashuBTest { assertEquals(1, parsed[0].proofs[0].amount) assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret) assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id) - assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C) + assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].c) assertEquals(3, parsed[1].totalAmount) assertEquals(2, parsed[1].proofs[0].amount) assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret) assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id) - assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C) + assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].c) assertEquals(1, parsed[1].proofs[1].amount) assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret) assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id) - assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C) + assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].c) } @Test() @@ -93,11 +93,11 @@ class CashuBTest { assertEquals(64, parsed[0].proofs[0].amount) assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret) assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id) - assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C) + assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].c) assertEquals(32, parsed[0].proofs[1].amount) assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret) assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id) - assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C) + assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].c) } } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index 6114b9efe5..c869020eea 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -54,9 +54,10 @@ import org.junit.runner.RunWith * Asserts the three contracts the feature relies on: * 1. `feedKey` is mode-discriminated so each pinned tab caches independently. * 2. `followList()` honors `modeOverride` when set; falls back to the spinner setting otherwise. - * 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for - * `TopFilter.Global` (so `isGlobal()` is true, allowing non-follower notifications through), - * and a non-Global filter for `TopFilter.AllFollows` (forcing the follow-membership gate). + * 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for both + * `TopFilter.Global` and `TopFilter.Selected` (so `isGlobal()` is true, allowing + * non-follower notifications through), and a non-Global filter for `TopFilter.AllFollows` + * (forcing the follow-membership gate). */ @RunWith(AndroidJUnit4::class) class NotificationFeedFilterModeOverrideTest { @@ -66,13 +67,16 @@ class NotificationFeedFilterModeOverrideTest { private val client = NostrClient( - OkHttpWebSocket.Builder { - OkHttpClient - .Builder() - .followRedirects(true) - .followSslRedirects(true) - .build() - }, + OkHttpWebSocket.Builder( + httpClient = { + OkHttpClient + .Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + }, + canDial = { true }, + ), scope, ) @@ -130,8 +134,8 @@ class NotificationFeedFilterModeOverrideTest { fun followListFallsBackToSpinnerWhenOverrideNull() { val spinner = NotificationFeedFilter(account) - account.settings.defaultNotificationFollowList.value = TopFilter.Global - assertEquals(TopFilter.Global, spinner.followList()) + account.settings.defaultNotificationFollowList.value = TopFilter.Selected + assertEquals(TopFilter.Selected, spinner.followList()) account.settings.defaultNotificationFollowList.value = TopFilter.AllFollows assertEquals(TopFilter.AllFollows, spinner.followList()) @@ -151,6 +155,22 @@ class NotificationFeedFilterModeOverrideTest { ) } + @Test + fun buildFilterParamsForSelectedOverrideReportsGlobal() { + val selected = NotificationFeedFilter(account, TopFilter.Selected) + + val params = selected.buildFilterParams(account) + + // Selected rides the same GlobalFeedFlow relay set as Global, so it must + // also report isGlobal and let non-followers through; the difference is + // that acceptableEvent applies the per-kind relevance heuristics, which + // Global skips. + assertTrue( + "Selected mode's FilterByListParams must report isGlobal so non-followers pass the gate", + params.isGlobal(), + ) + } + @Test fun buildFilterParamsForAllFollowsOverrideIsNotGlobal() { val following = NotificationFeedFilter(account, TopFilter.AllFollows) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 2bf278b03b..2cc4fcd1e9 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -58,13 +58,16 @@ class ThreadDualAxisChartAssemblerTest { val client = NostrClient( - OkHttpWebSocket.Builder { - OkHttpClient - .Builder() - .followRedirects(true) - .followSslRedirects(true) - .build() - }, + OkHttpWebSocket.Builder( + httpClient = { + OkHttpClient + .Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + }, + canDial = { true }, + ), scope, ) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt new file mode 100644 index 0000000000..04f5e861e6 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.images + +import android.graphics.Bitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class ThumbnailDiskCacheInstrumentedTest { + private val appContext = InstrumentationRegistry.getInstrumentation().targetContext + private lateinit var cacheDir: File + private lateinit var cache: ThumbnailDiskCache + private lateinit var sourceFile: File + + @Before + fun setUp() { + cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}") + cache = ThumbnailDiskCache(cacheDir) + sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg") + val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + bitmap.recycle() + } + + @After + fun tearDown() { + cacheDir.deleteRecursively() + sourceFile.delete() + } + + @Test + fun generatesThumbnailFromJpeg() { + val url = "https://example.com/profile-pic-${UUID.randomUUID()}.jpg" + + assertTrue("generateFromFile must return true for a valid JPEG", cache.generateFromFile(url, sourceFile)) + assertNotNull("load() must return a non-null Bitmap for a cached JPEG", cache.load(url)) + } + + @Test + fun recreatesCacheDirClearedAtRuntime() { + // The system (cache trim) or the user (Settings > Clear cache) can delete + // the cache dir while the app runs; the next write must recreate it + // instead of failing with ENOENT. + val url = "https://example.com/profile-pic-${UUID.randomUUID()}.jpg" + assertTrue(cacheDir.deleteRecursively()) + + assertTrue( + "generateFromFile must recreate the cache dir after it is cleared at runtime", + cache.generateFromFile(url, sourceFile), + ) + assertNotNull("load() must return the thumbnail written after dir recreation", cache.load(url)) + } +} diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 065754cc4f..72bdeefe46 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + @@ -73,6 +78,16 @@ + + + + + + + + + + + + + + @@ -246,6 +265,22 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 4ac9fefc0e..e5531f56b8 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bd30452fe1..2a70f2084f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst +import android.content.ComponentCallbacks2 import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache @@ -43,6 +44,9 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState +import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore +import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore +import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus @@ -60,6 +64,7 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket +import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache import com.vitorpamplona.amethyst.service.okhttp.SurgeDns import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache @@ -68,7 +73,9 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector +import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -109,6 +116,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend @@ -119,8 +127,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -147,6 +158,9 @@ class AppModules( val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + private val _trimLevelEvents = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val trimLevelEvents = _trimLevelEvents.asSharedFlow() + // Pre-load both preference DataStores in parallel on IO threads. // Both constructors use runBlocking internally, so starting them concurrently // reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs). @@ -231,6 +245,11 @@ class AppModules( // path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it. val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns) + // Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket + // response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor + // on Tor-enabled clients to transparently redirect to .onion addresses. + val onionLocationCache = OnionLocationCache() + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -249,6 +268,7 @@ class AppModules( val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false master && !profileOnly && localBlossomCacheProbe.available.value }, + onionCache = onionLocationCache, ) // Offers easy methods to know when connections are happening through Tor or not @@ -339,6 +359,7 @@ class AppModules( isPrimaryCoreRpc = true, ) } + NamecoinBackend.ELECTRUMX -> { // Custom servers first (if any). If the user only has the public // defaults configured, primary == defaultElectrumx and the @@ -422,14 +443,24 @@ class AppModules( isMobileDataProvider = connManager.isMobileOrNull, scope = applicationIOScope, dns = surgeDns, + onionCache = onionLocationCache, ) // Connects the INostrClient class with okHttp val websocketBuilder = - OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.flow.value.useTor(url) - okHttpClientForRelays.getHttpClient(useTor) - } + OkHttpWebSocket.Builder( + httpClient = { url -> + val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) + okHttpClientForRelays.getHttpClient(useTor) + }, + // Don't dial Tor-routed relays until Tor's SOCKS port is up. Otherwise the + // whole Tor-routed relay set is hammered with doomed dials against the dead + // proxy during bootstrap. RelayProxyClientConnector reconnects them (with + // ignoreRetryDelays=true) the instant Tor flips to Active. + canDial = { url -> + !torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady() + }, + ) // Caches all events in Memory val cache: LocalCache = LocalCache @@ -471,6 +502,18 @@ class AppModules( // Provides a relay pool val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope) + // Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't + // see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough + // fail with zero successes in the window, pokes TorManager to drop + re-init Arti. + val torCircuitHealthTracker = + TorCircuitHealthTracker( + client = client, + isTorRouted = { torEvaluatorFlow.shouldUseTorForRelay(it) }, + isTorActive = { torManager.isSocksReady() }, + isConnectivityActive = { connManager.status.value is ConnectivityStatus.Active }, + onCircuitsDead = { torManager.onTorCircuitsDead() }, + ).also { it.register() } + // Watches for changes on Tor and Relay List Settings val relayProxyClientConnector = RelayProxyClientConnector( @@ -489,6 +532,15 @@ class AppModules( // Show messages from the Relay and controls their dismissal val notifyCoordinator = NotifyCoordinator(client) + // Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts. + val relayAuthPermissionStore by lazy { + DataStoreRelayAuthPermissionStore(appContext) + } + + // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. + val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) } + val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) } + // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) @@ -511,6 +563,9 @@ class AppModules( val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null + // Focused timeline for the DM / gift-wrap loading path (tag: DMPagination). + // val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null + // Coordinates all subscriptions for the Nostr Client val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator( @@ -718,6 +773,18 @@ class AppModules( sessionManager.loginWithDefaultAccountIfLoggedOff() } + // One-time hygiene: remove per-account directories (MLS/Marmot stores) left behind + // by accounts that are no longer saved — account deletion historically didn't clean + // them up, so they leaked disk across every add/remove. + applicationIOScope.launch { + val keep = + LocalPreferences + .allSavedAccounts() + .mapNotNull { decodePublicKeyAsHexOrNull(it.npub) } + .toSet() + accountsCache.pruneOrphanAccountDirs(keep) + } + // forces initialization of uiPrefs in the main thread to avoid blinking themes uiPrefs @@ -739,6 +806,13 @@ class AppModules( resourceCacheInit() } + // Initialize napplet permission stores on an IO thread to avoid StrictMode violations + // when ConnectedAppsScreen first accesses them on the main thread. + applicationIOScope.launch { + nappletPermissionStore + signerPermissionStore + } + // registers to receive events pokeyReceiver.register(appContext) @@ -826,12 +900,71 @@ class AppModules( accountsCache.clear() } - fun trim() { + fun trim(level: Int) { + _trimLevelEvents.tryEmit(level) applicationIOScope.launch { // Backgrounding is a natural moment to flush the DNS cache. dnsStore.save() val loggedIn = accountsCache.accounts.value.values - trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts()) + trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level) + // Trim in-process caches proportional to OS memory pressure. + // + // Background levels (app not visible, ordered highest-first so the when + // chain short-circuits at the right tier): + // COMPLETE (80) — at the bottom of the LRU list, kill imminent + // MODERATE (60) — system is hurting, neighbouring apps being killed + // BACKGROUND(40) — backgrounded, mild system pressure + // UI_HIDDEN (20) — just backgrounded, no pressure yet + // + // Foreground levels (app is active but system is low): + // RUNNING_CRITICAL (15), RUNNING_LOW (10) + // + // UI_HIDDEN fires on EVERY app switch. Don't clear CPU-heavy caches + // (Robohash SVG assembly, rich-text parsing) there — clearing them + // forces a full rebuild on every resume and causes visible jank. + when { + level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { + // Kill imminent: free everything. + memoryCache.trimToSize(0) + CachedRichTextParser.trimToSize(0) + CachedRobohash.trimToSize(0) + nip11Cache.trimToSize(0) + } + level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE -> { + // System under real pressure: clear images and most parsed state. + memoryCache.trimToSize(0) + CachedRichTextParser.trimToSize(50) + CachedRobohash.trimToSize(10) + nip11Cache.trimToSize(100) + } + level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { + // Backgrounded with mild pressure: trim significantly. + memoryCache.trimToSize(memoryCache.maxSize / 4) + CachedRichTextParser.trimToSize(100) + CachedRobohash.trimToSize(20) + nip11Cache.trimToSize(200) + } + level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { + // Just backgrounded, no pressure yet: trim images (bitmaps are the + // largest allocations) but keep parsed-text and avatar caches warm + // so resuming is instant. + memoryCache.trimToSize(memoryCache.maxSize / 2) + } + level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { + // Foreground, critically low memory. + memoryCache.trimToSize(memoryCache.maxSize / 4) + CachedRichTextParser.trimToSize(100) + CachedRobohash.trimToSize(20) + nip11Cache.trimToSize(200) + } + level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { + // Foreground, low memory. + memoryCache.trimToSize(memoryCache.maxSize / 2) + CachedRichTextParser.trimToSize(250) + CachedRobohash.trimToSize(50) + nip11Cache.trimToSize(500) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index 5869a936a5..dcabff9b11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -37,6 +37,58 @@ import kotlin.time.measureTimedValue @Suppress("SENSELESS_COMPARISON") val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark" +data class MemorySnapshot( + val heapUsedMb: Long, + val heapMaxMb: Long, + val nativeHeapUsedMb: Long, + val imageCacheUsedMb: Long, + val imageCacheMaxMb: Long, + val imageDiskUsedMb: Long, + val imageDiskMaxMb: Long, + val noteCount: Int, + val userCount: Int, + val addressableCount: Int, + val chatroomCount: Int, + val memoryClassMb: Int, +) { + val heapFraction: Float get() = heapUsedMb.toFloat() / heapMaxMb.coerceAtLeast(1L).toFloat() +} + +fun collectMemorySnapshot(context: Context): MemorySnapshot { + val rt = Runtime.getRuntime() + val heapUsedMb = (rt.totalMemory() - rt.freeMemory()) / 1_048_576L + val heapMaxMb = rt.maxMemory() / 1_048_576L + val nativeHeapUsedMb = Debug.getNativeHeapAllocatedSize() / 1_048_576L + + val app = Amethyst.instance + val imageCacheUsedMb = app.memoryCache.size / 1_048_576L + val imageCacheMaxMb = app.memoryCache.maxSize / 1_048_576L + val imageDiskUsedMb = app.diskCache.size / 1_048_576L + val imageDiskMaxMb = app.diskCache.maxSize / 1_048_576L + + val activityManager: ActivityManager? = context.getSystemService() + val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0 + val memoryClassMb = + activityManager?.let { + if (isLargeHeap) it.largeMemoryClass else it.memoryClass + } ?: 0 + + return MemorySnapshot( + heapUsedMb = heapUsedMb, + heapMaxMb = heapMaxMb, + nativeHeapUsedMb = nativeHeapUsedMb, + imageCacheUsedMb = imageCacheUsedMb, + imageCacheMaxMb = imageCacheMaxMb, + imageDiskUsedMb = imageDiskUsedMb, + imageDiskMaxMb = imageDiskMaxMb, + noteCount = LocalCache.notes.size(), + userCount = LocalCache.users.size(), + addressableCount = LocalCache.addressables.size(), + chatroomCount = LocalCache.chatroomList.size(), + memoryClassMb = memoryClassMb, + ) +} + private const val STATE_DUMP_TAG = "STATE DUMP" fun debugState(context: Context) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 3bf5f7eeca..c853fc7cc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,8 +25,10 @@ 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.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.UiSettings @@ -104,6 +106,10 @@ private object PrefKeys { const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList" const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList" const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList" + const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList" + const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList" + const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList" + const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList" const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList" const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList" const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList" @@ -121,9 +127,12 @@ private object PrefKeys { const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList" const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList" + const val DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST = "defaultAppRecommendationsFollowList" 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" @@ -147,7 +156,13 @@ private object PrefKeys { const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" + const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled" + const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications" + + // One-shot stamp: set once an account has gone through the notifications + // Global -> Selected (Curated) migration (or was created after it shipped). + const val NOTIF_GLOBAL_TO_CURATED_MIGRATED = "notif_global_to_curated_migrated" const val TOR_SETTINGS = "tor_settings" const val USE_PROXY = "use_proxy" const val PROXY_PORT = "proxy_port" @@ -171,6 +186,12 @@ object LocalPreferences { private var currentAccount: String? = null private val savedAccounts: MutableStateFlow?> = MutableStateFlow(null) + + // Guards the one-time lazy population of [savedAccounts]. Without it, concurrent callers + // of savedAccounts() (e.g. the account-load path, the always-on notification service, and + // the orphan-dir sweep, all launched at startup) would each see a null value, run the IO + // read in parallel, and the migration branch could double-write ALL_ACCOUNT_INFO. + private val savedAccountsMutex = Mutex() private val cachedAccounts: MutableMap = mutableMapOf() suspend fun currentAccount(): String? { @@ -200,41 +221,46 @@ object LocalPreferences { } private suspend fun savedAccounts(): List { - if (savedAccounts.value == null) { - withContext(Dispatchers.IO) { - with(encryptedPreferences()) { - val newSystemOfAccounts = - getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { - JsonMapper.fromJson>(it) - } + // Fast path: already populated, no lock needed. + savedAccounts.value?.let { return it } - if (!newSystemOfAccounts.isNullOrEmpty()) { - savedAccounts.emit(newSystemOfAccounts) - } else { - val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf() + return savedAccountsMutex.withLock { + // Re-check under the lock: another coroutine may have populated it while we waited. + savedAccounts.value ?: loadSavedAccountsFromStorage().also { savedAccounts.emit(it) } + } + } - val migrated = - oldAccounts.map { npub -> - AccountInfo( - npub, - encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), - (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(), - false, - ) - } - - savedAccounts.emit(migrated) - - edit { - putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated)) - } + private suspend fun loadSavedAccountsFromStorage(): List = + withContext(Dispatchers.IO) { + with(encryptedPreferences()) { + val newSystemOfAccounts = + getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { + JsonMapper.fromJson>(it) } + + if (!newSystemOfAccounts.isNullOrEmpty()) { + newSystemOfAccounts + } else { + val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf() + + val migrated = + oldAccounts.map { npub -> + AccountInfo( + npub, + encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), + (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(), + false, + ) + } + + edit { + putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated)) + } + + migrated } } } - // it's always not null when it gets here. - return savedAccounts.value!! - } fun accountsFlow() = savedAccounts @@ -317,6 +343,9 @@ object LocalPreferences { suspend fun deleteAccount(accountInfo: AccountInfo) { Log.d("LocalPreferences") { "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}" } withContext(Dispatchers.IO) { + // Drop the in-memory copy as well; otherwise re-adding the same account later + // would resurrect the deleted settings from this cache. + mutex.withLock { cachedAccounts.remove(accountInfo.npub) } encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } removeAccount(accountInfo) deleteUserPreferenceFile(accountInfo.npub) @@ -329,7 +358,27 @@ object LocalPreferences { } } - suspend fun setDefaultAccount(accountSettings: AccountSettings) { + /** + * Make [accountSettings] the current account, persisting + caching it. Returns the settings that + * actually became current — normally [accountSettings] itself, but see the downgrade guard below. + */ + suspend fun setDefaultAccount(accountSettings: AccountSettings): AccountSettings { + val npub = accountSettings.keyPair.pubKey.toNpub() + + // Downgrade guard: adding a read-only npub for a pubkey we already hold a SIGNING account for + // must not clobber that account. Accounts dedup by npub, so saving fresh read-only settings + // here would overwrite the signing account's per-npub file — wiping its cached follow/relay/ + // mute lists and flipping hasPrivKey off, which silently disables its push notifications. A + // signing account already does everything the read-only one would, so keep it and just make + // it current instead of degrading it. + if (!accountSettings.isWriteable()) { + val existing = loadAccountConfigFromEncryptedStorage(npub) + if (existing != null && existing.isWriteable()) { + setCurrentAccount(existing) + return existing + } + } + // Save the per-npub file before emitting onto the savedAccounts flow. // Otherwise a collector (e.g. AlwaysOnNotificationServiceManager) can race in // and call loadAccountConfigFromEncryptedStorage(npub) before NOSTR_PUBKEY is @@ -337,9 +386,9 @@ object LocalPreferences { // rest of the session — making every later switch to this account land on // LoggedOff instead of LoggedIn. saveToEncryptedStorage(accountSettings) - val npub = accountSettings.keyPair.pubKey.toNpub() mutex.withLock { cachedAccounts.put(npub, accountSettings) } setCurrentAccount(accountSettings) + return accountSettings } suspend fun allSavedAccounts(): List = savedAccounts() @@ -377,6 +426,10 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value)) putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value)) + putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value)) + putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value)) + putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value)) + putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value)) putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value)) putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value)) putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value)) @@ -394,6 +447,7 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value)) putString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultFollowPacksFollowList.value)) + putString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultAppRecommendationsFollowList.value)) val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() } if (walletEntries.isNotEmpty()) { @@ -401,9 +455,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) @@ -444,7 +508,14 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) + putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value) + putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value) + // Any account that reaches a save has its notification filter in its + // post-split meaning, so stamp it as migrated. This keeps the one-shot + // Global -> Selected rewrite from ever touching it again and preserves a + // deliberate raw-Global choice (including on brand-new accounts). + putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true) // migrating from previous design remove(PrefKeys.USE_PROXY) @@ -554,7 +625,12 @@ object LocalPreferences { val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) + val defaultRelayAuthPolicy = + getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null) + ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } + ?: RelayAuthPolicy.IF_IN_MY_LIST val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) + val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) @@ -565,6 +641,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 +697,10 @@ object LocalPreferences { } } } + val clinkDebitsLoaded = + async { + parseOrNull>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList() + } val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } val viewedPollResultNoteIds = async { parseOrNull>(viewedPollResultNoteIdsStr) ?: mapOf() } @@ -660,12 +742,50 @@ object LocalPreferences { Log.d("LocalPreferences") { "Load account from file $npub - asyncs created" } + // Resolve every parallel parse into a local before constructing AccountSettings. + // Awaiting inside the 70-argument constructor expression below would place ~27 + // suspension points in the middle of a single huge operand stack, forcing the + // coroutine state machine to spill/restore every partially-evaluated argument at + // each point. That bloats the generated method past the compiler's per-method + // instruction limit ("Method exceeds compiler instruction limit"). Awaiting into + // vals first keeps each suspension point at a statement boundary (near-empty + // operand stack) and leaves the constructor as straight-line, suspension-free code. + val nwcWalletsResolved = nwcWalletsLoaded.await() + val clinkDebitsResolved = clinkDebitsLoaded.await() + val defaultFileServerResolved = defaultFileServer.await() + val viewedPollResultNoteIdsResolved = viewedPollResultNoteIds.await() + val pendingAttestationsResolved = pendingAttestations.await() + val lastReadPerRouteResolved = lastReadPerRoute.await() + val latestUserMetadataResolved = latestUserMetadata.await() + val latestContactListResolved = latestContactList.await() + val latestDmRelayListResolved = latestDmRelayList.await() + val latestNip65RelayListResolved = latestNip65RelayList.await() + val latestSearchRelayListResolved = latestSearchRelayList.await() + val latestIndexRelayListResolved = latestIndexRelayList.await() + val latestRelayFeedsListResolved = latestRelayFeedsList.await() + val latestBlockedRelayListResolved = latestBlockedRelayList.await() + val latestTrustedRelayListResolved = latestTrustedRelayList.await() + val latestMuteListResolved = latestMuteList.await() + val latestPrivateHomeRelayListResolved = latestPrivateHomeRelayList.await() + val latestAppSpecificDataResolved = latestAppSpecificData.await() + val latestChannelListResolved = latestChannelList.await() + val latestCommunityListResolved = latestCommunityList.await() + val latestHashtagListResolved = latestHashtagList.await() + val latestGeohashListResolved = latestGeohashList.await() + val latestEphemeralListResolved = latestEphemeralList.await() + val latestTrustProviderListResolved = latestTrustProviderList.await() + val latestPaymentTargetsResolved = latestPaymentTargets.await() + val latestCashuWalletResolved = latestCashuWallet.await() + val latestNutzapInfoResolved = latestNutzapInfo.await() + + Log.d("LocalPreferences") { "Load account from file $npub - asyncs resolved" } + return@with AccountSettings( keyPair = keyPair, transientAccount = false, externalSignerPackageName = externalSignerPackageName, localRelayServers = MutableStateFlow(localRelayServers), - defaultFileServer = defaultFileServer.await(), + defaultFileServer = defaultFileServerResolved, stripLocationOnUpload = stripLocationOnUpload, useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache), localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly), @@ -676,6 +796,10 @@ object LocalPreferences { defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery), defaultPollsFollowList = MutableStateFlow(followListPrefs.polls), defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures), + defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets), + defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites), + defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts), + defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories), defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars), defaultProductsFollowList = MutableStateFlow(followListPrefs.products), defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts), @@ -693,39 +817,50 @@ object LocalPreferences { defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets), defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities), defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks), - nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first), - defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second), + defaultAppRecommendationsFollowList = MutableStateFlow(followListPrefs.appRecommendations), + nwcWallets = MutableStateFlow(nwcWalletsResolved.first), + clinkDebitWallets = MutableStateFlow(clinkDebitsResolved), + // 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 + ?: nwcWalletsResolved.second + ?: clinkDebitsResolved.firstOrNull()?.id, + ), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), + defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled), - backupUserMetadata = latestUserMetadata.await(), - backupContactList = latestContactList.await(), - backupNIP65RelayList = latestNip65RelayList.await(), - backupDMRelayList = latestDmRelayList.await(), - backupSearchRelayList = latestSearchRelayList.await(), - backupIndexRelayList = latestIndexRelayList.await(), - backupRelayFeedsList = latestRelayFeedsList.await(), - backupBlockedRelayList = latestBlockedRelayList.await(), - backupTrustedRelayList = latestTrustedRelayList.await(), - backupPrivateHomeRelayList = latestPrivateHomeRelayList.await(), - backupMuteList = latestMuteList.await(), - backupAppSpecificData = latestAppSpecificData.await(), - backupChannelList = latestChannelList.await(), - backupCommunityList = latestCommunityList.await(), - backupHashtagList = latestHashtagList.await(), - backupGeohashList = latestGeohashList.await(), - backupEphemeralChatList = latestEphemeralList.await(), - backupTrustProviderList = latestTrustProviderList.await(), - lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()), + showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications), + backupUserMetadata = latestUserMetadataResolved, + backupContactList = latestContactListResolved, + backupNIP65RelayList = latestNip65RelayListResolved, + backupDMRelayList = latestDmRelayListResolved, + backupSearchRelayList = latestSearchRelayListResolved, + backupIndexRelayList = latestIndexRelayListResolved, + backupRelayFeedsList = latestRelayFeedsListResolved, + backupBlockedRelayList = latestBlockedRelayListResolved, + backupTrustedRelayList = latestTrustedRelayListResolved, + backupPrivateHomeRelayList = latestPrivateHomeRelayListResolved, + backupMuteList = latestMuteListResolved, + backupAppSpecificData = latestAppSpecificDataResolved, + backupChannelList = latestChannelListResolved, + backupCommunityList = latestCommunityListResolved, + backupHashtagList = latestHashtagListResolved, + backupGeohashList = latestGeohashListResolved, + backupEphemeralChatList = latestEphemeralListResolved, + backupTrustProviderList = latestTrustProviderListResolved, + lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), - viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()), - pendingAttestations = MutableStateFlow(pendingAttestations.await()), - backupNipA3PaymentTargets = latestPaymentTargets.await(), - backupCashuWallet = latestCashuWallet.await(), - backupNutzapInfo = latestNutzapInfo.await(), + viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved), + pendingAttestations = MutableStateFlow(pendingAttestationsResolved), + backupNipA3PaymentTargets = latestPaymentTargetsResolved, + backupCashuWallet = latestCashuWalletResolved, + backupNutzapInfo = latestNutzapInfoResolved, callsEnabled = MutableStateFlow(callsEnabled), ) } @@ -755,6 +890,10 @@ object LocalPreferences { val discovery: TopFilter, val polls: TopFilter, val pictures: TopFilter, + val napplets: TopFilter, + val nsites: TopFilter, + val workouts: TopFilter, + val gitRepositories: TopFilter, val calendars: TopFilter, val products: TopFilter, val shorts: TopFilter, @@ -772,16 +911,45 @@ object LocalPreferences { val browseEmojiSets: TopFilter, val communities: TopFilter, val followPacks: TopFilter, + val appRecommendations: TopFilter, ) + /** + * One-shot migration of the notifications filter. + * + * The notifications "Global" mode was split into a raw [TopFilter.Global] + * (every event that p-tags the user) and a curated [TopFilter.Selected]. + * Existing users who had selected the old, curated "Global" keep a value + * that now deserializes to the much-more-permissive raw Global. Move them to + * [TopFilter.Selected] exactly once, then stamp the account so a later, + * deliberate raw-Global choice is never reverted. Accounts created after the + * split are stamped at save time, so they are never touched here. + */ + private fun SharedPreferences.migrateNotificationFilter(current: TopFilter): TopFilter { + if (getBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, false)) return current + + val migrated = if (current is TopFilter.Global) TopFilter.Selected else current + edit { + if (migrated !== current) { + putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(migrated)) + } + putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true) + } + return migrated + } + private fun SharedPreferences.loadFollowListPrefs(): FollowListPrefs = FollowListPrefs( home = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null), TopFilter.AllFollows), stories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null), TopFilter.Global), - notification = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Global), + notification = migrateNotificationFilter(parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Selected)), discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global), polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global), pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global), + napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global), + nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global), + workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global), + gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global), calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global), products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe), shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global), @@ -799,6 +967,7 @@ object LocalPreferences { browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows), followPacks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, null), TopFilter.Global), + appRecommendations = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, null), TopFilter.Global), ) private inline fun parseOrNull(value: String?): T? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt new file mode 100644 index 0000000000..c7c92602e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt @@ -0,0 +1,154 @@ +/* + * 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.favorites + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history") + +/** + * One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking + * in the omnibox suggestions. + */ +@Serializable +data class BrowserHistoryEntry( + val url: String, + val title: String, + val host: String, + val lastVisitedAt: Long, + val visitCount: Int, +) + +/** + * The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites. + * + * **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host + * (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from + * the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded + * to [MAX_ENTRIES] most-recent entries. + * + * Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox + * never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for + * synchronous Compose reads, with write-through persistence to a DataStore on a background scope. + */ +object BrowserHistoryRegistry { + private val KEY = stringPreferencesKey("history") + private const val MAX_ENTRIES = 500 + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile private var appContext: Context? = null + + @Volatile private var hydrated = false + + /** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */ + fun init(context: Context) { + if (appContext != null) return + val ctx = context.applicationContext + appContext = ctx + scope.launch { + val json = ctx.browserHistoryDataStore.data.first()[KEY] + val loaded = if (json != null) decode(json) else emptyList() + // Merge disk under anything already recorded this session (session wins, newest-first). + update { current -> dedupeNewestFirst(current + loaded) } + hydrated = true + } + } + + /** + * Records a successful visit to [url], moving it to the front. An existing entry for the same URL is + * bumped (visit count +1, title refreshed if non-blank); otherwise a new entry is prepended. + */ + fun record( + url: String, + title: String, + ) { + val host = OmniboxInput.hostOf(url) ?: url + val now = System.currentTimeMillis() + update { current -> + val existing = current.firstOrNull { it.url == url } + val entry = + if (existing != null) { + existing.copy( + title = title.ifBlank { existing.title }, + host = host, + lastVisitedAt = now, + visitCount = existing.visitCount + 1, + ) + } else { + BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1) + } + (listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES) + } + } + + fun remove(url: String) = update { current -> current.filterNot { it.url == url } } + + fun clear() = update { emptyList() } + + private fun dedupeNewestFirst(list: List): List = + list + .sortedByDescending { it.lastVisitedAt } + .distinctBy { it.url } + .take(MAX_ENTRIES) + + private inline fun update(transform: (List) -> List) { + val next = transform(_history.value) + if (next == _history.value) return + _history.value = next + persist(encode(next)) + } + + private fun persist(json: String) { + val ctx = appContext ?: return + scope.launch { + ctx.browserHistoryDataStore.edit { it[KEY] = json } + } + } + + private fun encode(list: List): String = JsonMapper.toJson(list) + + private fun decode(json: String): List = + try { + JsonMapper.fromJson>(json) + } catch (e: Exception) { + Log.w("BrowserHistoryRegistry", "Failed to decode history", e) + emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt new file mode 100644 index 0000000000..1623d160d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt @@ -0,0 +1,98 @@ +/* + * 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.favorites + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.io.File + +/** + * Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView + * that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own — + * Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving + * alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the + * visit. Used to decorate favorite cards and omnibox suggestion rows. + * + * Lives only in the **main process**. Bytes are persisted as one small PNG per host under + * `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of + * hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep + * `File.exists()` disk checks out of composition). + */ +object BrowserIconRegistry { + private const val DIR = "browser_icons" + + private val _keys = MutableStateFlow>(emptySet()) + + /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ + val keys: StateFlow> = _keys.asStateFlow() + + @Volatile private var iconDir: File? = null + + /** Binds the app context and indexes already-stored icons. Idempotent. */ + fun init(context: Context) { + if (iconDir != null) return + val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() } + iconDir = dir + _keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet() + } + + /** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */ + fun record( + host: String, + bytes: ByteArray, + ) { + val dir = iconDir ?: return + if (host.isBlank() || bytes.isEmpty()) return + val key = sanitize(host) + try { + File(dir, key + PNG).writeBytes(bytes) + _keys.update { it + key } + } catch (e: Exception) { + Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e) + } + } + + /** + * A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers + * that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key. + */ + fun iconModelFor(host: String): String? { + val dir = iconDir ?: return null + val key = sanitize(host) + if (key !in _keys.value) return null + return "file://" + File(dir, key + PNG).absolutePath + } + + // Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared + // icon file, which is harmless for a decoration. + private fun sanitize(host: String): String = + host + .lowercase() + .map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' } + .joinToString("") + .take(120) + + private const val PNG = ".png" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt new file mode 100644 index 0000000000..f9ab3183e9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -0,0 +1,219 @@ +/* + * 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.favorites + +import android.app.Activity +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.ThemeType +import com.vitorpamplona.amethyst.napplet.NappletLauncher +import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry +import com.vitorpamplona.amethyst.napplethost.HostProfile +import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +/** + * Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the + * codebase, nothing more: + * + * - [FavoriteApp.WebApp] → a full-screen direct-WebView + * [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own + * task/recents entry), so the web client owns the whole screen and scrolls/zooms natively. + * - [FavoriteApp.NostrApp] → re-resolve the live event from [LocalCache] by coordinate, read its + * `requires`/website-mode off the event, then hand to [NappletLauncher] (the sandboxed `:napplet` + * host). nsite vs napplet is decided *here*, from the event, never from stored state. + * + * A [FavoriteApp.NostrApp] whose event hasn't loaded yet can't launch; we surface that instead of + * failing silently. + */ +object FavoriteAppLauncher { + fun launch( + context: Context, + app: FavoriteApp, + ) { + when (app) { + is FavoriteApp.WebApp -> launchUrl(context, app.url) + is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate) + } + } + + /** + * Opens [url] full-screen in its own task, so back/recents treat it like a separate app. Uses the + * direct-WebView [NappletBrowserActivity] (page scrolls/zooms and the keyboard resizes natively), + * resolving the proxy port + this site's remembered Tor choice here in the main process. [preferTor] + * forces Tor (when available) regardless of the remembered choice — used for `.onion`, which only + * resolves over Tor. + */ + fun launchUrl( + context: Context, + url: String, + preferTor: Boolean = false, + ) { + val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url)) + val themeType = Amethyst.instance.uiPrefs.value.theme.value + val theme = + when (themeType) { + ThemeType.DARK -> "DARK" + ThemeType.LIGHT -> "LIGHT" + ThemeType.SYSTEM -> { + val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT" + } + } + val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url") + val intent = + NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply { + if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + + private fun launchNostrApp( + context: Context, + coordinate: String, + ) { + val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event + when (event) { + is RootNappletEvent -> + NappletLauncher.launch(context, event, event.pubKey, "") + is NamedNappletEvent -> + NappletLauncher.launch(context, event, event.pubKey, event.identifier()) + is RootSiteEvent -> + NappletLauncher.launch( + context = context, + paths = event.paths(), + servers = event.servers(), + authorPubKey = event.pubKey, + identifier = "", + aggregateHash = null, + title = event.title() ?: "nsite", + requires = emptyList(), + profile = HostProfile.WEBSITE, + ) + is NamedSiteEvent -> + NappletLauncher.launch( + context = context, + paths = event.paths(), + servers = event.servers(), + authorPubKey = event.pubKey, + identifier = event.identifier(), + aggregateHash = null, + title = event.title() ?: event.identifier(), + requires = emptyList(), + profile = HostProfile.WEBSITE, + ) + else -> { + Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate") + Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show() + } + } + } + + /** + * Builds the main-process-minted launch parameters for embedding the nsite/napplet at [coordinate] + * as an in-app tab (see `NappletHostService`). Returns null when the event isn't resolvable in + * [LocalCache] yet — the caller shows a loading state. nsite vs napplet (and website mode) is decided + * here from the live event, exactly as in [launchNostrApp]. + */ + fun embedParams( + context: Context, + coordinate: String, + ): Bundle? { + val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event + return when (event) { + is RootNappletEvent -> + NappletLauncher.buildLaunchParams( + context, + event.paths(), + event.servers(), + event.pubKey, + "", + event.declaredAggregateHash() ?: event.computeAggregateHash(), + event.title() ?: "Napplet", + event.requires(), + HostProfile.NAPPLET, + ) + is NamedNappletEvent -> + NappletLauncher.buildLaunchParams( + context, + event.paths(), + event.servers(), + event.pubKey, + event.identifier(), + event.declaredAggregateHash() ?: event.computeAggregateHash(), + event.title() ?: event.identifier(), + event.requires(), + HostProfile.NAPPLET, + ) + is RootSiteEvent -> + NappletLauncher.buildLaunchParams( + context, + event.paths(), + event.servers(), + event.pubKey, + "", + null, + event.title() ?: "nsite", + emptyList(), + HostProfile.WEBSITE, + ) + is NamedSiteEvent -> + NappletLauncher.buildLaunchParams( + context, + event.paths(), + event.servers(), + event.pubKey, + event.identifier(), + null, + event.title() ?: event.identifier(), + emptyList(), + HostProfile.WEBSITE, + ) + else -> null + } + } + + /** + * The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored + * instead of the content hash so the favorite survives routine code/manifest updates. + */ + fun coordinateOf(event: Event): String { + val dTag = + when (event) { + is NamedNappletEvent -> event.identifier() + is NamedSiteEvent -> event.identifier() + else -> "" + } + return "${event.kind}:${event.pubKey}:$dTag" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt new file mode 100644 index 0000000000..f8ca82918a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt @@ -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.favorites + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import java.util.concurrent.ConcurrentHashMap + +private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favorite_apps") + +/** + * The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom + * bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated + * by [FavoriteApp.id]. + * + * Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never + * touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it + * synchronously, with write-through persistence to a DataStore on a background scope. The list is + * stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key + * per entry). + */ +object FavoriteAppsRegistry { + private val KEY = stringPreferencesKey("favorites") + + // Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable + // coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and + // offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never + // has to. The relay subscription that warms these favorites keeps the cache fresh. + private val MANIFESTS_KEY = stringPreferencesKey("manifests") + + private val _favorites = MutableStateFlow>(emptyList()) + val favorites: StateFlow> = _favorites.asStateFlow() + + private val manifestCache = MutableStateFlow>(emptyMap()) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile private var appContext: Context? = null + + // Hydration runs async on a background scope, so the user can add/remove before the disk list + // merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't + // resurrect a just-deleted favorite from disk. + @Volatile private var hydrated = false + private val removedBeforeHydration = ConcurrentHashMap.newKeySet() + + /** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */ + fun init(context: Context) { + if (appContext != null) return + val ctx = context.applicationContext + appContext = ctx + scope.launch { + val prefs = ctx.favoriteAppsDataStore.data.first() + val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList() + // Don't clobber adds made in this session before hydration finished, and don't resurrect + // anything the user removed in that same window. + update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } } + + // Same race rules for the manifest cache: a cacheManifest() in this session wins over the + // disk copy, and a manifest whose favorite was removed pre-hydration must not come back. + val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap() + updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current } + + hydrated = true + removedBeforeHydration.clear() + } + } + + fun isFavorite(id: String): Boolean = _favorites.value.any { it.id == id } + + /** Adds [app] to the end if not already present (by [FavoriteApp.id]). */ + fun add(app: FavoriteApp) = update { current -> if (current.any { it.id == app.id }) current else current + app } + + fun remove(id: String) { + if (!hydrated) removedBeforeHydration.add(id) + update { current -> current.filterNot { it.id == id } } + // Drop the cached manifest too — favorite ids for nsites/napplets are "nostr:". + if (id.startsWith("nostr:")) updateManifests { it - id.removePrefix("nostr:") } + } + + /** The cached manifest event JSON for a favorited nsite/napplet [coordinate], or null if none. */ + fun cachedManifest(coordinate: String): String? = manifestCache.value[coordinate] + + /** + * Caches the raw manifest event [eventJson] for a favorited nsite/napplet [coordinate] so the next + * launch can resolve it instantly / offline. Write-through; no-ops when the JSON is unchanged. + */ + fun cacheManifest( + coordinate: String, + eventJson: String, + ) = updateManifests { if (it[coordinate] == eventJson) it else it + (coordinate to eventJson) } + + /** Replaces the whole list, e.g. after a drag-reorder. */ + fun setOrder(newOrder: List) = update { newOrder } + + private inline fun update(transform: (List) -> List) { + val next = transform(_favorites.value) + if (next == _favorites.value) return + _favorites.value = next + persist(encode(next)) + } + + private inline fun updateManifests(transform: (Map) -> Map) { + val next = transform(manifestCache.value) + if (next == manifestCache.value) return + manifestCache.value = next + persistManifests(encodeManifests(next)) + } + + private fun persist(json: String) { + val ctx = appContext ?: return + scope.launch { + ctx.favoriteAppsDataStore.edit { it[KEY] = json } + } + } + + private fun persistManifests(json: String) { + val ctx = appContext ?: return + scope.launch { + ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json } + } + } + + // --- Persistence DTO ------------------------------------------------------------------------ + // A flat, type-tagged record so we serialize one concrete shape instead of relying on + // polymorphic (sealed) (de)serialization. Mapping to/from the sealed model lives here. + + @Serializable + private data class Entry( + val type: String, + val ref: String, + val label: String, + val addedAt: Long, + val iconUrl: String? = null, + ) + + private fun encode(list: List): String = + JsonMapper.toJson( + list.map { + when (it) { + is FavoriteApp.NostrApp -> Entry(TYPE_NOSTR, it.coordinate, it.label, it.addedAt, it.iconUrl) + is FavoriteApp.WebApp -> Entry(TYPE_URL, it.url, it.label, it.addedAt, it.iconUrl) + } + }, + ) + + private fun decode(json: String): List = + try { + JsonMapper.fromJson>(json).mapNotNull { entry -> + when (entry.type) { + TYPE_NOSTR -> FavoriteApp.NostrApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl) + TYPE_URL -> FavoriteApp.WebApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl) + else -> null + } + } + } catch (e: Exception) { + Log.w("FavoriteAppsRegistry", "Failed to decode favorites", e) + emptyList() + } + + private const val TYPE_NOSTR = "nostr" + private const val TYPE_URL = "url" + + // --- Manifest cache persistence ------------------------------------------------------------- + // Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated + // shape as the favorites list, so we never serialize a raw polymorphic map. + + @Serializable + private data class ManifestEntry( + val coordinate: String, + val json: String, + ) + + private fun encodeManifests(manifests: Map): String = JsonMapper.toJson(manifests.map { ManifestEntry(it.key, it.value) }) + + private fun decodeManifests(json: String): Map = + try { + JsonMapper.fromJson>(json).associate { it.coordinate to it.json } + } catch (e: Exception) { + Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e) + emptyMap() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteNostrAppPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteNostrAppPreloader.kt new file mode 100644 index 0000000000..f1aebc6ea7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteNostrAppPreloader.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.favorites + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * Pre-fetches the addressable events behind the user's favorited [FavoriteApp.NostrApp]s (nSites / + * nApplets) while a screen that can launch them is on screen. + * + * A favorite stores only the addressable coordinate `kind:pubkey:dtag`, never the event. The launch + * path ([FavoriteAppLauncher.launchNostrApp] / [FavoriteAppLauncher.embedParams]) re-resolves the live + * event from [LocalCache] at tap time, so a favorite whose event hasn't streamed in yet can't launch — + * the user gets the "isn't loaded yet" toast / unavailable tab. Before this preloader, the only thing + * that pulled those events into the cache was visiting the nsite/napplet feed (it subscribes by author), + * which is why opening that feed and coming back made a favorite suddenly launchable. + * + * This subscribes each favorited coordinate to the shared + * [EventFinder][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler] + * — the same lifecycle-aware loader [observeNote][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote] + * uses — so the manifests fetch (via the author's outbox relays) as soon as the launcher opens and are + * already in [LocalCache] by the time the user taps. The loader drops each coordinate from its filter + * once the event arrives, so this is a one-shot fetch, not a standing feed. + */ +@Composable +fun PreloadFavoriteNostrApps( + apps: List, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + // Reuse the same query-state instances across recompositions (the manager ref-counts by identity), + // recomputing only when the favorite list or account changes. Events that already loaded are still + // included; the loader simply skips them because their note already has an event. + val states = + remember(apps, account) { + apps + .filterIsInstance() + .mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it.coordinate) } + .map { EventFinderQueryState(it, account) } + } + + LifecycleAwareKeyDataSourceSubscription(states, accountViewModel.dataSources().eventFinder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt new file mode 100644 index 0000000000..e03d31a737 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.favorites + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.napplethost.NappletBlobCache +import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** The chosen icon blob and the servers that hold it — resolved from the live manifest. */ +private class IconBlob( + val path: PathTag, + val servers: List, +) + +/** + * A Coil model (`file://…`) for the app icon an nsite/napplet bundles in its own content — the verified, + * content-addressed blob the [NappletIconPath][com.vitorpamplona.quartz.nip5aStaticWebsites.NappletIconPath] + * heuristic picks from the manifest's `path` tags — or null when the manifest declares no such icon (or it + * hasn't downloaded yet). Used to decorate an nsite/napplet favorite the same way a captured favicon + * decorates a web favorite, except this rides the same Tor-routed, sha256-verified blob path as the rest of + * the site instead of a clearnet favicon fetch. + * + * Unlike a webapp's favicon, this can't be captured live: the applet runs in a cross-origin sandboxed + * iframe under the trusted shell, so `WebChromeClient.onReceivedIcon` only ever reports the shell's icon, + * never the applet's. Deriving it from the bundled blobs is the only path that sees the real icon. + * + * Observes the addressable note, so the icon resolves whenever the manifest arrives or updates in + * LocalCache — not only if it already happened to be cached at first composition (on a cold start the + * event streams in from relays a moment later). Returns null until the blob is on disk; the icon then + * appears on the next recomposition. All disk + network work runs off the composition thread. The blob is + * usually already cached (the browse/feed card prefetches every manifest blob, this one included); the + * on-demand fetch here just covers favorites whose card isn't currently on screen. + */ +@Composable +fun rememberNappletIconModel(coordinate: String): String? { + val context = LocalContext.current + + // checkGetOrCreate returns null only for a malformed coordinate, so this early return is stable for a + // given coordinate (it never flips across recompositions, which would break composition structure). + val note = remember(coordinate) { LocalCache.checkGetOrCreateAddressableNote(coordinate) } ?: return null + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + + // Key on the event itself, not the NoteState wrapper: re-resolve only when the manifest actually + // changes, not on every unrelated metadata bump (a reaction/zap tracked on the note). + val event = noteState.note.event + val icon = remember(event) { resolveIconBlob(event) } ?: return null + + var model by remember(icon.path.hash) { mutableStateOf(null) } + LaunchedEffect(icon.path.hash) { + withContext(Dispatchers.IO) { + val file = File(NappletBlobCache.dirFor(context.cacheDir), icon.path.hash.lowercase()) + if (!file.isFile) { + val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + runCatching { NappletBlobPrefetcher.prefetch(listOf(icon.path), icon.servers, context.cacheDir, torPort) } + } + if (file.isFile) model = "file://" + file.absolutePath + } + } + return model +} + +/** Asks each nsite/napplet event type for its bundled icon blob + the servers that hold it. */ +private fun resolveIconBlob(event: Event?): IconBlob? = + when (event) { + is RootNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) } + is NamedNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) } + is RootSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) } + is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) } + else -> null + } + +/** + * A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon + * has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the + * WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache. + * + * Early-returns null when [url] is blank or has no parseable host — this early return is stable + * for a given [url] (the host either always parses or never does), so composition structure is + * preserved across recompositions. + */ +@Composable +fun rememberWebAppIconModel(url: String): String? { + val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } +} + +/** + * A Coil model for the bundled icon of an napplet or nsite identified by [author] and + * [identifier]. Tries the napplet manifest (kinds 15129 / 35129) first, then falls back to the + * nsite manifest (kinds 15128 / 35128). Returns null until the blob lands on disk. + */ +@Composable +fun rememberManifestIconModel( + author: String, + identifier: String, +): String? { + val nappletCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier" + } + val nsiteCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier" + } + return rememberNappletIconModel(nappletCoord) ?: rememberNappletIconModel(nsiteCoord) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e3dea0f4d1..2a5f8b5d84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel @@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender @@ -55,6 +57,7 @@ import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayStat import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState import com.vitorpamplona.amethyst.model.marmot.KeyPackageRelayListState import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountMineRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState @@ -70,6 +73,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.PinListState @@ -101,6 +105,7 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState +import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState @@ -177,6 +182,8 @@ import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -221,8 +228,8 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -285,6 +292,8 @@ import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash +private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" + @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -387,8 +396,10 @@ class Account( val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) val interestSets = InterestSetsState(signer, cache, scope) + val appRecommendations = AppRecommendationsState(signer, cache, scope) val oldBookmarkState = OldBookmarkListState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) + val gitRepositoryListState = GitRepositoryListState(signer, cache, scope) val pinState = PinListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope) @@ -406,6 +417,7 @@ class Account( // Relay settings val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope) val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope) + val mineRelays = AccountMineRelayState(nip65RelayList, privateStorageRelayList, localRelayList, proxyRelayList, scope) val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope) val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope) @@ -417,6 +429,8 @@ class Account( scope = scope, assembler = cashuWalletFilterAssembler(), outboxRelaysFlow = outboxRelays.flow, + inboxRelaysFlow = notificationRelays.flow, + dmRelaysFlow = dmRelays.flow, settings = settings, okHttpClient = okHttpClientForMoney, ) @@ -496,6 +510,7 @@ class Account( followsRelays = defaultGlobalRelays.flow, blockedRelays = blockedRelayList.flow, proxyRelays = proxyRelayList.flow, + mineRelays = mineRelays.flow, relayFeeds = relayFeedsList.flow, caches = feedDecryptionCaches, signer = signer, @@ -524,6 +539,18 @@ class Account( val livePicturesFollowLists: StateFlow = topNavFilterFlow(settings.defaultPicturesFollowList) val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow + val liveNappletsFollowLists: StateFlow = topNavFilterFlow(settings.defaultNappletsFollowList) + val liveNappletsFollowListsPerRelay = OutboxLoaderState(liveNappletsFollowLists, cache, scope).flow + + val liveNsitesFollowLists: StateFlow = topNavFilterFlow(settings.defaultNsitesFollowList) + val liveNsitesFollowListsPerRelay = OutboxLoaderState(liveNsitesFollowLists, cache, scope).flow + + val liveWorkoutsFollowLists: StateFlow = topNavFilterFlow(settings.defaultWorkoutsFollowList) + val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow + + val liveGitRepositoriesFollowLists: StateFlow = topNavFilterFlow(settings.defaultGitRepositoriesFollowList) + val liveGitRepositoriesFollowListsPerRelay = OutboxLoaderState(liveGitRepositoriesFollowLists, cache, scope).flow + val liveCalendarsFollowLists: StateFlow = topNavFilterFlow(settings.defaultCalendarsFollowList) val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow @@ -575,6 +602,11 @@ class Account( val liveFollowPacksFollowLists: StateFlow = topNavFilterFlow(settings.defaultFollowPacksFollowList) val liveFollowPacksFollowListsPerRelay = OutboxLoaderState(liveFollowPacksFollowLists, cache, scope).flow + // App recommendations are read straight from LocalCache (no relay feed of its + // own), so only the in-memory author/tag matcher is needed here, not a + // per-relay outbox loader. + val liveAppRecommendationsFollowLists: StateFlow = topNavFilterFlow(settings.defaultAppRecommendationsFollowList) + override fun isWriteable(): Boolean = settings.isWriteable() suspend fun updateWarnReports(warnReports: Boolean): Boolean { @@ -651,6 +683,17 @@ class Account( } } + suspend fun changeAudioVisualizer(style: VisualizerStyle) { + if (settings.changeAudioVisualizer(style)) { + sendNewAppSpecificData() + } + } + + suspend fun toggleChatroomPin(room: ChatroomKey) { + settings.toggleChatroomPin(room) + sendNewAppSpecificData() + } + suspend fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, @@ -725,8 +768,10 @@ class Account( val eventHint = note.toEventHint() ?: return null - // For NIP-17 private groups, we don't support tracked mode (too complex) - if (eventHint.event is NIP17Group) return null + // For NIP-17 private groups, we don't support tracked mode (too complex). + // Unsealed rumors (empty sig) must never get a public reaction — + // the e-tag would leak the private rumor id to public relays. + if (eventHint.event is NIP17Group || eventHint.event.sig.isEmpty()) return null val event = ReactionAction.reactTo(eventHint, reaction, signer) val relays = computeRelayListToBroadcast(event) @@ -873,6 +918,13 @@ class Account( return zapRequest } + private fun onchainBackendNotConfigured() = + OnchainZapSendResult.Failure( + OnchainZapSendStage.LOADING_UTXOS, + OnchainZapSendError.BACKEND_NOT_CONFIGURED, + ONCHAIN_BACKEND_NOT_CONFIGURED, + ) + /** * Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's * derived Taproot address, sign it, broadcast it, and publish the kind:8333 @@ -888,10 +940,7 @@ class Account( ): OnchainZapSendResult { val backend = cache.onchainBackend - ?: return OnchainZapSendResult.Failure( - OnchainZapSendStage.LOADING_UTXOS, - "Bitcoin chain backend is not configured", - ) + ?: return onchainBackendNotConfigured() return OnchainZapSender.send( backend = backend, signer = signer, @@ -904,6 +953,29 @@ class Account( ) { template -> signAndComputeBroadcast(template) } } + /** + * Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin` + * payment target) from the NIP-BC Taproot wallet. A plain wallet send — + * no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress]. + */ + suspend fun sendOnchainToAddress( + recipientAddress: String, + amountSats: Long, + feeRateSatPerVByte: Double, + ): OnchainZapSendResult { + val backend = + cache.onchainBackend + ?: return onchainBackendNotConfigured() + return OnchainZapSender.sendToAddress( + backend = backend, + signer = signer, + senderPubKey = signer.pubKey, + recipientAddress = recipientAddress, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + ) + } + /** * Send a NIP-BC onchain split zap: a single Bitcoin transaction paying * each recipient their precomputed share, plus one kind:8333 receipt per @@ -917,10 +989,7 @@ class Account( ): OnchainZapSendResult { val backend = cache.onchainBackend - ?: return OnchainZapSendResult.Failure( - OnchainZapSendStage.LOADING_UTXOS, - "Bitcoin chain backend is not configured", - ) + ?: return onchainBackendNotConfigured() return OnchainZapSender.sendSplit( backend = backend, signer = signer, @@ -936,7 +1005,15 @@ class Account( note: Note, type: ReportType, content: String = "", - ) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + ) { + if (note.isPrivateRumor()) { + // A kind-1984 e-tagging the rumor would leak the private id onto + // public relays. Report the author instead (p-tag only). + note.author?.let { report(it, type, content) } + } else { + sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + } + } suspend fun report( user: User, @@ -966,6 +1043,28 @@ class Account( } } + /** + * Retracts rumor-only events (private reactions/replies) with a + * gift-wrapped NIP-09 deletion delivered to the same participants as + * the [target] rumor they referenced. A public deletion would e-tag + * the private rumor ids onto public relays. + */ + suspend fun deletePrivately( + notes: List, + target: Note, + ) { + if (!isWriteable()) return + val targetEvent = target.event ?: return + + val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event } + if (myRumors.isEmpty()) return + + val recipients = (targetEvent.taggedUserIds() + targetEvent.pubKey).distinct().minus(signer.pubKey) + broadcastPrivately( + NIP17Factory().createDeletionNIP17(DeletionEvent.build(myRumors), recipients, signer), + ) + } + suspend fun delete( event: Event, additionalRelays: Set, @@ -1166,7 +1265,9 @@ class Account( emptySet() } } - if (event is WrappedEvent) { + // Seals, inner DM messages, and unsigned rumors never get broadcast + // relays: they only travel inside gift wraps. + if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) { return emptySet() } @@ -1289,26 +1390,39 @@ class Account( suspend fun broadcast(note: Note) { note.event?.let { noteEvent -> - if (noteEvent is WrappedEvent && noteEvent.host != null) { - // download the event and send it. - noteEvent.host?.let { host -> - client - .fetchFirst( - filters = - note.relays.associateWith { _ -> - listOf( - Filter( - kinds = listOf(host.kind), - tags = mapOf("p" to listOf(pubKey)), - ids = listOf(host.id), - ), - ) - }, - )?.let { downloadedEvent -> - val toRelays = computeRelayListToBroadcast(downloadedEvent) - client.publish(downloadedEvent, toRelays) - } - } + val host = note.rumorHost + if (host != null) { + // Rumors are rebroadcast as their delivering envelope: the + // cached copy is content-stripped, so download it and send it. + // A just-sent note has no relays until its self-wrap echoes + // back — fall back to our own DM inbox relays. Bare seals + // (kind 13) carry no p tag, so that filter is wrap-only. + val relays = note.relays.ifEmpty { dmRelays.flow.value.toList() } + val filter = + if (host.kind == SealedRumorEvent.KIND) { + Filter( + kinds = listOf(host.kind), + ids = listOf(host.id), + ) + } else { + Filter( + kinds = listOf(host.kind), + tags = mapOf("p" to listOf(pubKey)), + ids = listOf(host.id), + ) + } + client + .fetchFirst( + filters = relays.associateWith { _ -> listOf(filter) }, + )?.let { downloadedEvent -> + val toRelays = computeRelayListToBroadcast(downloadedEvent) + client.publish(downloadedEvent, toRelays) + } + } else if (noteEvent.sig.isEmpty()) { + // Rumor with no known wrap: publishing it would disclose the + // private content to relays even though they reject the + // missing signature. + return } else { client.publish(noteEvent, computeRelayListToBroadcast(note)) } @@ -1939,6 +2053,14 @@ class Account( extraNotesToBroadcast.forEach { client.publish(it, relays) } } + /** + * The live [AddressableNote] backing a draft tag for this account. It is the same cached + * note that draft events are consumed into, so its `event` tracks the draft over time. The + * composer holds onto it (via DraftTagState) so [LocalCache]'s weak reference can't collect + * it before a deletion needs it, which would otherwise orphan the draft on the relays. + */ + fun getOrCreateDraftNote(draftTag: String): AddressableNote = cache.getOrCreateAddressableNote(DraftWrapEvent.createAddress(signer.pubKey, draftTag)) + suspend fun createAndSendDraftIgnoreErrors( draftTag: String, template: EventTemplate, @@ -1975,18 +2097,25 @@ class Account( } } - suspend fun deleteDraftIgnoreErrors(draftTag: String) { + suspend fun deleteDraftIgnoreErrors(draftNote: AddressableNote?) { try { - deleteDraftInner(draftTag) + deleteDraftInner(draftNote) } catch (e: Exception) { if (e is CancellationException) throw e } } - suspend fun deleteDraftInner(draftTag: String) { + suspend fun deleteDraftInner(draftNote: AddressableNote?) { if (!isWriteable()) return - val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList() + // Only a real, still-present draft needs a deletion signed. The note's event is null when + // no draft was ever saved (e.g. auto-drafts disabled) and already empty once it has been + // deleted — in both cases there is nothing to delete, so we avoid prompting the signer. + val draftEvent = draftNote?.event as? DraftWrapEvent + if (draftEvent == null || draftEvent.isDeleted()) return + + val draftTag = draftNote.dTag() + val extraRelays = draftNote.relays val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer) val deletionEvent = signer.sign(DeletionEvent.build(listOf(deletedDraft))) @@ -2238,6 +2367,18 @@ class Account( broadcastPrivately(events) } + /** + * Publishes a kind-1 note privately: signs the template, then gift-wraps + * the rumor to every p-tagged user plus a self-copy and sends each wrap + * to the recipient's DM relays. Used for private replies (the parent's + * author and participants are already p-tagged) and for private posts + * (the Notify list is the audience). Nothing reaches public relays. + */ + suspend fun sendPrivateNote(template: EventTemplate) { + if (!isWriteable()) return + broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer)) + } + override suspend fun sendGiftWraps(wraps: List) { wraps.forEach { wrap -> val relayList = computeRelayListToBroadcast(wrap) @@ -2874,6 +3015,16 @@ class Account( delete(note) } + suspend fun addGitRepositoryBookmark(note: AddressableNote) { + if (!isWriteable()) return + sendMyPublicAndPrivateOutbox(gitRepositoryListState.addRepository(note)) + } + + suspend fun removeGitRepositoryBookmark(note: AddressableNote) { + if (!isWriteable()) return + gitRepositoryListState.removeRepository(note)?.let { sendMyPublicAndPrivateOutbox(it) } + } + suspend fun addBookmark( note: Note, isPrivate: Boolean, @@ -3542,11 +3693,12 @@ class Account( if (isNew) { innerNote.event = innerEvent } - marmotGroupList.restoreMessage(groupId, innerNote) + marmotGroupList.addMessage(groupId, innerNote) } catch (e: Exception) { Log.w( "Account", - "Failed to restore persisted Marmot message for $groupId: ${e.message}", + "Failed to restore persisted Marmot message for $groupId", + e, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 92b0224654..ea25cf75ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -21,9 +21,14 @@ 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.commons.relayauth.RelayAuthPolicy 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 @@ -36,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent @@ -91,6 +97,16 @@ sealed class TopFilter( @Serializable object Global : TopFilter(" Global ") + /** + * Notifications-only curated mode: like [Global] it admits authors the + * user doesn't follow, but it also applies per-kind relevance heuristics + * to remove less interesting notes (reactions/reposts that don't target + * the user's own notes, unrelated thread replies, etc.). In Notifications, + * [Global] shows every event that p-tags the user instead. + */ + @Serializable + object Selected : TopFilter(" Selected ") + @Serializable object AllFollows : TopFilter(" All Follows ") @@ -170,10 +186,14 @@ class AccountSettings( val hideCommunityRulesViolations: MutableStateFlow = MutableStateFlow(false), val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), - val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Selected), val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPollsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPicturesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultNappletsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultNsitesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultWorkoutsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultGitRepositoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCalendarsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultProductsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AroundMe), val defaultShortsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -191,13 +211,18 @@ class AccountSettings( val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultAppRecommendationsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), - val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), + val clinkDebitWallets: MutableStateFlow> = 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 = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, val alwaysOnNotificationService: MutableStateFlow = MutableStateFlow(false), val splitNotificationsEnabled: MutableStateFlow = MutableStateFlow(false), + val showMessagesInNotifications: MutableStateFlow = MutableStateFlow(true), var backupUserMetadata: MetadataEvent? = null, var backupContactList: ContactListEvent? = null, var backupDMRelayList: ChatMessageRelayListEvent? = null, @@ -243,6 +268,7 @@ class AccountSettings( var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, val callsEnabled: MutableStateFlow = MutableStateFlow(true), + val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST), ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -276,6 +302,13 @@ class AccountSettings( return newValue } + fun toggleShowMessagesInNotifications(): Boolean { + val newValue = !showMessagesInNotifications.value + showMessagesInNotifications.tryEmit(newValue) + saveAccountSettings() + return newValue + } + // --- // Zaps and Reactions // --- @@ -325,14 +358,26 @@ class AccountSettings( return false } - fun defaultNwcWallet(): NwcWalletEntryNorm? { - val id = defaultNwcWalletId.value - val wallets = nwcWallets.value - return if (id != null) { - wallets.firstOrNull { it.id == id } - } else { - wallets.firstOrNull() + fun changeAudioVisualizer(style: VisualizerStyle): Boolean { + if (syncedSettings.media.audioVisualizer.value != style) { + syncedSettings.media.audioVisualizer.tryEmit(style) + saveAccountSettings() + return true } + 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 wallets = nwcWallets.value + return wallets.firstOrNull { it.id == defaultPaymentSourceId.value } ?: wallets.firstOrNull() } fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri @@ -343,8 +388,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() @@ -354,16 +401,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 } @@ -389,7 +488,7 @@ class AccountSettings( if (newServer == null) { if (nwcWallets.value.isNotEmpty()) { nwcWallets.tryEmit(emptyList()) - defaultNwcWalletId.tryEmit(null) + reassignDefaultIfMissing() saveAccountSettings() return true } @@ -546,6 +645,50 @@ class AccountSettings( } } + fun changeDefaultNappletsFollowList(name: FeedDefinition) { + changeDefaultNappletsFollowList(name.code) + } + + fun changeDefaultNappletsFollowList(name: TopFilter) { + if (defaultNappletsFollowList.value != name) { + defaultNappletsFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultNsitesFollowList(name: FeedDefinition) { + changeDefaultNsitesFollowList(name.code) + } + + fun changeDefaultNsitesFollowList(name: TopFilter) { + if (defaultNsitesFollowList.value != name) { + defaultNsitesFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultWorkoutsFollowList(name: FeedDefinition) { + changeDefaultWorkoutsFollowList(name.code) + } + + fun changeDefaultWorkoutsFollowList(name: TopFilter) { + if (defaultWorkoutsFollowList.value != name) { + defaultWorkoutsFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultGitRepositoriesFollowList(name: FeedDefinition) { + changeDefaultGitRepositoriesFollowList(name.code) + } + + fun changeDefaultGitRepositoriesFollowList(name: TopFilter) { + if (defaultGitRepositoriesFollowList.value != name) { + defaultGitRepositoriesFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultCalendarsFollowList(name: FeedDefinition) { changeDefaultCalendarsFollowList(name.code) } @@ -722,6 +865,17 @@ class AccountSettings( } } + fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) { + changeDefaultAppRecommendationsFollowList(name.code) + } + + fun changeDefaultAppRecommendationsFollowList(name: TopFilter) { + if (defaultAppRecommendationsFollowList.value != name) { + defaultAppRecommendationsFollowList.tryEmit(name) + saveAccountSettings() + } + } + // --- // language services // --- @@ -846,12 +1000,41 @@ class AccountSettings( fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) { if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return + // A mints-less kind:10019 is the "stop receiving nutzaps" tombstone + // (an empty replacement carrying only an `alt` tag). Don't restore it + // on next launch — backing it up would undo clearNutzapInfo() once the + // empty event round-trips back through LocalCache. + if (newNutzapInfo.mints().isEmpty()) { + clearNutzapInfo() + return + } if (backupNutzapInfo?.id != newNutzapInfo.id) { backupNutzapInfo = newNutzapInfo saveAccountSettings() } } + /** + * Drop the cached kind:17375 so a relaunch doesn't restore a wallet the + * user just deleted. Called when the wallet event is NIP-09 deleted — + * without this the [backupCashuWallet] would be re-consumed into + * LocalCache on next launch and resurrect the deleted wallet. + */ + fun clearCashuWallet() { + if (backupCashuWallet != null) { + backupCashuWallet = null + saveAccountSettings() + } + } + + /** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */ + fun clearNutzapInfo() { + if (backupNutzapInfo != null) { + backupNutzapInfo = null + saveAccountSettings() + } + } + /** * NUT-13 keyset counters live in [CashuPreferences], a dedicated * SharedPreferences file with synchronous (`commit = true`) writes. @@ -1128,6 +1311,17 @@ class AccountSettings( } } + // --- + // pinned chatrooms + // --- + + fun toggleChatroomPin(room: ChatroomKey) { + syncedSettings.chats.pinnedChatrooms.update { + if (room in it) it - room else it + room + } + saveAccountSettings() + } + // --- // viewed poll results // --- @@ -1284,6 +1478,13 @@ class AccountSettings( saveAccountSettings() } } + + fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) { + if (defaultRelayAuthPolicy.value != policy) { + defaultRelayAuthPolicy.tryEmit(policy) + saveAccountSettings() + } + } } @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index f10791a1d5..acb4dfd8ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -62,6 +64,14 @@ class AccountSyncedSettings( AccountVideoPlayerPreferences( MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()), ) + val media = + AccountMediaPreferences( + MutableStateFlow(VisualizerStyle.fromName(internalSettings.media.audioVisualizer)), + ) + val chats = + AccountChatPreferences( + MutableStateFlow(internalSettings.chats.toChatroomKeys()), + ) fun toInternal(): AccountSyncedSettingsInternal = AccountSyncedSettingsInternal( @@ -92,6 +102,8 @@ class AccountSyncedSettings( security.addClientTag.value, ), videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), + media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name), + chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -160,6 +172,16 @@ class AccountSyncedSettings( if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) { videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems) } + + val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer) + if (media.audioVisualizer.value != newAudioVisualizer) { + media.audioVisualizer.tryEmit(newAudioVisualizer) + } + + val newPinnedChatrooms = syncedSettingsInternal.chats.toChatroomKeys() + if (chats.pinnedChatrooms.value != newPinnedChatrooms) { + chats.pinnedChatrooms.tryEmit(newPinnedChatrooms) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -253,6 +275,18 @@ class AccountLanguagePreferences( ): String? = languagePreferences.value["$source,$target"] } +@Stable +class AccountMediaPreferences( + val audioVisualizer: MutableStateFlow, +) + +@Stable +class AccountChatPreferences( + val pinnedChatrooms: MutableStateFlow>, +) + +internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) } + @Stable class AccountSecurityPreferences( val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index bdc1ab134e..800f4528d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -155,6 +155,8 @@ class AccountSyncedSettingsInternal( val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(), val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(), val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(), + val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(), + val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(), ) @Serializable @@ -197,3 +199,17 @@ class AccountSecurityPreferencesInternal( var sendKind0EventsToLocalRelay: Boolean = false, var addClientTag: Boolean = true, ) + +@Serializable +class AccountMediaPreferencesInternal( + // Stored as VisualizerStyle.name; defaults to CLASSIC (the app's classic audio animation). + var audioVisualizer: String = "CLASSIC", +) + +@Serializable +class AccountChatPreferencesInternal( + // Rooms pinned to the top of the chat list. Each room is its member + // pubkeys (hex) sorted ascending, so the serialized form is deterministic + // regardless of set iteration order. + var pinnedRooms: List> = emptyList(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt new file mode 100644 index 0000000000..476a95563e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt @@ -0,0 +1,71 @@ +/* + * 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.model + +import com.vitorpamplona.amethyst.model.LocalCache.observeEvents +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * Cross-screen index of the most recent NIP-34 pull-request update event + * (kind 1619) per parent pull-request id, kept up to date from + * [LocalCache.observeEvents]. A PR update *revises* its parent PR with a + * newer commit / merge base, so the UI folds the latest one into the PR rather + * than listing updates separately. Like [GitStatusIndex], updates aren't tracked + * in `Note.replies`, so a per-row cache scan would otherwise be required. + * + * The kind-indexed [observeEvents] re-emits the whole matching list on every new + * 1619 (and seeds it from the cache index via `init()`), so [latestByPullRequest] + * is just that list reduced to the latest-per-parent map. Shared [SharingStarted.Eagerly] + * — never `WhileSubscribed` — because callers read `.value` synchronously and must + * not see a stale map when no one is actively collecting. `null` means "not loaded yet". + */ +object GitPullRequestUpdateIndex { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + val latestByPullRequest: StateFlow?> = + LocalCache + .observeEvents(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND))) + .map { latestByParent(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) + + private fun latestByParent(events: List): Map { + val latest = HashMap() + for (event in events) { + val target = event.parentPullRequestId() ?: continue + val current = latest[target] + if (current == null || event.createdAt > current.createdAt) { + latest[target] = event + } + } + return latest + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index bcc7b0fa09..a19dce396f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -20,66 +20,78 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.model.LocalCache.observeEvents import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Cross-screen index of the most recent NIP-34 status event (kinds * 1630-1633) per target id, kept up to date from - * [LocalCache.live.newEventBundles]. Status events are not tracked in + * [LocalCache.observeEvents]. Status events are not tracked in * `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to * find them otherwise would be a full cache scan per row. + * + * The kind-indexed [observeEvents] re-emits the whole matching list on every new + * status event (and seeds it from the cache index via `init()`), so [latestByTarget] + * is just that list reduced to the latest-per-target map. Shared [SharingStarted.Eagerly] + * — never `WhileSubscribed` — because callers (e.g. [isClosedOrResolved] and the feed + * filters) read `.value` synchronously and must not see a stale map when no one is + * actively collecting. `null` means "not loaded yet". */ object GitStatusIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val started = AtomicBoolean(false) - private val mutableLatestByTarget = MutableStateFlow?>(null) - val latestByTarget: StateFlow?> = mutableLatestByTarget.asStateFlow() + private val statusKinds = + listOf( + GitStatusEvent.KIND_OPEN, + GitStatusEvent.KIND_APPLIED, + GitStatusEvent.KIND_CLOSED, + GitStatusEvent.KIND_DRAFT, + ) - fun startIfNeeded() { - if (!started.compareAndSet(false, true)) return - scope.launch { - // Subscribe to bundle updates BEFORE the initial scan via onStart, so any - // events that arrive between scan start and collector attach are picked up. - LocalCache.live.newEventBundles - .onStart { - val initial = HashMap() - LocalCache.notes.forEach { _, note -> - val event = note.event as? GitStatusEvent ?: return@forEach - val target = event.rootEventId() ?: return@forEach - val current = initial[target] - if (current == null || event.createdAt > current.createdAt) { - initial[target] = event - } - } - mutableLatestByTarget.value = initial - }.collect { bundle -> processBundle(bundle) } - } - } + val latestByTarget: StateFlow?> = + LocalCache + .observeEvents(Filter(kinds = statusKinds)) + .map { reduceLatestByTarget(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) - private fun processBundle(bundle: Set) { - val snapshot = mutableLatestByTarget.value ?: emptyMap() - var modified: HashMap? = null - for (note in bundle) { - val event = note.event as? GitStatusEvent ?: continue + private fun reduceLatestByTarget(events: List): Map { + val latest = HashMap() + for (event in events) { val target = event.rootEventId() ?: continue - val map = modified ?: snapshot - val current = map[target] + val current = latest[target] if (current == null || event.createdAt > current.createdAt) { - if (modified == null) modified = HashMap(snapshot) - modified[target] = event + latest[target] = event } } - modified?.let { mutableLatestByTarget.value = it } + return latest + } + + /** + * Whether the latest status for [targetId] marks it as closed (kind 1632) + * or applied/resolved/merged (kind 1631). Items with no status event, or + * whose latest status is open (1630) or draft (1633), are considered open. + * + * Reads from the synchronous snapshot in [latestByTarget]; pass an explicit + * [map] to avoid re-reading the value across a batch. + */ + fun isClosedOrResolved( + targetId: HexKey, + map: Map? = latestByTarget.value, + ): Boolean { + val event = map?.get(targetId) ?: return false + return event is GitStatusClosedEvent || event is GitStatusAppliedEvent } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index b316174452..fc6a9d14e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -59,6 +59,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent @@ -74,6 +76,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -105,6 +109,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent @@ -115,12 +120,14 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -145,6 +152,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -209,11 +217,12 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -250,6 +259,7 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @@ -784,6 +794,13 @@ object LocalCache : ILocalCache, ICacheProvider { // Counts the replies replyTo.forEach { it.addReply(note) } + // NIP-18 quote reposts: a note carrying a `q` tag is a quote-repost of the + // quoted note. Count it as a boost so it shows in the quoted note's repost + // counter alongside kind:6/kind:16 reposts. The quoted note is deliberately + // kept out of `replyTo` so the quote still renders as a root post in the home + // feed (see Note.isNewThread); deletion cleanup lives in unlinkAndRemove. + addQuoteBoosts(event, note, replyTo) + refreshNewNoteObservers(note) true @@ -792,6 +809,25 @@ object LocalCache : ILocalCache, ICacheProvider { } } + /** + * Adds [note] as a boost of every event/address referenced by a NIP-18 `q` tag + * (a quote-repost). Targets already in [replyTo] are skipped so a note that both + * replies to and quotes the same note isn't counted twice, and self-quotes are + * ignored. + */ + private fun addQuoteBoosts( + event: Event, + note: Note, + replyTo: List, + ) { + event.taggedQuoteIds().forEach { quotedId -> + val quoted = checkGetOrCreateNote(quotedId) + if (quoted != null && quoted != note && quoted !in replyTo) { + quoted.addBoost(note) + } + } + } + fun consume( event: NipTextEvent, relay: NormalizedRelayUrl?, @@ -1352,30 +1388,39 @@ object LocalCache : ILocalCache, ICacheProvider { * resurrected by `computeReplyTo` as a second Note for the same id. * - prune (see [unlinkAndRemove] callers): the whole child subtree is removed. * - * Gift-wrapped events additionally drop their decrypted inner host. + * Rumors additionally drop the envelope notes that delivered them. */ private fun deleteNote(deleteNote: Note) { - (deleteNote.event as? WrappedEvent)?.let { deleteWraps(it) } + deleteEnvelopes(deleteNote) deleteNote.detachFromChildren() unlinkAndRemove(deleteNote) } - fun deleteWraps(event: WrappedEvent) { - event.host?.let { hostStub -> - // seal - getNoteIfExists(hostStub.id)?.let { hostNote -> - val noteEvent = hostNote.event - if (noteEvent is WrappedEvent) { - deleteWraps(noteEvent) - } - hostNote.clearFlow() - refreshDeletedNoteObservers(hostNote) - } + /** + * Removes the envelope notes that delivered [rumorNote]'s rumor: its + * host (normally the kind-1059 wrap; a bare kind-13 seal otherwise) + * and, when the host is a wrap, the seal layer it carried. Public + * events have no envelopes and are ignored. + */ + fun deleteEnvelopes(rumorNote: Note) { + val host = rumorNote.rumorHost ?: return - notes.remove(hostStub.id) + getNoteIfExists(host.id)?.let { hostNote -> + (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> + getNoteIfExists(sealId)?.let { sealNote -> + sealNote.clearFlow() + refreshDeletedNoteObservers(sealNote) + } + notes.remove(sealId) + } + hostNote.clearFlow() + refreshDeletedNoteObservers(hostNote) } + + notes.remove(host.id) + rumorNote.rumorHost = null } fun consume( @@ -2236,20 +2281,18 @@ object LocalCache : ILocalCache, ICacheProvider { // the wallet service we sent the request to. The pending // entry is left in place so the real response can still // resolve it; we silently drop this one. - Log.w( - "LocalCache", + Log.w("LocalCache") { "Rejecting NWC response ${event.id}: expected author ${match.expected} but event was signed by ${match.actual}. " + - "This may be a spoofed reply — keeping the request pending for the legitimate wallet response.", - ) + "This may be a spoofed reply — keeping the request pending for the legitimate wallet response." + } return false } NwcPaymentTracker.MatchResult.NoMatch -> { - Log.w( - "LocalCache", + Log.w("LocalCache") { "NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " + - "The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag.", - ) + "The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag." + } return false } } @@ -2352,6 +2395,21 @@ object LocalCache : ILocalCache, ICacheProvider { note.event is AppSpecificDataEvent ) + /** + * Tag names whose values should not match text searches: the `client` tag + * names the app that published the event (searching for "Amethyst" would + * otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt` + * values are ids or descriptions of other events, not content of this one. + */ + private val excludedTagNamesFromSearch = + setOf( + ClientTag.TAG_NAME, + PTag.TAG_NAME, + ETag.TAG_NAME, + ATag.TAG_NAME, + AltTag.TAG_NAME, + ) + fun findNotesStartingWith( text: String, hiddenUsers: HiddenUsersState, @@ -2391,7 +2449,7 @@ object LocalCache : ILocalCache, ICacheProvider { return@filter false } - if (note.event?.tags?.tagValueContains(text, true) == true || + if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true || note.idHex.startsWith(text, true) ) { return@filter !note.isHiddenFor(hiddenUsers.flow.value) @@ -2412,7 +2470,7 @@ object LocalCache : ILocalCache, ICacheProvider { return@filter false } - if (addressable.event?.tags?.tagValueContains(text, true) == true || + if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true || addressable.idHex.startsWith(text, true) ) { return@filter !addressable.isHiddenFor(hiddenUsers.flow.value) @@ -2645,20 +2703,67 @@ object LocalCache : ILocalCache, ICacheProvider { } chatroomList.forEach { userHex, room -> + // History floors are pinned per scope on first advance; null means that window never paged + // history, so its cursors hold no position to misalign and nothing needs rewinding. Only the + // bands strictly BELOW a floor are this window's responsibility — a pruned message newer than + // the floor is the always-on live tail's concern, and rewinding history for it would needlessly + // re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor + // filter when accumulating below. + val giftWrapFloor = room.giftWrapHistory.floor + val accountNip04Floor = room.nip04History.floor + room.rooms.map { key, chatroom -> val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() - toBeRemoved.forEach { - childrenToBeRemoved.addAll(removeIfWrap(it)) - unlinkAndRemove(it) + // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. + // Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's + // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. + val giftWrapPruned = HashMap() + val accountNip04Pruned = HashMap() + val roomNip04Pruned = HashMap() + // chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a + // kind:4 message, so rooms that never paged conversation history pay nothing. + val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null - childrenToBeRemoved.addAll(it.clearChildLinks()) + toBeRemoved.forEach { note -> + when (val ev = note.event) { + is BaseDMGroupEvent -> + if (giftWrapFloor != null) { + val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt + if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } + } + is PrivateDmEvent -> { + val until = ev.createdAt + if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) } + if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) } + } + } + + childrenToBeRemoved.addAll(removeIfWrap(note)) + unlinkAndRemove(note) + + childrenToBeRemoved.addAll(note.clearChildLinks()) } unlinkAndRemove(childrenToBeRemoved) + // Realign the windows so a relay that already paged past (or `done` below) the dropped band + // re-requests it on the next demand-advance instead of skipping the hole. + if (giftWrapPruned.isNotEmpty()) { + room.giftWrapHistory.rewindTo(giftWrapPruned) + Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" } + } + if (accountNip04Pruned.isNotEmpty()) { + room.nip04History.rewindTo(accountNip04Pruned) + Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" } + } + if (roomNip04Pruned.isNotEmpty()) { + chatroom.nip04History.rewindTo(roomNip04Pruned) + Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" } + } + if (toBeRemoved.size > 1) { println( "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", @@ -2669,21 +2774,21 @@ object LocalCache : ILocalCache, ICacheProvider { } fun removeIfWrap(note: Note): List { - val noteEvent = note.event + val host = note.rumorHost ?: return emptyList() - val children = - if (noteEvent is WrappedEvent) { - noteEvent.host?.id?.let { - getNoteIfExists(it)?.let { it2 -> - unlinkAndRemove(it2) - it2.clearChildLinks() - } + val children = mutableListOf() + getNoteIfExists(host.id)?.let { hostNote -> + (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> + getNoteIfExists(sealId)?.let { sealNote -> + unlinkAndRemove(sealNote) + children.addAll(sealNote.clearChildLinks()) } - } else { - null } - - return children ?: emptyList() + unlinkAndRemove(hostNote) + children.addAll(hostNote.clearChildLinks()) + } + note.rumorHost = null + return children } fun prunePastVersionsOfReplaceables() { @@ -2787,6 +2892,12 @@ object LocalCache : ILocalCache, ICacheProvider { val noteEvent = note.event + // Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so + // detach this note from every quoted note's boosts here. + noteEvent?.taggedQuoteIds()?.forEach { quotedId -> + getNoteIfExists(quotedId)?.removeBoost(note) + } + if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().forEach { getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note) @@ -3516,6 +3627,14 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is RootNappletEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is NamedNappletEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is ChessGameEvent -> { consumeRegularEvent(event, relay, wasVerified) } @@ -3764,6 +3883,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is RoadEventReportEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is RoadEventConfirmationEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is RelayDiscoveryEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } @@ -3884,6 +4011,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is WorkoutRecordEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is ExerciseTemplateEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is PaymentTargetsEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index fc453f5c29..e986f5f778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems -import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries import kotlinx.serialization.Serializable @Stable @@ -44,7 +44,7 @@ data class UiSettings( val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS, val useTrackedBroadcasts: BooleanType = BooleanType.ALWAYS, val automaticallyCreateDrafts: BooleanType = BooleanType.ALWAYS, - val bottomBarItems: List = DefaultBottomBarItems, + val bottomBarItems: List = DefaultBottomBarEntries, val showHomeNewThreadsTab: Boolean = true, val showHomeConversationsTab: Boolean = true, val showHomeEverythingTab: Boolean = false, @@ -53,6 +53,10 @@ data class UiSettings( val showProfileZapReceivedFeed: Boolean = true, val showProfileFollowersFeed: Boolean = true, val dontShowOnchainPublicWarning: Boolean = false, + val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS, + val accentColor: AccentColorType = AccentColorType.PURPLE, + val fontFamily: FontFamilyType = FontFamilyType.SYSTEM, + val fontSize: FontSizeType = FontSizeType.NORMAL, ) enum class ThemeType( @@ -72,6 +76,68 @@ fun parseThemeType(code: Int?): ThemeType = else -> ThemeType.SYSTEM } +enum class AccentColorType( + val screenCode: Int, + val resourceId: Int, +) { + PURPLE(0, R.string.accent_color_purple), + BLUE(1, R.string.accent_color_blue), + GREEN(2, R.string.accent_color_green), + ORANGE(3, R.string.accent_color_orange), + RED(4, R.string.accent_color_red), + PINK(5, R.string.accent_color_pink), +} + +fun parseAccentColorType(screenCode: Int): AccentColorType = + when (screenCode) { + AccentColorType.PURPLE.screenCode -> AccentColorType.PURPLE + AccentColorType.BLUE.screenCode -> AccentColorType.BLUE + AccentColorType.GREEN.screenCode -> AccentColorType.GREEN + AccentColorType.ORANGE.screenCode -> AccentColorType.ORANGE + AccentColorType.RED.screenCode -> AccentColorType.RED + AccentColorType.PINK.screenCode -> AccentColorType.PINK + else -> AccentColorType.PURPLE + } + +enum class FontFamilyType( + val screenCode: Int, + val resourceId: Int, +) { + SYSTEM(0, R.string.font_family_system), + SANS_SERIF(1, R.string.font_family_sans_serif), + SERIF(2, R.string.font_family_serif), + MONOSPACE(3, R.string.font_family_monospace), +} + +fun parseFontFamilyType(screenCode: Int): FontFamilyType = + when (screenCode) { + FontFamilyType.SYSTEM.screenCode -> FontFamilyType.SYSTEM + FontFamilyType.SANS_SERIF.screenCode -> FontFamilyType.SANS_SERIF + FontFamilyType.SERIF.screenCode -> FontFamilyType.SERIF + FontFamilyType.MONOSPACE.screenCode -> FontFamilyType.MONOSPACE + else -> FontFamilyType.SYSTEM + } + +enum class FontSizeType( + val scale: Float, + val screenCode: Int, + val resourceId: Int, +) { + SMALL(0.85f, 0, R.string.font_size_small), + NORMAL(1.0f, 1, R.string.font_size_normal), + LARGE(1.15f, 2, R.string.font_size_large), + HUGE(1.3f, 3, R.string.font_size_huge), +} + +fun parseFontSizeType(screenCode: Int): FontSizeType = + when (screenCode) { + FontSizeType.SMALL.screenCode -> FontSizeType.SMALL + FontSizeType.NORMAL.screenCode -> FontSizeType.NORMAL + FontSizeType.LARGE.screenCode -> FontSizeType.LARGE + FontSizeType.HUGE.screenCode -> FontSizeType.HUGE + else -> FontSizeType.NORMAL + } + enum class ConnectivityType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 146a6a8fc6..d86fc45a51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems -import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -44,7 +44,7 @@ class UiSettingsFlow( val automaticallyProposeAiImprovements: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val useTrackedBroadcasts: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val automaticallyCreateDrafts: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), - val bottomBarItems: MutableStateFlow> = MutableStateFlow(DefaultBottomBarItems), + val bottomBarItems: MutableStateFlow> = MutableStateFlow(DefaultBottomBarEntries), val showHomeNewThreadsTab: MutableStateFlow = MutableStateFlow(true), val showHomeConversationsTab: MutableStateFlow = MutableStateFlow(true), val showHomeEverythingTab: MutableStateFlow = MutableStateFlow(false), @@ -53,6 +53,10 @@ class UiSettingsFlow( val showProfileZapReceivedFeed: MutableStateFlow = MutableStateFlow(true), val showProfileFollowersFeed: MutableStateFlow = MutableStateFlow(true), val dontShowOnchainPublicWarning: MutableStateFlow = MutableStateFlow(false), + val suggestWorkoutsFromHealthConnect: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), + val accentColor: MutableStateFlow = MutableStateFlow(AccentColorType.PURPLE), + val fontFamily: MutableStateFlow = MutableStateFlow(FontFamilyType.SYSTEM), + val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), ) { val listOfFlows: List> = listOf>( @@ -80,6 +84,10 @@ class UiSettingsFlow( showProfileZapReceivedFeed, showProfileFollowersFeed, dontShowOnchainPublicWarning, + suggestWorkoutsFromHealthConnect, + accentColor, + fontFamily, + fontSize, ) // emits at every change in any of the propertyes. @@ -102,7 +110,7 @@ class UiSettingsFlow( flows[12] as BooleanType, flows[13] as BooleanType, flows[14] as BooleanType, - flows[15] as List, + flows[15] as List, flows[16] as Boolean, flows[17] as Boolean, flows[18] as Boolean, @@ -111,6 +119,10 @@ class UiSettingsFlow( flows[21] as Boolean, flows[22] as Boolean, flows[23] as Boolean, + flows[24] as BooleanType, + flows[25] as AccentColorType, + flows[26] as FontFamilyType, + flows[27] as FontSizeType, ) } @@ -140,6 +152,10 @@ class UiSettingsFlow( showProfileZapReceivedFeed.value, showProfileFollowersFeed.value, dontShowOnchainPublicWarning.value, + suggestWorkoutsFromHealthConnect.value, + accentColor.value, + fontFamily.value, + fontSize.value, ) fun update(torSettings: UiSettings): Boolean { @@ -241,6 +257,22 @@ class UiSettingsFlow( dontShowOnchainPublicWarning.tryEmit(torSettings.dontShowOnchainPublicWarning) any = true } + if (suggestWorkoutsFromHealthConnect.value != torSettings.suggestWorkoutsFromHealthConnect) { + suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect) + any = true + } + if (accentColor.value != torSettings.accentColor) { + accentColor.tryEmit(torSettings.accentColor) + any = true + } + if (fontFamily.value != torSettings.fontFamily) { + fontFamily.tryEmit(torSettings.fontFamily) + any = true + } + if (fontSize.value != torSettings.fontSize) { + fontSize.tryEmit(torSettings.fontSize) + any = true + } return any } @@ -290,6 +322,10 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.showProfileZapReceivedFeed), MutableStateFlow(uiSettings.showProfileFollowersFeed), MutableStateFlow(uiSettings.dontShowOnchainPublicWarning), + MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect), + MutableStateFlow(uiSettings.accentColor), + MutableStateFlow(uiSettings.fontFamily), + MutableStateFlow(uiSettings.fontSize), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 2544c32776..247040f5a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.preview.UrlPreview -import com.vitorpamplona.amethyst.ui.components.UrlPreviewState +import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import okhttp3.OkHttpClient @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 8a84e0dbbc..d307f3a07b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -108,6 +108,39 @@ class AccountCacheState( } } + /** The on-disk root that [loadAccount] creates a per-account directory under. */ + private fun accountsRootDir() = File(rootFilesDir(), "accounts") + + /** + * Deletes the on-disk per-account directory (the MLS/Marmot stores created in + * [loadAccount]). Call only on permanent account deletion — [removeAccount] just + * drops the in-memory copy and leaves these files behind. + */ + fun deleteAccountFiles(pubkey: HexKey) { + val dir = File(accountsRootDir(), pubkey) + if (dir.exists() && !dir.deleteRecursively()) { + Log.w("AccountCacheState", "Failed to delete account directory ${dir.absolutePath}") + } + } + + /** + * Removes per-account directories left behind by accounts that are no longer saved + * (e.g. deleted before [deleteAccountFiles] existed). Keeps only [keepPubkeys]. Safe to + * run alongside [loadAccount]: it only loads saved accounts, whose pubkeys are kept. + */ + fun pruneOrphanAccountDirs(keepPubkeys: Set) { + val children = accountsRootDir().listFiles() ?: return + children.forEach { child -> + if (child.isDirectory && child.name !in keepPubkeys) { + if (child.deleteRecursively()) { + Log.d("AccountCacheState") { "Pruned orphan account dir ${child.name.take(8)}…" } + } else { + Log.w("AccountCacheState", "Failed to prune orphan account dir ${child.absolutePath}") + } + } + } + } + fun loadAccount(accountSettings: AccountSettings): Account = loadAccount( signer = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt index 01d9ee9ccf..829126eb67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -78,6 +78,10 @@ class AndroidMarmotMessageStore( writeMutex.withLock { try { val existing = readAll(nostrGroupId).toMutableList() + if (innerEventJson in existing) { + Log.d(TAG) { "appendMessage($nostrGroupId): duplicate entry skipped" } + return@withLock + } existing.add(innerEventJson) writeAll(nostrGroupId, existing) Log.d(TAG) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountMineRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountMineRelayState.kt new file mode 100644 index 0000000000..7205249d3b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountMineRelayState.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip01UserMetadata + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn + +/** + * The set of relays to read the user's *own* events from for the "Mine" top-nav selection: + * the user's NIP-65 outbox, their private-storage relays, their local relays and their proxy + * relays. Deliberately mirrors [AccountOutboxRelayState] **minus broadcast**: broadcast relays are + * write-only blast targets, so reading the user's own content back from them is wrong — they don't + * serve reads and would only waste a subscription. + */ +class AccountMineRelayState( + nip65: Nip65RelayListState, + privateStorage: PrivateStorageRelayListState, + local: LocalRelayListState, + proxy: ProxyRelayListState, + scope: CoroutineScope, +) { + val flow = + combine( + nip65.outboxFlow, + privateStorage.flow, + local.flow, + proxy.flow, + ) { nip65Outbox, privateOutBox, localRelays, proxyRelays -> + nip65Outbox + privateOutBox + localRelays + proxyRelays + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + nip65.outboxFlow.value + + privateStorage.flow.value + + local.flow.value + + proxy.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt index d3179dd07f..3bacb99eb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt index ed87c5deb0..0e69c58954 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt @@ -36,6 +36,12 @@ class Nip11CachedRetriever( private val relayInformationDocumentCache = LruCache(1000) private val retriever = Nip11Retriever(okHttpClient) + fun trimToSize(maxItems: Int) { + relayInformationDocumentCache.trimToSize(maxItems) + // relayInformationEmptyCache holds only lightweight display-name+favicon-url placeholders; + // trimming it saves negligible memory but forces redundant NIP-11 HTTP fetches on resume. + } + fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation { relayInformationEmptyCache.get(relay)?.let { return it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt index 41ce3a3bf9..10118b09ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt @@ -47,14 +47,21 @@ class Nip11Retriever( onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit, ) = withContext(Dispatchers.IO) { val url = relay.toHttp() - try { - val request: Request = + val request = + try { Request .Builder() .header("Accept", "application/nostr+json") .url(url) .build() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) + onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + return@withContext + } + try { val client = okHttpClient(relay) client.newCall(request).executeAsync().use { response -> @@ -81,8 +88,8 @@ class Nip11Retriever( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) - onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + Log.e("RelayInfoFail", "Failed to fetch NIP-11 from ${relay.url}", e) + onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message ?: e::class.simpleName) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index d7abdecf83..8b06eee55b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -65,12 +65,10 @@ class NwcSignerState( * Flow of the default wallet's NWC URI, derived from multi-wallet settings. */ val defaultWalletUri: StateFlow = - 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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index fb13e4d527..42ef3e2ae1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.amethyst.model.nip51Lists typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState + +typealias GitRepositoryListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt index 65a5c80c45..87d197065b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -68,7 +68,8 @@ class TrustedRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + // Synchronously seed public tags from the backup; private tags may be absent on first boot. + settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(), ) suspend fun saveRelayList(trustedRelays: List): TrustedRelayListEvent { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index d380314df2..9ff58c3534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -20,6 +20,14 @@ */ package com.vitorpamplona.amethyst.model.nip60Cashu +import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader +import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps +import com.vitorpamplona.amethyst.commons.cashu.ops.MeltCompleted +import com.vitorpamplona.amethyst.commons.cashu.ops.NutzapSent +import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome +import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState import com.vitorpamplona.amethyst.model.AccountSettings @@ -35,7 +43,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto -import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -45,7 +52,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -55,6 +61,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -96,6 +103,8 @@ class CashuWalletState( private val scope: CoroutineScope, private val assembler: CashuWalletFilterAssembler, private val outboxRelaysFlow: StateFlow>, + private val inboxRelaysFlow: StateFlow>, + private val dmRelaysFlow: StateFlow>, private val settings: AccountSettings, okHttpClient: (String) -> OkHttpClient, ) { @@ -219,6 +228,39 @@ class CashuWalletState( }.flowOn(Dispatchers.Default) .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + /** + * Mints to surface in the wallet screen's per-mint list: the union of + * our configured mints (kind:17375 — listed even at zero balance so the + * user can top them up) and every mint we actually hold tokens at + * (token-derived, via [mintBalances]). The token-derived half is what + * keeps the per-mint rows summing to [balanceSats]: a balance + * auto-redeemed from a mint we never configured (e.g. a nutzap on a mint + * not in our kind:10019) contributes to the total, so without a row for + * it the displayed mint balances would silently under-count the wallet. + * Configured mints come first; extra token-only mints follow. + */ + val displayMints: StateFlow> = + combine(_mints, mintBalances) { configured, balances -> + (configured + balances.keys).distinct() + }.flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + /** + * Mints we hold a spendable balance at but never configured in our + * kind:17375 wallet — keyed by mint URL → balance in sats. Almost + * always coins auto-redeemed from a NIP-61 nutzap that was sent on a + * mint outside our kind:10019. Surfaced so the wallet can highlight + * them and nudge the user to move the funds to a mint they trust (or + * out to Lightning): holding ecash at an unvetted mint means trusting + * an issuer the user never chose. Empty in the common case where every + * mint we hold is also configured. + */ + val unconfiguredMintBalances: StateFlow> = + combine(_mints, mintBalances) { configured, balances -> + balances.filterKeys { it !in configured }.filterValues { it > 0 } + }.flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _history.asStateFlow() @@ -402,12 +444,31 @@ class CashuWalletState( triggerAutoRedeem() } - // Keep the relay subscription in sync with the outbox set. + // Keep the wallet subscription in sync with the relay sets it reads + // from. Following the NIP-65 outbox model, the two halves of the + // subscription read from different places: + // - our own NIP-60 events (wallet/token/history) are read back from + // our OUTBOX relays, where we published them; + // - inbound kind:9321 nutzaps are read from our INBOX set, since + // that is where other people deliver them. Per NIP-61 the source + // of truth for "where to send me nutzaps" is the `relay` tags in + // our own kind:10019 — and another client may have published that + // with relays unrelated to our NIP-65 lists — so we listen on the + // union of those plus our NIP-65 inbox + DM relays. jobs += scope.launch(Dispatchers.IO) { - outboxRelaysFlow.collect { relays -> - syncSubscription(relays) - } + combine( + outboxRelaysFlow, + inboxRelaysFlow, + dmRelaysFlow, + _nutzapInfoEvent, + ) { outbox, inbox, dm, info -> + CashuWalletQueryState( + pubkey = pubKey, + ownEventRelays = outbox, + inboxRelays = inbox + dm + (info?.relays() ?: emptyList()), + ) + }.collect { syncSubscription(it) } } // Reactive incremental update: any new event arrival that matches our @@ -470,17 +531,16 @@ class CashuWalletState( // ============================================================ // Subscription management // ============================================================ - private fun syncSubscription(relays: Set) { + private fun syncSubscription(next: CashuWalletQueryState) { val previous = currentSubscription - if (relays.isEmpty()) { + if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) { previous?.let { runCatching { assembler.unsubscribe(it) } } currentSubscription = null return } - if (previous != null && previous.relays == relays) return // unchanged + if (previous == next) return // unchanged previous?.let { runCatching { assembler.unsubscribe(it) } } - val next = CashuWalletQueryState(pubKey, relays) currentSubscription = next assembler.subscribe(next) } @@ -563,6 +623,11 @@ class CashuWalletState( // Any wallet event resolves the "discovering" state — whether it // came from cache backfill or a fresh relay delivery. _discovering.value = false + // The NUT-13 seed is derived from the wallet's P2PK key. A new + // kind:17375 may carry a rotated key (our own recreateNutzapKey, or + // a rotation from another client), so drop the cached seed and let + // ensureSeed re-derive from whatever key the live event now holds. + cachedSeed = null walletEventInternal?.let { evt -> _mints.value = runCatching { evt.mints(signer) } @@ -633,8 +698,16 @@ class CashuWalletState( if (dirtyWallet) { _walletEvent.value = null _mints.value = emptyList() + // The cached kind:17375 mirrors the live event; once it's deleted + // (our own teardown or an external NIP-09) drop the backup too, or + // the next launch would re-consume it from settings and resurrect + // the wallet. + settings.clearCashuWallet() + } + if (dirtyNutzapInfo) { + _nutzapInfoEvent.value = null + settings.clearNutzapInfo() } - if (dirtyNutzapInfo) _nutzapInfoEvent.value = null if (dirtyTokens) recomputeUnspent() if (dirtyHistory) _history.value = historyEvents.values.sortedByDescending { it.createdAt } if (dirtyQuotes || dirtyHistory) recomputePending() @@ -665,44 +738,13 @@ class CashuWalletState( } } - // Apply `del` rollover. - val deletedIds = mutableSetOf() - all.forEach { evt -> tokenContents[evt.id]?.del?.let(deletedIds::addAll) } - - val unspent = - all - .filter { it.id !in deletedIds && tokenContents.containsKey(it.id) } - .mapNotNull { evt -> tokenContents[evt.id]?.let { TokenEntry(evt, it) } } - .sortedByDescending { it.event.createdAt } - - _tokenEntries.value = unspent + // Shared del-rollover + sort with the headless reader. + _tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents) } private fun recomputePending() { - val now = TimeUtils.now() - // A quote is "pending" if (1) not expired, and (2) no kind:7376 history - // event references its id with a "destroyed" marker — completion of the - // mint flow deletes the kind:7374, and history records a `destroyed` - // reference to the now-fulfilled quote. - val destroyedQuoteIds = - historyEvents.values - .asSequence() - .flatMap { it.tags.asSequence() } - .filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" } - .map { it[1] } - .toSet() - - _pendingQuotes.value = - quoteEvents.values - .filter { it.id !in destroyedQuoteIds } - .filter { evt -> - val exp = - evt.tags - .firstOrNull { it.size >= 2 && it[0] == "expiration" } - ?.get(1) - ?.toLongOrNull() - exp == null || exp > now - }.sortedByDescending { it.createdAt } + // Shared destroyed/expired filter with the headless reader. + _pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values) } private fun scanCacheForOwnEvents(): List { @@ -765,6 +807,80 @@ class CashuWalletState( } } + // ============================================================ + // Stop receiving nutzaps / delete wallet + // ============================================================ + + /** + * Stop receiving NIP-61 nutzaps: replace kind:10019 with an empty version + * and NIP-09 delete it (see [CashuWalletOps.stopNutzaps]). Leaves the + * kind:17375 wallet and held proofs intact — the wallet keeps sending. + * + * Clears the local index + on-disk backup immediately so the change is + * effective without waiting for the kind:5 round-trip; the deletion bundle + * arriving later via [removeEvents] is then a no-op. + */ + suspend fun stopNutzaps() { + check(started) { NOT_STARTED_MESSAGE } + ops.stopNutzaps() + nutzapInfoEventInternal = null + _nutzapInfoEvent.value = null + settings.clearNutzapInfo() + } + + /** + * Delete the whole Cashu wallet: withdraws the nutzap advertisement and + * NIP-09 deletes the kind:17375 (see [CashuWalletOps.deleteWallet]). Held + * kind:7375 proofs are NOT deleted — that ecash still exists at the mint — + * but with the P2PK key gone any unredeemed inbound nutzaps and any + * remaining balance may become unrecoverable. The UI must warn first. + * + * No-op when no wallet is loaded. Clears local indexes + backups inline so + * the wallet screen drops straight to its empty/create state. + */ + suspend fun deleteWallet() { + check(started) { NOT_STARTED_MESSAGE } + val wallet = walletEventInternal ?: return + ops.deleteWallet(wallet) + + walletEventInternal = null + _walletEvent.value = null + _mints.value = emptyList() + nutzapInfoEventInternal = null + _nutzapInfoEvent.value = null + settings.clearCashuWallet() + settings.clearNutzapInfo() + } + + /** + * Rotate the wallet's NIP-61 P2PK key: re-publish kind:17375 + kind:10019 + * with a fresh (or supplied) key, keeping the current mint list. After + * this, senders lock nutzaps to the NEW key — any inbound nutzap still + * locked to the OLD key that hasn't been redeemed yet becomes + * unrecoverable. Rarely needed (key exposure, or restoring a specific key + * from a backup), which is why it lives behind the settings Danger Zone. + * + * [manualPrivkeyHex] null/blank → generate a fresh random key; otherwise + * adopt the supplied hex key (e.g. importing a backup). + */ + suspend fun recreateNutzapKey(manualPrivkeyHex: String? = null) { + check(started) { NOT_STARTED_MESSAGE } + val currentMints = _mints.value + require(currentMints.isNotEmpty()) { "Wallet has no mints to re-publish" } + ops.publishWalletEvents( + mints = currentMints, + p2pkPrivkeyHex = manualPrivkeyHex?.takeIf { it.isNotBlank() }, + // Advertise our NIP-65 inbox relays as the nutzap relays (NIP-65 + // outbox model): senders publish kind:9321 where we read inbound + // events. We listen on a wider set (inbox + DM + these tags), but + // the kind:10019 default copies the inbox relay list, not outbox. + nutzapRelays = inboxRelaysFlow.value.toList(), + ) + // The NUT-13 seed (derived from the P2PK key) is invalidated by + // applyEvents when the new kind:17375 round-trips in, so it re-derives + // from the rotated key. No need to reset it here. + } + // ============================================================ // Send nutzap // ============================================================ @@ -923,6 +1039,161 @@ class CashuWalletState( return outcome } + // ============================================================ + // Find-my-wallet wizard support (cross-relay discovery) + // ============================================================ + + /** + * Decrypt a discovered kind:17375 (possibly authored by another client, + * pulled in by [com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.wizard.CashuWalletDiscovery]) + * into its mint list + wallet P2PK private key. Returns null if the + * content can't be decrypted (not really ours / signer rejected). + */ + suspend fun decryptDiscoveredWallet(event: CashuWalletEvent): DiscoveredWalletConfig? = + runCatching { + DiscoveredWalletConfig( + mints = event.mints(signer), + privkeyHex = event.privkey(signer), + ) + }.onFailure { + Log.w("CashuWallet") { "Failed to decrypt discovered wallet ${event.id.take(8)}: ${it.message}" } + }.getOrNull() + + /** + * Read-only probe of how much ecash is recoverable from a wallet's + * NUT-13 seed, per mint, WITHOUT publishing anything. Drives the + * wizard's "structural + balance" verification of each discovered + * wallet — including OLD/duplicate wallets whose [privkeyHex] differs + * from the main wallet's. Mints that error (unreachable, no funds) are + * simply absent from the result. Secrets already held by the main + * wallet are excluded so the figure reflects *new* recoverable funds. + */ + suspend fun probeRecoverableFromSeed( + privkeyHex: String, + mints: List, + ): Map { + check(started) { NOT_STARTED_MESSAGE } + val seed = CashuDeterministic.deriveWalletSeed(privkeyHex.hexToByteArray()) + val existingSecrets = + _tokenEntries.value + .flatMap { it.content.proofs } + .mapTo(HashSet()) { it.secret } + val result = LinkedHashMap() + for (mint in mints) { + val recoverable = + runCatching { ops.scanRecoverableProofs(mint, seed, existingSecrets = existingSecrets) } + .onFailure { Log.w("CashuWallet") { "Probe of $mint failed: ${describeMintError(it)}" } } + .getOrNull() ?: continue + if (!recoverable.isEmpty) result[mint] = recoverable.amountSats + } + return result + } + + /** + * Recover every spendable proof derivable from [privkeyHex]'s NUT-13 + * seed at [mints] into the **current** wallet: each mint is re-scanned + * (NUT-09 + NUT-07) and its UNSPENT proofs are published as fresh + * kind:7375 owned by this account. Funds locked to an OLD wallet's key + * thus land in the main wallet as bearer proofs (surfacing as a + * configured- or unconfigured-mint balance the user can then move). + * + * [bumpCounter] advances this wallet's persistent NUT-13 counter past the + * recovered slots — correct ONLY when [privkeyHex] is the **main** wallet's + * own key (the wizard adopting a discovered wallet's own balance). For a + * FOREIGN old/duplicate wallet it must stay false: the foreign seed's slots + * are unrelated to the main seed's, so bumping would corrupt the main + * wallet's counter (see [CashuWalletOps.publishRecoveredProofs]). + * + * Returns the total sats recovered across all mints. + */ + suspend fun recoverFromSeed( + privkeyHex: String, + mints: List, + bumpCounter: Boolean = false, + ): Long { + check(started) { NOT_STARTED_MESSAGE } + val seed = CashuDeterministic.deriveWalletSeed(privkeyHex.hexToByteArray()) + var total = 0L + for (mint in mints) { + // existingSecrets is re-read per mint so a proof published while + // recovering an earlier mint isn't double-counted here. + val existingSecrets = + _tokenEntries.value + .flatMap { it.content.proofs } + .mapTo(HashSet()) { it.secret } + val outcome = + runCatching { + val recoverable = ops.scanRecoverableProofs(mint, seed, existingSecrets = existingSecrets) + val published = ops.publishRecoveredProofs(recoverable) + if (bumpCounter) { + // Advance past EVERY slot the mint signed, even when all + // recovered proofs were already spent (recoverable.proofs + // empty after the NUT-07 filter). nextCounterAfterScan is + // set from the pre-checkstate scan, so the delta still + // covers those slots — without this a fully-spent adopt on + // a fresh device would leave the counter at 0 and the next + // mint would reuse an already-signed slot (unspendable). + val current = settings.peekCashuCounter(recoverable.keysetId) + val delta = (recoverable.nextCounterAfterScan - current).coerceAtLeast(0L) + if (delta > 0) settings.reserveCashuCounters(recoverable.keysetId, delta.toInt()) + } + published + }.onFailure { Log.w("CashuWallet") { "Recover from $mint failed: ${describeMintError(it)}" } } + .getOrNull() ?: continue + total += outcome.amountRecoveredSats + } + return total + } + + /** + * Adopt a discovered wallet as the account's live + main wallet by + * **re-signing a fresh** kind:17375 + kind:10019 with the discovered + * wallet's own mints and P2PK key. + * + * We deliberately do NOT rebroadcast the discovered event verbatim. The + * crawl may surface a wallet the user already DELETED: the user's own + * NIP-09 kind:5 (from [CashuWalletOps.deleteWallet]) carries both an `e` + * tag (the old event id) and an `a` tag (the replaceable `17375:pubkey:` + * address). Re-publishing the same event loses on both — relays that + * honored the deletion reject the duplicate id, and the `a`-tag rule + * re-deletes any version with `created_at <= deletion.created_at` the + * moment the kind:5 propagates back (on relays and in our own LocalCache). + * + * A freshly-signed event sidesteps both: a new id isn't covered by the + * `e` tag, and `created_at = now` is newer than the past deletion so it + * survives the `a`-tag rule. Same key + mints means the same nutzap + * address and the same recoverable funds (the NUT-13 seed is derived from + * the key, independent of the event id). [nutzapInfo] is unused in this + * path — publishWalletEvents re-issues a fresh kind:10019 advertising our + * current inbox relays — but is kept for the decrypt-failure fallback. + */ + suspend fun adoptDiscoveredWallet( + wallet: CashuWalletEvent, + nutzapInfo: NutzapInfoEvent? = null, + ) { + check(started) { NOT_STARTED_MESSAGE } + val config = decryptDiscoveredWallet(wallet) + if (config != null && config.mints.isNotEmpty()) { + ops.publishWalletEvents( + mints = config.mints, + p2pkPrivkeyHex = config.privkeyHex, + nutzapRelays = inboxRelaysFlow.value.toList(), + ) + } else { + // Couldn't decrypt (shouldn't happen for a wallet the wizard + // surfaced as valid) — fall back to rebroadcasting the raw event so + // it's at least findable. This keeps the original id/created_at and + // so remains vulnerable to a prior deletion, but it's the best we + // can do without the plaintext to re-sign from. + LocalCache.justConsumeMyOwnEvent(wallet) + publishEvent(wallet) + if (nutzapInfo != null) { + LocalCache.justConsumeMyOwnEvent(nutzapInfo) + publishEvent(nutzapInfo) + } + } + } + /** * Scan held kind:7375 events for accidental duplicates and NIP-09 * delete the redundant ones. An event B is redundant when there @@ -1025,41 +1296,43 @@ class CashuWalletState( .groupBy { it.content.mint } .filterKeys { mintUrlFilter == null || it == mintUrlFilter } for ((mintUrl, entries) in byMint) { - val allProofs = entries.flatMap { it.content.proofs } - if (allProofs.isEmpty()) continue - val states = - runCatching { ops.checkProofStates(mintUrl, allProofs) } + // Shared NUT-07 check + NIP-09 delete with amy's `cashu maintenance + // scrub`. Returns the stale token events it published a deletion for. + val staleEvents = + runCatching { ops.scrubStaleProofs(mintUrl, entries) } .onFailure { - Log.w("CashuWallet", "checkProofStates($mintUrl) failed; skipping sweep", it) + Log.w("CashuWallet", "scrubStaleProofs($mintUrl) failed; skipping sweep", it) }.getOrNull() ?: continue - - // Any entry with at least one SPENT proof gets purged. Keeping - // mixed-state entries around would let the next send pick them - // and trip the same HTTP 400 we're trying to prevent. - val staleEntries = - entries.filter { entry -> - entry.content.proofs.any { states[it.secret] == ProofState.SPENT } - } - if (staleEntries.isEmpty()) continue + if (staleEvents.isEmpty()) continue Log.i("CashuWallet") { - "Scrubbing ${staleEntries.size} stale kind:7375 event(s) at $mintUrl" + "Scrubbing ${staleEvents.size} stale kind:7375 event(s) at $mintUrl" } - val staleIds = staleEntries.map { it.event.id }.toSet() - runCatching { - val template = DeletionEvent.build(staleEntries.map { it.event }) - val signed = signer.sign(template) - publishEvent(signed) - }.onFailure { - Log.w("CashuWallet", "Failed to NIP-09 delete stale entries for $mintUrl", it) - } - // Drop from internal indexes regardless of publish success — even - // if the kind:5 didn't go out, we know these proofs are unusable - // and shouldn't be selected for the next swap. - removeEvents(staleIds) + // Drop from internal indexes — even if the kind:5 didn't reach a + // relay, these proofs are unusable and must not be re-selected. + removeEvents(staleEvents.map { it.id }.toSet()) } } + /** + * Proactively reconcile *every* mint we currently hold tokens at against + * its NUT-07 `/checkstate` — not just the one mint a spend happens to + * target. [scrubLocallyStaleProofs] with a null filter already iterates + * the token-derived mint set ([mintBalances]), so proofs auto-redeemed + * from a mint we never configured (e.g. a nutzap on a mint not in our + * kind:10019) get their spent state checked here too, instead of sitting + * unverified until the user happens to spend from that mint. + * + * Non-destructive (it only prunes proofs the mint reports SPENT) and + * idempotent — safe to call on every wallet-screen open. Deliberately + * does NOT run [migrateStaleKeysets]; that swap-then-publish sequence + * isn't atomic and stays user-driven. + */ + suspend fun syncAllMints() { + if (!started) return + scrubLocallyStaleProofs() + } + /** * Migrate proofs held on inactive keysets onto each mint's current * active keyset. Cheap when nothing needs migrating (one /v1/keys @@ -1096,13 +1369,14 @@ class CashuWalletState( /** * Send a NIP-61 nutzap of [amountSats] to [recipientPubKey] referencing - * [zappedEvent]. Returns the resulting [NutzapSent] on success or throws + * [zappedEvent] (null for a profile nutzap that targets the person, not an + * event). Returns the resulting [NutzapSent] on success or throws * — callers should surface errors via [describeMintError]. */ suspend fun sendNutzap( amountSats: Long, recipientPubKey: HexKey, - zappedEvent: EventHintBundle, + zappedEvent: EventHintBundle?, message: String = "", preferredMintUrl: String? = null, onProgress: ((Float) -> Unit)? = null, @@ -1316,6 +1590,15 @@ class CashuWalletState( } } +/** + * Decrypted config of a discovered kind:17375 — its configured [mints] and + * wallet P2PK private key. See [CashuWalletState.decryptDiscoveredWallet]. + */ +data class DiscoveredWalletConfig( + val mints: List, + val privkeyHex: String?, +) + /** Mint + recipient pubkey resolved from a kind:10019. */ data class NutzapTarget( val mintUrl: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt new file mode 100644 index 0000000000..f341af4e10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip89AppHandlers + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * This user's public NIP-89 app recommendations: one kind 31989 addressable + * event per handled kind, each listing the recommended apps for that kind. + */ +class AppRecommendationsState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + /** + * Synchronous cache scan. Seeds [flow] and feeds the read-modify-write + * publishers below, which must read current truth from the cache while + * holding [publishMutex]. + */ + fun existingRecommendationEvents(): List = + cache.addressables + .filterIntoSet(AppRecommendationEvent.KIND, signer.pubKey) + .mapNotNull { it.event as? AppRecommendationEvent } + + /** + * My kind 31989 recommendation events (one per handled kind), kept in + * sync as the cache consumes new versions. UI should collect this + * instead of rescanning the cache on every event bundle. + * + * Eagerly started on purpose, like the sibling account states: the + * registered observer holds strong references to these notes, pinning + * them in the soft-reference cache so the read-modify-write publishers + * below never rebuild a 31989 from a partially garbage-collected + * snapshot (which would silently drop previously recommended apps). + */ + val flow: StateFlow> = + cache + .observeEvents( + Filter(kinds = listOf(AppRecommendationEvent.KIND), authors = listOf(signer.pubKey)), + ).flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, existingRecommendationEvents()) + + /** + * Serializes read-modify-write of the per-kind recommendation events so + * two rapid toggles can't race each other into losing updates. + */ + private val publishMutex = Mutex() + + /** + * Returns a createdAt strictly greater than whatever AppRecommendationEvent + * currently sits in cache for this d-tag. Needed because + * LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't + * strictly greater, and TimeUtils.now() has only second resolution. + */ + private fun nextCreatedAt(supportedKind: String): Long { + val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind) + val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L + return maxOf(TimeUtils.now(), latest + 1) + } + + private fun currentRecommendations(supportedKind: String): List { + val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind) + val event = cache.getAddressableNoteIfExists(address)?.event as? AppRecommendationEvent + return event?.recommendations() ?: emptyList() + } + + /** + * Adds [app] to this user's public NIP-89 recommendations, one kind 31989 + * event per event kind the app declares to handle via `k` tags. + */ + suspend fun recommendApp( + app: AppDefinitionEvent, + relayHint: NormalizedRelayUrl?, + account: Account, + ) { + if (!account.isWriteable()) return + + val kinds = app.supportedKinds() + if (kinds.isEmpty()) return + + val newTag = RecommendationTag(app.address(), relayHint, PlatformType.ANDROID.code) + + publishMutex.withLock { + kinds.forEach { kind -> + val supportedKind = kind.toString() + val current = currentRecommendations(supportedKind) + if (current.any { it.address == app.address() }) return@forEach + + val template = + AppRecommendationEvent.buildFromTags( + supportedKind = supportedKind, + recommendations = current + newTag, + createdAt = nextCreatedAt(supportedKind), + ) + account.sendMyPublicAndPrivateOutbox(account.signer.sign(template)) + } + } + } + + /** Removes the app at [address] from every kind 31989 recommendation event of this user. */ + suspend fun unrecommendApp( + address: Address, + account: Account, + ) { + if (!account.isWriteable()) return + + publishMutex.withLock { + existingRecommendationEvents().forEach { event -> + val current = event.recommendations() + val updated = current.filterNot { it.address == address } + if (updated.size == current.size) return@forEach + + val template = + AppRecommendationEvent.buildFromTags( + supportedKind = event.dTag(), + recommendations = updated, + createdAt = nextCreatedAt(event.dTag()), + ) + account.sendMyPublicAndPrivateOutbox(account.signer.sign(template)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index a61a1b0203..e4533b8f0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -89,8 +89,8 @@ class BlossomServerListState( }.onStart { emit(mergeServerList(flow.value)) }.onEach { servers -> - if (servers.none { it == settings.defaultFileServer }) { - settings.changeDefaultFileServer(servers.firstOrNull() ?: DEFAULT_MEDIA_SERVERS[0]) + resetTargetOrNull(flow.value, servers, settings.defaultFileServer)?.let { + settings.changeDefaultFileServer(it) } }.flowOn(Dispatchers.IO) .stateIn( @@ -127,3 +127,25 @@ class BlossomServerListState( alt: String, ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) } + +/** + * Decides whether the persisted default file server must be reset, and to what. + * + * Returns the new default server, or `null` when no change should happen. + * + * The guard on [rawList] being non-empty is what prevents the startup race: before the user's + * [BlossomServersEvent] (kind 10063) loads from cache/relay, [rawList] is empty and [merged] is the + * transient [DEFAULT_MEDIA_SERVERS] fallback. Resetting against that fallback would clobber the + * locally-saved pick on every launch. Only reset once a real, loaded list is in hand and it no + * longer contains the current pick (e.g. the user removed it from their list). + */ +fun resetTargetOrNull( + rawList: List, + merged: List, + current: ServerName, +): ServerName? = + if (rawList.isNotEmpty() && merged.none { it == current }) { + merged.firstOrNull() ?: DEFAULT_MEDIA_SERVERS[0] + } else { + null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index ebf0c03d44..25f5ba21cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -31,15 +31,20 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.UiSettingsFlow -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -117,6 +122,10 @@ class UiSharedPreferences( val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed") val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") + val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") + val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") + val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") + val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -143,7 +152,7 @@ class UiSharedPreferences( preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems, + bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarEntries, showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true, showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true, showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false, @@ -152,6 +161,11 @@ class UiSharedPreferences( showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true, showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true, dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, + suggestWorkoutsFromHealthConnect = + preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, + fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, + fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -191,7 +205,7 @@ class UiSharedPreferences( preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name - preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name } + preferences[UI_BOTTOM_BAR_ITEMS] = JsonMapper.toJson(sharedSettings.bottomBarItems) preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab @@ -200,6 +214,10 @@ class UiSharedPreferences( preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning + preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name + preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name + preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name + preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name } } catch (e: Exception) { if (e is CancellationException) throw e @@ -208,17 +226,29 @@ class UiSharedPreferences( } } - private fun decodeBottomBarItems(raw: String): List { - if (raw.isEmpty()) return emptyList() - return raw - .split(",") - .mapNotNull { name -> - try { - NavBarItem.valueOf(name) - } catch (_: IllegalArgumentException) { - null - } - } + private fun decodeBottomBarItems(raw: String): List? { + if (raw.isBlank()) return DefaultBottomBarEntries + // Current format: a JSON list of BottomBarEntry (built-ins + favorites). + runCatching { return JsonMapper.fromJson>(raw) } + // Configs written before the stable @SerialName discriminators used the fully-qualified + // class name as the polymorphic "type" value. Rewrite it to the short name and retry, so a + // customized bar survives the upgrade instead of silently resetting to defaults. + runCatching { + val migrated = + raw + .replace(LEGACY_BUILTIN_DISCRIMINATOR, "builtIn") + .replace(LEGACY_FAVORITE_DISCRIMINATOR, "favorite") + return JsonMapper.fromJson>(migrated) + } + // Oldest format: comma-joined NavBarItem enum names (before favorites/unified entries). + val legacy = raw.split(",").mapNotNull { name -> runCatching { NavBarItem.valueOf(name) }.getOrNull() } + if (legacy.isNotEmpty()) return legacy.map { BottomBarEntry.BuiltIn(it) } + // Unrecognizable — fall back to the defaults rather than leaving the bar empty. + return DefaultBottomBarEntries } + + // The pre-@SerialName polymorphic discriminators (fully-qualified class names) for migration. + private const val LEGACY_BUILTIN_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.BuiltIn" + private const val LEGACY_FAVORITE_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.Favorite" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 0292c1f151..1758c14a8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAl import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.MultiHashtagFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.mine.MineFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow import com.vitorpamplona.amethyst.service.location.LocationState @@ -62,6 +63,7 @@ class FeedTopNavFilterState( val followsRelays: StateFlow>, val blockedRelays: StateFlow>, val proxyRelays: StateFlow>, + val mineRelays: StateFlow>, val relayFeeds: StateFlow>, val caches: FeedDecryptionCaches, val signer: NostrSigner, @@ -72,7 +74,7 @@ class FeedTopNavFilterState( ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { - TopFilter.Global -> { + TopFilter.Global, TopFilter.Selected -> { GlobalFeedFlow(followsRelays, proxyRelays, relayFeeds) } @@ -93,7 +95,7 @@ class FeedTopNavFilterState( } TopFilter.Mine -> { - AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) + MineFeedFlow(signer.pubKey, mineRelays) } is TopFilter.Community, is TopFilter.PeopleList, is TopFilter.MuteList -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt index dd5325011e..1e83a7dc03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe -import com.vitorpamplona.amethyst.service.location.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash fun compute50kmLine(geoHash: GeoHash): List { val hashes = mutableListOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/mine/MineFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/mine/MineFeedFlow.kt new file mode 100644 index 0000000000..694366f9b8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/mine/MineFeedFlow.kt @@ -0,0 +1,66 @@ +/* + * 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.model.topNavFeeds.mine + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +/** + * Resolves the "Mine" top-nav selection to an author filter scoped to the logged-in user's own + * pubkey, pinned to the user's own relays — their outbox, private-storage, local and proxy relays + * (see [com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountMineRelayState]). Unlike the + * follow filters, "Mine" doesn't need per-author outbox resolution from cache: the only author is + * the user, and the user's relays are already known from their own account state — so we pin the + * fixed set directly via [AuthorsByProxyTopNavFilter] (it associates each given relay with the + * authors, which is exactly "query my relays for my events"). + * + * This is the single source of truth for "Mine". Both the relay sub-assemblers (through each + * screen's `liveXFollowListsPerRelay`) and the local DAL filters (through `liveXFollowLists`) + * consume the produced [AuthorsByProxyTopNavFilter], so screens no longer need a dedicated + * `TopFilter.Mine` branch: the generic author path already narrows to the user. It also makes + * relay-set changes re-invalidate automatically — `liveXFollowListsPerRelay` re-emits whenever + * [mineRelays] changes, through the sub-assemblers' existing `followsPerRelayFlow` collector. + */ +class MineFeedFlow( + val myPubkey: HexKey, + val mineRelays: StateFlow>, +) : IFeedFlowsType { + fun convert(relays: Set): IFeedTopNavFilter = + AuthorsByProxyTopNavFilter( + authors = setOf(myPubkey), + proxyRelays = relays, + ) + + override fun flow(): Flow = mineRelays.map(::convert) + + override fun startValue(): IFeedTopNavFilter = convert(mineRelays.value) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt index 68aada42cb..a437179a8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt @@ -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> = + accountsCache.accounts + .debounce(200) + .transformLatest { snapshot -> + val perAccountFlows = + snapshot.map { (_, account) -> + combine( + account.settings.nwcWallets, + account.settings.clinkDebitWallets, + ) { nwcWallets, clinkDebitWallets -> + val relays = mutableSetOf() + 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() + perAccount.forEach { moneyOpRelays.addAll(it) } + moneyOpRelays.toSet() + }, + ) + }.onEach { + torEvaluatorFlow.moneyOpRelays.tryEmit(it) + }.stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index b6822fc21d..bd0366a1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -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>(emptySet()) val trustedRelays = MutableStateFlow>(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>(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>(emptyMap()) + + private fun currentMoneyOpRelays(): Set = 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) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { this[it] = (this[it] ?: 0) + 1 } + } + } + } + + fun unregisterMoneyOpRelays(relays: Set) { + 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, dmRelayList: Set -> + moneyOpRelays, + adHocMoneyOpCounts, + ) { + torSettings: TorRelaySettings, + trustedRelayList: Set, + dmRelayList: Set, + moneyOpRelayList: Set, + adHocMoneyOps: Map, + -> 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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt new file mode 100644 index 0000000000..cd7cc65d4f Binary files /dev/null and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt new file mode 100644 index 0000000000..53705a0dac Binary files /dev/null and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt new file mode 100644 index 0000000000..fa64ed6378 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt @@ -0,0 +1,191 @@ +/* + * 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.napplet + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.flow.first +import java.io.File +import java.security.MessageDigest + +/** + * Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb` + * file per app (keyed by a SHA-256 prefix of the coordinate) so loading or saving one app's + * permissions never touches another app's data — essential at scale with 1000s of apps. + * + * The coordinate is stored inside each file under [KEY_COORDINATE] so [allPolicies] can + * reverse-map file → coordinate without scanning the filesystem. + */ +class DataStoreNostrSignerPermissionStore( + private val filesDir: File, +) : NostrSignerPermissionStore { + constructor(context: Context) : this(context.applicationContext.filesDir) + + private val cache = LargeCache>() + + private fun storeFor(coordinate: String): DataStore { + val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") + return cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + } + + override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? { + val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null + return runCatching { AppSignerPolicy.valueOf(raw) }.getOrNull() + } + + override suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[KEY_POLICY] = policy.name + } + } + + override suspend fun clearPolicy(coordinate: String) { + storeFor(coordinate).edit { it.remove(KEY_POLICY) } + } + + override suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? { + val raw = storeFor(coordinate).data.first()[opKey(op)] ?: return null + return runCatching { NostrOpDecision.valueOf(raw) }.getOrNull() + } + + override suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[opKey(op)] = decision.name + } + } + + override suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) { + storeFor(coordinate).edit { it.remove(opKey(op)) } + } + + override suspend fun allPolicies(): Map { + val dir = File(filesDir, "datastore") + if (!dir.exists()) return emptyMap() + val result = mutableMapOf() + for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { + val ds = + cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue + val policy = loadPolicy(coordinate) ?: continue + result[coordinate] = policy + } + return result + } + + override suspend fun allOpDecisions(coordinate: String): Map { + val prefs = storeFor(coordinate).data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(OP_PREFIX)) continue + // Skip expiry metadata keys — they end with the expiry suffix + if (name.endsWith(OP_EXPIRY_SUFFIX)) continue + val opKey = name.removePrefix(OP_PREFIX) + val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue + result[opKey] = decision + } + return result + } + + override suspend fun loadOpExpiry( + coordinate: String, + op: NostrSignerOp, + ): Long? { + val raw = storeFor(coordinate).data.first()[opExpiryKey(op)] ?: return null + return raw.toLongOrNull() + } + + override suspend fun storeOpExpiry( + coordinate: String, + op: NostrSignerOp, + expiresAt: Long, + ) { + storeFor(coordinate).edit { it[opExpiryKey(op)] = expiresAt.toString() } + } + + override suspend fun clearOpExpiry( + coordinate: String, + op: NostrSignerOp, + ) { + storeFor(coordinate).edit { it.remove(opExpiryKey(op)) } + } + + override suspend fun loadLastUsed(coordinate: String): Long? { + val raw = storeFor(coordinate).data.first()[KEY_LAST_USED] ?: return null + return raw.toLongOrNull() + } + + override suspend fun storeLastUsed( + coordinate: String, + epochSeconds: Long, + ) { + storeFor(coordinate).edit { it[KEY_LAST_USED] = epochSeconds.toString() } + } + + override suspend fun clearAll(coordinate: String) { + storeFor(coordinate).edit { it.clear() } + } + + private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}") + + private fun opExpiryKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}$OP_EXPIRY_SUFFIX") + + companion object { + private val KEY_COORDINATE = stringPreferencesKey("coordinate") + private val KEY_POLICY = stringPreferencesKey("policy") + private val KEY_LAST_USED = stringPreferencesKey("lastused") + private const val OP_PREFIX = "op:" + private const val OP_EXPIRY_SUFFIX = ":exp" + + private fun hash(coordinate: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt new file mode 100644 index 0000000000..59f276401d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -0,0 +1,431 @@ +/* + * 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.napplet + +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import android.os.SystemClock +import android.util.Log +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.napplet.NappletBroker +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways +import com.vitorpamplona.amethyst.napplethost.NappletIpc +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.screen.AccountState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this + * service and sends requests as JSON over a [Messenger]; this is the only side that holds the + * signer, the relays, and the permission ledger. It runs each request through the shared + * [NappletBroker] (built per account by [AccountNappletGateways]) via the host-agnostic + * [NappletRequestRouter], which gates everything on consent and never returns key material. + * + * This class is intentionally thin: it owns the Messenger transport, the per-account broker cache, + * and the live-subscription registry, and delegates all decode/policy/execution to the shared core. + * + * `exported=false` in the manifest restricts binding to this app's own UID, so no other + * installed app can reach the broker. A renderer escape that reaches the `:napplet` process + * could still bind here — but it gains only what the user explicitly consents to, per + * capability, and never the private key (the broker's contract). + */ +class NappletBrokerService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM. + private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + + // Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only + // instantiated in the main process where the signer lives; never touched from :napplet. + private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } + + // Per-applet sandboxed key-value store (namespaced by coordinate inside the impl). + private val storage by lazy { DataStoreNappletStorage(applicationContext) } + + private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) } + + // The broker for the current account, rebuilt only on account switch (see broker()). + private var cachedBroker: Pair? = null + + // Live relay subscriptions, keyed by the applet's subId; reads the current account live. + private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() } + + // The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes. + private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) } + + // Streams identity.changed pushes (account switch / connect / disconnect) to a watching applet. + private val identityWatch = + NappletIdentityWatch(scope) { + Amethyst.instance.sessionManager.accountContent + .map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" } + } + + // Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS. + // A UID check here would be useless: onBind() runs once (the binder is cached and reused for all + // later clients) and outside any binder transaction, so getCallingUid() returns our own UID anyway. + override fun onBind(intent: Intent?): IBinder? = incoming.binder + + override fun onDestroy() { + liveSubscriptions.closeAll() + identityWatch.stop() + // Drop any foreground holds this broker still owns so they don't leak past the service. + synchronized(foregroundLeases) { + repeat(foregroundLeases.size) { SandboxForegroundHold.release() } + foregroundLeases.clear() + } + scope.cancel() + super.onDestroy() + } + + // Sandbox surfaces (full-screen :napplet hosts) currently reporting themselves foreground, mapped to + // the last time each renewed its lease (monotonic elapsedRealtime). While this map is non-empty the + // main process is held resumed (Tor/relays/AUTH up) via SandboxForegroundHold — the napplet host + // lives in :napplet and can't touch that lifecycle itself, so it signals over IPC. + // + // The host re-sends its foreground report on a heartbeat while genuinely resumed. We key on the + // launch token, so a repeated report just refreshes the timestamp (idempotent, no double-acquire). + // If a host's process dies while foreground it can't send its onPause "false", so the heartbeats + // simply stop and [foregroundLeaseWatchdog] reaps the stale lease — bounding any such leak to one + // [FOREGROUND_LEASE_TTL_MS] window instead of holding the network up forever. + private val foregroundLeases = HashMap() + private var foregroundLeaseWatchdog: Job? = null + + private fun handleMessage(msg: Message): Boolean { + // A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the + // main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays. + if (msg.what == NappletIpc.MSG_SET_FOREGROUND) { + val data = msg.data ?: return true + val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true + val foreground = data.getBoolean(NappletIpc.KEY_FOREGROUND, false) + synchronized(foregroundLeases) { + if (foreground) { + val firstReport = !foregroundLeases.containsKey(token) + // A foreground lease just pins Tor/relays (no key access), but a misbehaving sandbox + // could still spam distinct keys to keep the network up. Bound the damage: refuse new + // lease keys past the cap. Real usage holds only a handful of foreground surfaces. + if (firstReport && foregroundLeases.size >= MAX_FOREGROUND_LEASES) { + Log.w("NappletBrokerService", "Foreground lease cap reached; ignoring new lease $token") + return true + } + foregroundLeases[token] = SystemClock.elapsedRealtime() + if (firstReport) { + SandboxForegroundHold.acquire() + ensureForegroundLeaseWatchdog() + } + } else if (foregroundLeases.remove(token) != null) { + SandboxForegroundHold.release() + } + } + return true + } + + // The direct-WebView browser relays a successfully loaded page; record it in the visit history + // (main process only). Only clean page-finishes reach here, so misspellings never get recorded. + if (msg.what == NappletIpc.MSG_RECORD_HISTORY) { + val data = msg.data ?: return true + val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true + BrowserHistoryRegistry.init(applicationContext) + BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty()) + return true + } + + // The direct-WebView browser relays a favicon captured from the loaded page; store it by host. + if (msg.what == NappletIpc.MSG_RECORD_ICON) { + val data = msg.data ?: return true + val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true + val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true + BrowserIconRegistry.init(applicationContext) + BrowserIconRegistry.record(host, bytes) + return true + } + + // The direct-WebView browser requests a favorite toggle for the current URL (main process only). + if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) { + val data = msg.data ?: return true + val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true + val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url } + FavoriteAppsRegistry.init(applicationContext) + val id = "url:$url" + if (FavoriteAppsRegistry.isFavorite(id)) { + FavoriteAppsRegistry.remove(id) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis())) + } + return true + } + + // The direct-WebView browser relays its per-host Tor choice; persist it (main process only). + if (msg.what == NappletIpc.MSG_SET_WEB_TOR) { + val data = msg.data ?: return true + val host = data.getString(NappletIpc.KEY_WEB_HOST)?.takeIf { it.isNotBlank() } ?: return true + WebAppNetworkRegistry.init(applicationContext) + WebAppNetworkRegistry.set(host, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true)) + return true + } + + // The sandbox relays the user's per-site network choice; persist it against the trusted + // coordinate the launch token resolves to (the sandbox can't state its own coordinate). + if (msg.what == NappletIpc.MSG_SET_NETWORK_MODE) { + val data = msg.data ?: return true + val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) ?: return true + NappletNetworkRegistry.init(applicationContext) + NappletNetworkRegistry.set(session.identity.coordinate, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true)) + return true + } + + // A running full-screen sandbox surface asks to open its editable permission screen. The sandbox + // can't state its own coordinate, so a napplet/nsite sends its launch token (resolved here to the + // trusted coordinate) and a browser sends its visited origin (keyed as `browser:`). Open + // the main activity at that Connected Apps detail. + if (msg.what == NappletIpc.MSG_OPEN_PERMISSIONS) { + val data = msg.data ?: return true + val coordinate = + data.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { NappletLaunchRegistry.resolve(it)?.identity?.coordinate } + ?: data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() }?.let { "browser:$it" } + ?: return true + openConnectedAppDetail(coordinate) + return true + } + + // Browser mode mints a fresh launch token per visited origin, so NIP-07 consent is scoped to the + // one site the request came from. The origin is the trusted source origin the WebView reported + // (the sandbox can't forge it), and the synthetic identity keys the permission ledger per host. + if (msg.what == NappletIpc.MSG_MINT_BROWSER_TOKEN) { + val data = msg.data ?: return true + val replyTo = msg.replyTo ?: return true + val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true + val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin) + val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)) + val response = + Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply { + this.data = + Bundle().apply { + putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) + putString(NappletIpc.KEY_LAUNCH_TOKEN, token) + } + } + runCatching { replyTo.send(response) } + return true + } + + if (msg.what != NappletIpc.MSG_REQUEST) return false + + val data = msg.data ?: return true + val replyTo = msg.replyTo ?: return true + val requestId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true + val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true + + val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" + + // Resolve the launch token to the trusted identity + declared set. The sandbox never states + // its own coordinate, so a compromised :napplet process can only ever act as the napplet it + // was launched as (it holds only its own token). An unknown token = no session; refuse. + val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) + if (session == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) + return true + } + val identity = session.identity + val declared = session.declared + + scope.launch { + // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply + // decision (it stays wire-identical with the future desktop host). This service only supplies + // the broker, the Messenger transport, and the live relay subscription each Outcome implies. + val broker = broker() + if (broker == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in."))) + return@launch + } + when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { + is NappletRequestRouter.Outcome.Ignore -> {} + is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) + is NappletRequestRouter.Outcome.OpenSubscription -> liveSubscriptions.open(outcome.subId, outcome.filters) { push(replyTo, it) } + is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) + is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) } + is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop() + is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } + is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) + } + } + return true + } + + /** + * Periodically reaps foreground leases that stopped renewing — the signature of a `:napplet` host + * process that died (or was killed) while foreground, so it never sent its onPause "false". Each + * reaped lease releases its [SandboxForegroundHold] so the network isn't held up forever by a host + * that's already gone. Runs only while at least one lease exists; cancelled with [scope] on destroy. + */ + private fun ensureForegroundLeaseWatchdog() { + if (foregroundLeaseWatchdog != null) return + foregroundLeaseWatchdog = + scope.launch { + while (true) { + delay(FOREGROUND_LEASE_CHECK_MS) + synchronized(foregroundLeases) { + val now = SystemClock.elapsedRealtime() + val iterator = foregroundLeases.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > FOREGROUND_LEASE_TTL_MS) { + Log.w("NappletBrokerService", "Foreground lease ${entry.key} expired (host process gone?); releasing hold") + iterator.remove() + SandboxForegroundHold.release() + } + } + } + } + } + } + + /** + * The broker for the *currently* signed-in account, cached and rebuilt only when the account + * changes (reference identity). The gateways capture the account and read its flows live, so a + * cached broker stays correct across requests without per-request allocation. + */ + @Synchronized + private fun broker(): NappletBroker? { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null + cachedBroker?.let { (acc, broker) -> if (acc === account) return broker } + val broker = + AccountNappletGateways( + account = account, + context = applicationContext, + ledger = ledger, + storage = storage, + // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes + // through Tor when asked + active, and falls back to clearnet otherwise. + httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, + signerLedger = signerLedger, + ).broker() + cachedBroker = account to broker + return broker + } + + /** + * Brings the main activity (a `singleInstance`) forward at the Connected Apps detail for [coordinate], + * via the in-process `connectedapp?coordinate=` deep link that [com.vitorpamplona.amethyst.ui.uriToRoute] + * resolves. Used when a full-screen sandbox surface taps "Manage permissions". + */ + private fun openConnectedAppDetail(coordinate: String) { + val intent = + Intent(applicationContext, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + data = Uri.parse("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)) + } + runCatching { applicationContext.startActivity(intent) } + .onFailure { Log.w("NappletBrokerService", "Could not open Connected Apps detail", it) } + } + + private fun reply( + replyTo: Messenger, + requestId: String, + payload: String, + ) { + val response = + Message.obtain(null, NappletIpc.MSG_RESPONSE).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_REQUEST_ID, requestId) + putString(NappletIpc.KEY_PAYLOAD, payload) + } + } + try { + replyTo.send(response) + } catch (e: RemoteException) { + Log.w("NappletBrokerService", "Applet host went away before reply could be delivered", e) + } + } + + /** Sends an unsolicited push (a `relay.event`/`relay.eose` envelope) for the host to forward verbatim. */ + private fun push( + replyTo: Messenger, + payload: String, + ) { + val message = + Message.obtain(null, NappletIpc.MSG_PUSH).apply { + data = Bundle().apply { putString(NappletIpc.KEY_PAYLOAD, payload) } + } + try { + replyTo.send(message) + } catch (e: RemoteException) { + Log.w("NappletBrokerService", "Applet host went away before push could be delivered", e) + } + } + + companion object { + /** + * Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin, + * carried in the identity's identifier (which the consent dialog shows); this constant only fills + * the coordinate's author slot so each origin keys the permission ledger separately as + * `browser:`. It is never treated as a real pubkey. + */ + private const val BROWSER_IDENTITY_AUTHOR = "browser" + + /** + * How long a foreground lease stays valid without a renewing heartbeat. A live host re-reports + * every [NappletHostActivity.FOREGROUND_HEARTBEAT_MS][com.vitorpamplona.amethyst.napplethost.NappletHostActivity] + * (well under this), so only a host that's actually gone lets its lease age past it. Sized to + * tolerate a couple of missed/delayed heartbeats while still reaping a dead host's hold promptly. + */ + private const val FOREGROUND_LEASE_TTL_MS = 90_000L + + /** How often [ensureForegroundLeaseWatchdog] sweeps for stale leases. */ + private const val FOREGROUND_LEASE_CHECK_MS = 20_000L + + /** + * Cap on concurrent foreground leases. A foreground lease only keeps Tor/relays up (no key + * access), but this bounds how long a misbehaving sandbox can pin the network by spamming + * distinct lease keys. Comfortably above any realistic number of foreground sandbox surfaces. + */ + private const val MAX_FOREGROUND_LEASES = 8 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt new file mode 100644 index 0000000000..adff3b46f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -0,0 +1,59 @@ +/* + * 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.napplet + +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability + +/** Localized display name for a capability, shared by the consent dialog and the permissions screen. */ +@StringRes +fun NappletCapability.labelRes(): Int = + when (this) { + NappletCapability.SHELL -> R.string.napplet_cap_shell + NappletCapability.IDENTITY -> R.string.napplet_cap_identity + NappletCapability.KEYS -> R.string.napplet_cap_keys + NappletCapability.RELAY -> R.string.napplet_cap_relay + NappletCapability.STORAGE -> R.string.napplet_cap_storage + NappletCapability.VALUE -> R.string.napplet_cap_value + NappletCapability.RESOURCE -> R.string.napplet_cap_resource + NappletCapability.UPLOAD -> R.string.napplet_cap_upload + NappletCapability.THEME -> R.string.napplet_cap_theme + NappletCapability.NOTIFY -> R.string.napplet_cap_notify + NappletCapability.INC -> R.string.napplet_cap_inc + } + +/** Localized one-line description of what a capability lets a napplet do. */ +@StringRes +fun NappletCapability.descriptionRes(): Int = + when (this) { + NappletCapability.SHELL -> R.string.napplet_cap_shell_desc + NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc + NappletCapability.KEYS -> R.string.napplet_cap_keys_desc + NappletCapability.RELAY -> R.string.napplet_cap_relay_desc + NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc + NappletCapability.VALUE -> R.string.napplet_cap_value_desc + NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc + NappletCapability.UPLOAD -> R.string.napplet_cap_upload_desc + NappletCapability.THEME -> R.string.napplet_cap_theme_desc + NappletCapability.NOTIFY -> R.string.napplet_cap_notify_desc + NappletCapability.INC -> R.string.napplet_cap_inc_desc + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt new file mode 100644 index 0000000000..a5c2f15811 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -0,0 +1,288 @@ +/* + * 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.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +class NappletConnectActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletConnectCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletConnectScreen( + info = info, + onConnect = { policy -> + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) + finish() + }, + onBlock = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Blocked) + finish() + }, + onCancel = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletConnectCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletConnectScreen( + info: NappletConnectInfo, + onConnect: (AppSignerPolicy) -> Unit, + onBlock: () -> Unit, + onCancel: () -> Unit, +) { + var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) } + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f + + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + app name + connect subtitle + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isBrowser = info.coordinate.startsWith("browser:") + FavoriteAppIcon( + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.napplet_connect_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + info.domain, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + + Text( + stringResource(R.string.napplet_connect_how_handle), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(8.dp)) + + // Trust level options + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + PolicyOption( + selected = selected == AppSignerPolicy.FULL_TRUST, + icon = "❤", + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { selected = AppSignerPolicy.FULL_TRUST }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.REASONABLE, + icon = "👍", + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { selected = AppSignerPolicy.REASONABLE }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.PARANOID, + icon = "🕶", + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { selected = AppSignerPolicy.PARANOID }, + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.cancel)) + } + Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.napplet_connect_button)) + } + } + + OutlinedButton( + onClick = onBlock, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text( + stringResource(R.string.napplet_connect_block, info.domain), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun PolicyOption( + selected: Boolean, + icon: String, + label: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(icon, style = MaterialTheme.typography.headlineSmall) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt new file mode 100644 index 0000000000..0467578f2e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt @@ -0,0 +1,86 @@ +/* + * 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.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the "Connect to Nostr" dialog needs to render. */ +data class NappletConnectInfo( + val appletTitle: String, + val coordinate: String, + val domain: String, + val iconUrl: String? = null, +) + +/** + * Bridges the broker to the "Connect to Nostr" first-connect UI. Suspends in [requestConnect]; + * the Activity resolves the deferred with the user's choice. + * A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant. + */ +object NappletConnectCoordinator { + private class Pending( + val info: NappletConnectInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConnect( + context: Context, + info: NappletConnectInfo, + ): AppConnectResult { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletConnectActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info + + fun complete( + token: String, + result: AppConnectResult, + ) { + pending[token]?.deferred?.complete(result) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(AppConnectResult.Cancelled) + } + + const val EXTRA_TOKEN = "napplet_connect_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt new file mode 100644 index 0000000000..94875be170 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -0,0 +1,241 @@ +/* + * 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.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +/** + * The capability-consent dialog, shown in the **main** process (the only place trusted to + * make a grant). It renders the [NappletConsentInfo] for a pending request and reports the + * user's [GrantState] back through [NappletConsentCoordinator]. The untrusted applet never + * sees or drives this UI. + */ +class NappletConsentActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val token = intent.getStringExtra(NappletConsentCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletConsentCoordinator.infoFor(it) } + + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletConsentDialog( + info = info, + onDecision = { grant -> + decided = true + NappletConsentCoordinator.complete(token, grant) + finish() + }, + onDismiss = { + decided = true + NappletConsentCoordinator.cancel(token) + finish() + }, + ) + } + } + } + + override fun finish() { + // If the system tears us down before the user chose, fail closed. + if (!decided) token?.let { NappletConsentCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletConsentDialog( + info: NappletConsentInfo, + onDecision: (GrantState) -> Unit, + onDismiss: () -> Unit, +) { + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + app name + capability category + coordinate + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isBrowser = info.coordinate.startsWith("browser:") + FavoriteAppIcon( + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + info.capabilityLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + // Operation detail box (may include content preview) + if (info.operationSummary.isNotBlank()) { + Spacer(Modifier.height(12.dp)) + Surface( + modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + SelectionContainer { + Text( + info.operationSummary, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + if (info.allowAlways) { + Button( + onClick = { onDecision(GrantState.ALLOW_ALWAYS) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } + OutlinedButton( + onClick = { onDecision(GrantState.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_once)) + } + } else { + Button( + onClick = { onDecision(GrantState.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_once)) + } + } + + Spacer(Modifier.height(4.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + onClick = { onDecision(GrantState.ASK) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text( + stringResource(R.string.napplet_consent_not_now), + style = MaterialTheme.typography.bodyMedium, + ) + } + OutlinedButton( + onClick = { onDecision(GrantState.DENY) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text( + stringResource(R.string.napplet_consent_deny_always), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt new file mode 100644 index 0000000000..e2a0daa355 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt @@ -0,0 +1,95 @@ +/* + * 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.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the consent dialog needs to describe what an applet is asking permission to do. */ +data class NappletConsentInfo( + val appletTitle: String, + val coordinate: String, + val capabilityLabel: String, + val operationSummary: String, + /** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */ + val allowAlways: Boolean, + val iconUrl: String? = null, +) + +/** + * Bridges the main-process broker to the consent UI. The broker suspends in + * [requestConsent]; this launches [NappletConsentActivity], holds the pending decision keyed + * by a one-time token, and resolves it when the activity reports the user's choice. + * + * A dismissed dialog resolves to [GrantState.ASK] — i.e. "not authorized, ask again next + * time" — so closing the prompt never silently grants nor permanently blocks. + */ +object NappletConsentCoordinator { + private class Pending( + val info: NappletConsentInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConsent( + context: Context, + info: NappletConsentInfo, + ): GrantState { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + val intent = + Intent(context, NappletConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token) + context.startActivity(intent) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + /** Called by [NappletConsentActivity] to render the prompt. */ + fun infoFor(token: String): NappletConsentInfo? = pending[token]?.info + + /** Called by [NappletConsentActivity] with the user's decision. */ + fun complete( + token: String, + grant: GrantState, + ) { + pending[token]?.deferred?.complete(grant) + } + + /** Called when the dialog is dismissed without a choice — fails closed (no grant). */ + fun cancel(token: String) { + pending[token]?.deferred?.complete(GrantState.ASK) + } + + const val EXTRA_TOKEN = "napplet_consent_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt new file mode 100644 index 0000000000..a65238c703 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -0,0 +1,106 @@ +/* + * 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.napplet + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil + +/** + * Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog + * shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview + * or a sat amount). Localized via app resources; holds only a [Context], no account state. + */ +class NappletConsentSummary( + private val context: Context, +) { + fun info( + identity: NappletIdentity, + capability: NappletCapability, + request: NappletRequest, + ): NappletConsentInfo { + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } + return NappletConsentInfo( + appletTitle = title, + coordinate = identity.coordinate, + capabilityLabel = context.getString(capability.labelRes()), + operationSummary = summaryFor(request), + allowAlways = capability.canGrantAlways, + iconUrl = iconUrl, + ) + } + + private fun summaryFor(request: NappletRequest): String = + when (request) { + is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey) + is NappletRequest.IdentityRead -> context.getString(R.string.napplet_consent_identity_read) + is NappletRequest.Publish -> { + val preview = request.content.take(160).trim() + if (preview.isEmpty()) { + context.getString(R.string.napplet_consent_publish, request.kind) + } else { + context.getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”" + } + } + is NappletRequest.SignEvent -> { + val preview = request.content.take(160).trim() + if (preview.isEmpty()) { + context.getString(R.string.napplet_consent_sign, request.kind) + } else { + context.getString(R.string.napplet_consent_sign, request.kind) + "\n“$preview”" + } + } + is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted) + is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query) + is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys -> + context.getString(R.string.napplet_consent_storage) + is NappletRequest.NotifyCreate -> { + val preview = request.title.take(80).trim() + if (preview.isEmpty()) context.getString(R.string.napplet_consent_notify) else context.getString(R.string.napplet_consent_notify) + "\n“$preview”" + } + is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify) + is NappletRequest.PayInvoice -> { + val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull() + if (sats != null) { + pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats) + } else { + context.getString(R.string.napplet_consent_pay) + } + } + is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource) + is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload) + // Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown. + is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt new file mode 100644 index 0000000000..8fc713bf34 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -0,0 +1,61 @@ +/* + * 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.napplet + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +/** + * Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It + * collects the active user's public key ([pubKey]) and, on every *subsequent* change (the current + * value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key + * (or `""` when no account is signed in) to the caller-supplied sink. + * + * One watch at a time per host binding; [start] replaces any prior one. Reached only after the + * router confirmed the applet declared the IDENTITY capability. + */ +class NappletIdentityWatch( + private val scope: CoroutineScope, + private val pubKey: () -> Flow, +) { + private var job: Job? = null + + fun start(push: (String) -> Unit) { + stop() + job = + scope.launch { + pubKey() + .distinctUntilChanged() + .drop(1) + .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } + } + } + + fun stop() { + job?.cancel() + job = null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIncBus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIncBus.kt new file mode 100644 index 0000000000..080042dc3f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIncBus.kt @@ -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.napplet + +import android.os.Messenger +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson + +/** + * The NAP `inc` bus: a topic pub/sub that fans an `inc.emit` out to **other** napplet sessions + * subscribed to that topic as an `inc.event` push (never echoing back to the sender). Lives in the + * main-process broker service, which is the single point every sandbox session binds to, so it can + * route between them. + * + * Subscribers are keyed by their reply [Messenger] (one per live napplet host). A napplet must have + * declared the `inc` capability to subscribe or emit (enforced by the router before we get here). + * + * NOTE: Amethyst runs napplets **foreground-only, one at a time**, so in practice two sessions rarely + * overlap and cross-napplet delivery is usually a no-op — but the bus is correct if they ever do. + * The bus is app-wide (not scoped per author), so it deliberately allows different napplets to talk; + * a future refinement could namespace topics by author if cross-napplet isolation is wanted. + */ +class NappletIncBus( + private val deliver: (Messenger, String) -> Unit, +) { + // topic -> the reply Messengers of the napplet sessions subscribed to it. + private val subscribers = HashMap>() + + @Synchronized + fun subscribe( + who: Messenger, + topic: String, + ) { + subscribers.getOrPut(topic) { mutableSetOf() }.add(who) + } + + @Synchronized + fun unsubscribe( + who: Messenger, + topic: String, + ) { + subscribers[topic]?.let { set -> + set.remove(who) + if (set.isEmpty()) subscribers.remove(topic) + } + } + + /** Delivers an `inc.event` for [topic] to every subscriber except [from] (no self-echo). */ + @Synchronized + fun emit( + from: Messenger, + sender: String, + topic: String, + payloadRaw: String, + ) { + val set = subscribers[topic] ?: return + if (set.isEmpty()) return + val envelope = NappletProtocolJson.encodeIncEvent(topic, payloadRaw, sender) + set.filter { it != from }.forEach { deliver(it, envelope) } + } + + /** Drops [who] from every topic (e.g. when its napplet host goes away). */ + @Synchronized + fun removeAll(who: Messenger) { + val empties = mutableListOf() + for ((topic, set) in subscribers) { + set.remove(who) + if (set.isEmpty()) empties.add(topic) + } + empties.forEach { subscribers.remove(it) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt new file mode 100644 index 0000000000..2b619b39ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import java.security.SecureRandom + +/** + * Main-process registry that binds a sandbox launch to its **trusted** identity + declared capability + * set, addressed by an unguessable launch token. + * + * Why: the broker (main process) must know *which* napplet a brokered request belongs to, but the + * request travels up from the sandboxed `:napplet` process, which is the very thing we don't fully + * trust (a WebView/renderer escape runs code there). If the identity rode along inside each IPC + * message, a compromised sandbox could simply claim another napplet's coordinate and read/act as it. + * + * Instead, [NappletLauncher] (main process) mints a random token here at launch time, hands only that + * token to the sandbox, and the broker resolves it back to the identity it was registered with. The + * sandbox only ever holds *its own* token, so even a fully compromised `:napplet` process can act as + * nothing but the napplet it was launched as. + * + * Both the launcher and [NappletBrokerService] run in the main process, so they share this singleton. + */ +object NappletLaunchRegistry { + data class Session( + val identity: NappletIdentity, + val declared: Set, + ) + + // Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The + // active napplet always re-touches its token, so only stale sessions are ever evicted. + private const val MAX_SESSIONS = 128 + private val sessions = + object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > MAX_SESSIONS + } + private val secureRandom = SecureRandom() + + @Synchronized + fun register( + identity: NappletIdentity, + declared: Set, + ): String { + val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey() + sessions[token] = Session(identity, declared) + return token + } + + @Synchronized + fun resolve(token: String?): Session? = token?.let { sessions[it] } + + @Synchronized + fun unregister(token: String?) { + token?.let { sessions.remove(it) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt new file mode 100644 index 0000000000..d7e6d15f57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.ThemeType +import com.vitorpamplona.amethyst.napplethost.HostProfile +import com.vitorpamplona.amethyst.napplethost.NappletHostActivity +import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +/** + * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only + * the verified manifest data the host needs to render and broker for the applet is passed — + * the declared `path → hash` map, the Blossom servers, the applet's identity coordinate, the + * declared capabilities, and a display title. No account state crosses into the sandbox process. + */ +object NappletLauncher { + /** Opens a NIP-5D napplet, forwarding its declared capabilities to the broker. */ + fun launch( + context: Context, + manifest: NappletManifest, + authorPubKey: HexKey, + identifier: String, + ) = launch( + context = context, + paths = manifest.paths(), + servers = manifest.servers(), + authorPubKey = authorPubKey, + identifier = identifier, + aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), + title = manifest.title() ?: identifier.ifBlank { "Napplet" }, + requires = manifest.requires(), + ) + + /** + * Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite — + * the broker then refuses every capability, so the site renders as inert static content. + */ + fun launch( + context: Context, + paths: List, + servers: List, + authorPubKey: HexKey, + identifier: String, + aggregateHash: HexKey?, + title: String, + requires: List, + // nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The + // broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless + // of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET]. + profile: HostProfile = HostProfile.NAPPLET, + ) { + val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) + val intent = + Intent(context, NappletHostActivity::class.java).apply { + putExtras(params) + if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + + /** + * Builds the verified launch parameters — minting the launch token, augmenting the Blossom server + * set, resolving the per-site Tor choice and capability labels — as a [Bundle] keyed by the + * [NappletHostContract] EXTRA_* names. Used both for the activity intent (above) and for the + * embedded [com.vitorpamplona.amethyst.napplethost.NappletHostService] session (carried over + * Messenger), so the two host paths launch from identical, main-process-minted parameters. + */ + fun buildLaunchParams( + context: Context, + paths: List, + servers: List, + authorPubKey: HexKey, + identifier: String, + aggregateHash: HexKey?, + title: String, + requires: List, + profile: HostProfile, + ): Bundle { + val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + + // Augment the manifest's servers with the author's published Blossom list (kind:10063), if + // we already hold it, so a blob the manifest's servers dropped can still be fetched. The + // host re-verifies every blob's sha256, so a wrong/extra server can never inject content. + val authorBlossomServers = + runCatching { + (LocalCache.getAddressableNoteIfExists(BlossomServersEvent.createAddressTag(authorPubKey))?.event as? BlossomServersEvent)?.servers() + }.getOrNull().orEmpty() + val allServers = (servers + authorBlossomServers).distinct() + + // Mint the launch token in the (trusted) main process: the broker resolves the sandbox's + // requests back to THIS identity + declared set, regardless of anything the sandbox sends. + val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash) + val declared = profile.declaredCapabilities(requires) + val launchToken = NappletLaunchRegistry.register(identity, declared) + + // Resolve the per-site network choice (Tor default; a site can be opted out to the open web). + // Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle. + NappletNetworkRegistry.init(context.applicationContext) + val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true + + // Resolve capability labels here (the app has the resources) so the sandbox module needs none. + val capLabels = declared.map { context.getString(it.labelRes()) } + + val themeType = Amethyst.instance.uiPrefs.value.theme.value + val theme = + when (themeType) { + ThemeType.DARK -> "DARK" + ThemeType.LIGHT -> "LIGHT" + ThemeType.SYSTEM -> { + val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT" + } + } + + return Bundle().apply { + putStringArrayList(NappletHostContract.EXTRA_PATHS, ArrayList(paths.map { it.path })) + putStringArrayList(NappletHostContract.EXTRA_HASHES, ArrayList(paths.map { it.hash })) + putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers)) + putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey) + putString(NappletHostContract.EXTRA_IDENTIFIER, identifier) + putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash) + putString(NappletHostContract.EXTRA_TITLE, title) + putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires)) + putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels)) + putString(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken) + putInt(NappletHostContract.EXTRA_PROXY_PORT, proxyPort) + putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name) + putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor) + putString(NappletHostContract.EXTRA_THEME, theme) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt new file mode 100644 index 0000000000..1115bd026e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * The registry of live relay subscriptions an applet has open, keyed by its `subId`. Each entry + * holds the exact [INostrClient] that opened it, so teardown unsubscribes from the right account + * even after an account switch, plus an EOSE latch so a multi-relay subscription emits a single + * `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the + * caller-supplied sink — it never touches the transport itself. + * + * [account] is read live (so it always targets the currently signed-in account); [open] is reached + * only after the broker authorized the subscription (RELAY consent). + */ +class NappletLiveSubscriptions( + private val account: () -> Account?, +) { + private val liveSubs = ConcurrentHashMap() + private val liveSeq = AtomicInteger(0) + + private class LiveSub( + val clientSubId: String, + val client: INostrClient, + ) { + val eoseSent = AtomicBoolean(false) + } + + /** + * Opens a live relay subscription for [nappletSubId], streaming `relay.event`/`relay.eose`/ + * `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription for + * the same id. With no account/relays/filters it pushes a single empty EOSE to close it. + */ + fun open( + nappletSubId: String, + filters: List, + push: (String) -> Unit, + ) { + val account = account() + val relays = account?.homeRelays?.flow?.value ?: emptySet() + if (account == null || filters.isEmpty() || relays.isEmpty()) { + push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + return + } + + close(nappletSubId) + // liveSeq guarantees a unique client subId, so a rapid re-open of the same applet subId + // can't collide with the subscription it's replacing. + val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client) + liveSubs[nappletSubId] = sub + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) + + // A subscription fans out to several relays; collapse their EOSEs into the single + // relay.eose the SDK expects (fired when the first relay finishes its stored events). + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) + } + + runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } + } + + /** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */ + fun close(nappletSubId: String) { + val sub = liveSubs.remove(nappletSubId) ?: return + runCatching { sub.client.unsubscribe(sub.clientSubId) } + } + + /** Tears down every open subscription (service teardown). */ + fun closeAll() { + liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } } + liveSubs.clear() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt new file mode 100644 index 0000000000..067661e93e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +/** + * Looks up the best-effort human title and icon URL for a napplet or nsite from the local cache. + * Falls back to the d-identifier or [untitled] when no manifest is cached. + */ +fun resolveNappletMeta( + author: String, + identifier: String, + untitled: String, +): Pair { + val events = + Amethyst.instance.cache + .filter( + Filter( + kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND), + authors = listOf(author), + ), + ).mapNotNull { it.event } + val match = + events.firstOrNull { ev -> + when (ev) { + is NamedNappletEvent -> ev.identifier() == identifier + is RootNappletEvent -> identifier.isEmpty() + is NamedSiteEvent -> ev.identifier() == identifier + is RootSiteEvent -> identifier.isEmpty() + else -> false + } + } + val title = + when (match) { + is NappletManifest -> match.title() + is RootSiteEvent -> match.title() + is NamedSiteEvent -> match.title() + else -> null + }?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = + when (match) { + is NappletManifest -> match.icon() + is RootSiteEvent -> match.icon() + is NamedSiteEvent -> match.icon() + else -> null + }?.ifBlank { null } + return title to iconUrl +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt new file mode 100644 index 0000000000..3dc4e46da8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt @@ -0,0 +1,103 @@ +/* + * 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.napplet + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network") + +/** + * Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default) + * or over the **open web**. Keyed by the site's coordinate (`author:identifier`), like the permission + * ledger, so the choice survives across launches and routine code updates. + * + * When Tor is active, nSites route through it by default; a user can opt a specific site out (e.g. a + * site that breaks or is unusably slow over Tor) from the sandbox top bar. "Open web" makes + * *everything* for that site direct — both its live web traffic and its blob fetches. + * + * An in-memory map is authoritative for the session ([NappletLauncher] reads it synchronously while + * building the launch intent) with write-through persistence. Absent = Tor (the safe default), so a + * site never silently starts on the open web before the on-disk preference has hydrated. + * + * Lives only in the **main process**: the launcher reads it, and [NappletBrokerService] writes it + * when the key-free `:napplet` sandbox relays a toggle over IPC. The sandbox never touches it. + */ +object NappletNetworkRegistry { + private const val OPEN_WEB = "OPEN" + private const val TOR = "TOR" + + // coordinate -> useTor. Absent means "never chosen" -> Tor. + private val modes = ConcurrentHashMap() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile private var appContext: Context? = null + + @Volatile private var hydration: Job? = null + + /** Binds the app context and hydrates the on-disk preferences into memory. Idempotent. */ + fun init(context: Context) { + if (appContext != null) return + val ctx = context.applicationContext + appContext = ctx + hydration = + scope.launch { + ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) -> + // putIfAbsent: never clobber a choice made in this session before hydration finished. + modes.putIfAbsent(key.name, value != OPEN_WEB) + } + } + } + + /** + * Suspends until [init]'s on-disk hydration has finished, so [useTor] reflects the user's remembered + * per-site choices instead of the bare Tor default. The startup preloader awaits this before making + * any routing decision, so a site the user pinned to the open web isn't wrongly treated as Tor on a + * cold start. Returns at once once hydrated, or immediately if [init] was never called. + */ + suspend fun awaitReady() { + hydration?.join() + } + + /** Whether [coordinate] routes through Tor. Defaults to true (Tor) for any site never set. */ + fun useTor(coordinate: String): Boolean = modes[coordinate] ?: true + + /** Records [useTor] for [coordinate] in memory and persists it. */ + fun set( + coordinate: String, + useTor: Boolean, + ) { + modes[coordinate] = useTor + val ctx = appContext ?: return + scope.launch { + ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNotificationStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNotificationStore.kt new file mode 100644 index 0000000000..158af6bc46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNotificationStore.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.commons.napplet.NappletNotification +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Per-coordinate registry of the notifications a napplet created via the NAP `notify` domain. Lives + * in the main process (the broker's process) and survives broker rebuilds on account switch, so a + * napplet's `notify.list`/`notify.dismiss` see the same set it created. + * + * Namespaced by applet coordinate, like [DataStoreNappletStorage]: a napplet can only ever see and + * dismiss its **own** notifications. In-memory only — notifications are ephemeral UI, not durable state. + */ +object NappletNotificationStore { + // coordinate -> (id -> notification), insertion-ordered so list() is stable. + private val byCoordinate = ConcurrentHashMap>() + private val seq = AtomicLong(0) + + fun create( + coordinate: String, + title: String, + body: String, + ): String { + val id = "n${System.currentTimeMillis()}-${seq.incrementAndGet()}" + val map = byCoordinate.getOrPut(coordinate) { LinkedHashMap() } + synchronized(map) { map[id] = NappletNotification(id, title, body) } + return id + } + + fun list(coordinate: String): List { + val map = byCoordinate[coordinate] ?: return emptyList() + return synchronized(map) { map.values.toList() } + } + + fun dismiss( + coordinate: String, + id: String, + ) { + val map = byCoordinate[coordinate] ?: return + synchronized(map) { map.remove(id) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt new file mode 100644 index 0000000000..6fa8bad403 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -0,0 +1,325 @@ +/* + * 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.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.quartz.utils.TimeUtils + +class NappletSignerConsentActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletSignerConsentDialog( + info = info, + onGrant = { grant -> + decided = true + NappletSignerConsentCoordinator.complete(token, grant) + finish() + }, + onDismiss = { + decided = true + NappletSignerConsentCoordinator.cancel(token) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletSignerConsentDialog( + info: NappletSignerConsentInfo, + onGrant: (SignerOpGrant) -> Unit, + onDismiss: () -> Unit, +) { + var showRawData by remember { mutableStateOf(false) } + var showMoreOptions by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(scrollState) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + title + description + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isBrowser = info.coordinate.startsWith("browser:") + FavoriteAppIcon( + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.napplet_consent_wants_to, info.operationSummary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() + if (hasContent) { + Spacer(Modifier.height(12.dp)) + Surface( + modifier = + Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp)) { + if (info.contentPreview.isNotBlank()) { + Text( + "“${info.contentPreview}”", + style = MaterialTheme.typography.bodySmall, + ) + } + if (info.rawData.isNotBlank()) { + if (showRawData) { + Spacer(Modifier.height(8.dp)) + Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { + SelectionContainer { + Text( + info.rawData, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = false, + ) + } + } + } + TextButton( + onClick = { showRawData = !showRawData }, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp), + ) { + Text( + if (showRawData) { + stringResource(R.string.napplet_consent_hide_event) + } else { + stringResource(R.string.napplet_consent_show_event) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + // Primary: always allow this op + Button( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } + + // Secondary: allow just once + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowOnce) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_once)) + } + + // "More options" toggle: session and time-bound grants + TextButton( + onClick = { showMoreOptions = !showMoreOptions }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + if (showMoreOptions) { + stringResource(R.string.napplet_consent_fewer_options) + } else { + stringResource(R.string.napplet_consent_more_options) + }, + style = MaterialTheme.typography.bodyMedium, + ) + Icon( + if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + + if (showMoreOptions) { + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_session)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_24h)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_30d)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowAll) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_all)) + } + } + + Spacer(Modifier.height(4.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + onClick = { onGrant(SignerOpGrant.DenyOnce) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.napplet_signer_deny_once)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt new file mode 100644 index 0000000000..9febb3b0f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt @@ -0,0 +1,94 @@ +/* + * 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.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the per-operation consent dialog needs to render. */ +data class NappletSignerConsentInfo( + val appletTitle: String, + val coordinate: String, + val op: NostrSignerOp, + val operationSummary: String, + /** Short excerpt shown in the dialog body (≤ 160 chars). */ + val contentPreview: String, + /** + * Full raw content for the "See more" toggle — event JSON for sign/encrypt operations, + * decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose). + */ + val rawData: String = "", + val iconUrl: String? = null, +) + +/** + * Bridges the broker to the per-operation signer consent UI. + * A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed. + */ +object NappletSignerConsentCoordinator { + private class Pending( + val info: NappletSignerConsentInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConsent( + context: Context, + info: NappletSignerConsentInfo, + ): SignerOpGrant { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletSignerConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info + + fun complete( + token: String, + grant: SignerOpGrant, + ) { + pending[token]?.deferred?.complete(grant) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce) + } + + const val EXTRA_TOKEN = "napplet_signer_consent_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt new file mode 100644 index 0000000000..9c7bb146d9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -0,0 +1,118 @@ +/* + * 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.napplet + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** Human-readable label for a [NostrSignerOp]. */ +fun NostrSignerOp.label(context: Context): String = + when (this) { + is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind) + NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt) + NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) + } + +/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */ +fun buildSignerConsentInfo( + context: Context, + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, +): NappletSignerConsentInfo { + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } + val summary = op.label(context) + val preview = + when (request) { + is NappletRequest.Publish -> request.content.take(160).trim() + is NappletRequest.SignEvent -> request.content.take(160).trim() + is NappletRequest.PublishEncrypted -> request.content.take(160).trim() + else -> "" + } + val rawData = + when (request) { + is NappletRequest.Publish -> + JacksonMapper.toJsonPretty(EventTemplate(TimeUtils.now(), request.kind, request.tags, request.content)) + is NappletRequest.SignEvent -> + JacksonMapper.toJsonPretty(EventTemplate(request.createdAt, request.kind, request.tags, request.content)) + is NappletRequest.PublishEncrypted -> { + val node = JacksonMapper.mapper.createObjectNode() + node.put("kind", request.kind) + node.put("recipient", request.recipient) + node.put("encryption", request.encryption) + val tagsNode = node.putArray("tags") + for (tag in request.tags) { + val tagNode = tagsNode.addArray() + for (item in tag) tagNode.add(item) + } + node.put("content", request.content) + JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + } + else -> "" + } + return NappletSignerConsentInfo( + appletTitle = title, + coordinate = identity.coordinate, + op = op, + operationSummary = summary, + contentPreview = preview, + rawData = rawData, + iconUrl = iconUrl, + ) +} + +/** Creates a [NappletConnectInfo] for the first-connect dialog. */ +fun buildConnectInfo( + context: Context, + identity: NappletIdentity, +): NappletConnectInfo { + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } + val domain = + if (identity.authorPubKey == "browser") { + OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + } else { + identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } + } + return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/SandboxForegroundHold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/SandboxForegroundHold.kt new file mode 100644 index 0000000000..7257b3c043 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/SandboxForegroundHold.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Keeps Amethyst's **main** process in "resumed" resource posture while a full-screen sandbox surface + * (a `:napplet`-process browser/napplet/nSite host) is in the foreground. + * + * The problem it solves: opening one of those full-screen surfaces backgrounds `MainActivity`. Its + * Compose collectors (`ManageRelayServices` / `ManageWebOkHttp`) are lifecycle-aware, so they stop — + * and the underlying `WhileSubscribed` flows then scale everything down on their timers: Tor's port + * after ~2 s, the relay pool after ~30 s, dropping the relay AUTH sessions with it. The user is still + * actively using a Nostr surface (the sandbox brokers NIP-07 + relays back through this very process), + * so tearing the network down is wrong — the next signed request or relay read pays a full reconnect. + * + * The fix is a **ref-counted hold**, acquired only while such a surface is foreground. While the count + * is > 0 it subscribes to exactly the flows the resumed UI subscribes to — keeping Tor, the relay pool, + * and the AUTH sessions up. When the last surface leaves, it releases and normal background scaling + * resumes. It deliberately does *not* keep anything alive when nothing is foreground, so the intended + * battery/bandwidth scaling is preserved. + * + * Switching between several sandbox surfaces must not restart the network. Android tears the old + * surface down and brings the new one up as `old.onPause() → new.onResume()`, which would dip the + * count to 0 and back to 1. To keep the same collectors running straight through that dip, releasing to + * 0 only *schedules* the stop after a short [LINGER_MS] linger; a re-acquire within the window cancels + * the pending stop and the existing collectors are never even cancelled — so swapping between open + * napplets/nSites/browser apps holds Tor + relays + AUTH perfectly steady. + * + * Embedded favorite tabs need no hold: they render inside `MainActivity`, which stays resumed. + * + * Threading: [acquire]/[release] are `@Synchronized` and may be called from any thread (Activity main + * thread, broker IPC handler). The keep-alive collectors run on [AppModules.applicationIOScope]. + */ +object SandboxForegroundHold { + /** + * How long the resource hold lingers after the last sandbox surface backgrounds, before the + * collectors are actually torn down. Sized to comfortably bridge an activity-to-activity handoff + * (`old.onPause → new.onResume`, plus the async IPC hop for `:napplet` surfaces) so switching + * between open surfaces never restarts Tor/relays. A genuine exit still releases after this short + * window, restoring normal background scaling. + */ + private const val LINGER_MS = 5_000L + + private var holdCount = 0 + + // The live keep-alive collectors. Non-null whenever the resources are being held up (including + // during a linger after the count hit 0 but before the scheduled stop fires). + private var holdJob: Job? = null + + // The pending teardown scheduled when the count hit 0; cancelled if a surface re-acquires first. + private var stopJob: Job? = null + + @Synchronized + fun acquire() { + holdCount++ + if (holdCount == 1) { + // Cancel any lingering teardown and keep the existing collectors running if they're still + // up (the common "switch between two surfaces" path) — only start fresh if fully released. + stopJob?.cancel() + stopJob = null + if (holdJob == null) start() + } + } + + @Synchronized + fun release() { + if (holdCount == 0) return + holdCount-- + if (holdCount == 0) scheduleStop() + } + + private fun start() { + // Only ever invoked in the main process (broker + main-process activities); instance is set. + val app = Amethyst.instance + Log.d("SandboxForegroundHold", "Holding main-process resources up for a foreground sandbox surface") + holdJob = + app.applicationIOScope.launch { + // Mirror exactly what the resumed UI collects (AccountScreen's ManageRelayServices + + // ManageWebOkHttp): keeping these subscribed keeps the relay pool connected (and so the + // relay AUTH sessions), and — transitively, via the relay connector's combine and the + // okHttp proxy-port provider — keeps Tor up too. + launch { app.relayProxyClientConnector.relayServices.collect {} } + launch { app.okHttpClients.defaultHttpClient.collect {} } + launch { app.okHttpClients.defaultHttpClientWithoutProxy.collect {} } + } + } + + private fun scheduleStop() { + stopJob?.cancel() + stopJob = + Amethyst.instance.applicationIOScope.launch { + delay(LINGER_MS) + synchronized(this@SandboxForegroundHold) { + // A surface may have re-acquired while we waited; only tear down if still released. + if (holdCount == 0) { + Log.d("SandboxForegroundHold", "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold") + holdJob?.cancel() + holdJob = null + } + stopJob = null + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt new file mode 100644 index 0000000000..185b780e99 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt @@ -0,0 +1,106 @@ +/* + * 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.napplet + +import android.content.Context +import android.net.Uri +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "weburl_network") + +/** + * Per-web-client network-routing preference: whether a favorited URL / browsed site routes through + * **Tor** (the default when Tor is active) or over the **open web**. The web-client sibling of + * [NappletNetworkRegistry] — same model, but keyed by the site's **host** (so every page of a site + * shares the choice and it survives across launches). + * + * This exists because some sites' servers reject Tor exit connections; the user toggles such a site to + * the open web once from its control puck, and we must remember that so it doesn't break again next + * time. Absent = Tor (the safe default). + * + * Lives only in the **main process** (where the browser chrome runs); an in-memory map is authoritative + * for the session with write-through persistence. + */ +object WebAppNetworkRegistry { + private const val OPEN_WEB = "OPEN" + private const val TOR = "TOR" + + // host -> useTor. Absent means "never chosen" -> Tor. + private val modes = ConcurrentHashMap() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile private var appContext: Context? = null + + @Volatile private var hydration: Job? = null + + /** Binds the app context and hydrates the on-disk preferences into memory. Idempotent. */ + fun init(context: Context) { + if (appContext != null) return + val ctx = context.applicationContext + appContext = ctx + hydration = + scope.launch { + ctx.webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) -> + // putIfAbsent: never clobber a choice made in this session before hydration finished. + modes.putIfAbsent(key.name, value != OPEN_WEB) + } + } + } + + /** + * Suspends until [init]'s on-disk hydration has finished, so [useTor] reflects the user's remembered + * per-site choices instead of the bare Tor default. The startup preloader awaits this before making + * any routing decision, so a site the user pinned to the open web isn't wrongly treated as Tor on a + * cold start (which would leave a Tor-incompatible site failing until the next visit). Returns at once + * once hydrated, or immediately if [init] was never called. + */ + suspend fun awaitReady() { + hydration?.join() + } + + /** The host key for [url] (e.g. `vitorpamplona.com`), or the raw string if it has no host. */ + fun hostKeyOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + + /** Whether the site behind [url] routes through Tor. Defaults to true (Tor) for any site never set. */ + fun useTor(url: String): Boolean = modes[hostKeyOf(url)] ?: true + + /** Records [useTor] for the host behind [url] in memory and persists it. */ + fun set( + url: String, + useTor: Boolean, + ) { + val host = hostKeyOf(url) + modes[host] = useTor + val ctx = appContext ?: return + scope.launch { + ctx.webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt new file mode 100644 index 0000000000..369a7b95c9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt @@ -0,0 +1,196 @@ +/* + * 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.napplet.gateways + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Reads non-key identity data from an account as JSON value strings, for the napplet `identity.*` + * domain. Everything here is **public** profile/list data — never key material. Returns the literal + * `"null"` for an absent value, or `null` for a method this shell does not implement yet (the broker + * then answers `Unsupported`). Shapes match `@napplet/nap` (e.g. `displayName`, not `display_name`). + * + * List/zap/badge reads draw from what Amethyst already has locally ([Account.cache]) — the same + * "what we know now" semantics as `getFollows`/`getMutes` — so they never block on a relay round + * trip. Private (encrypted) list entries are not included; only the public tag values. + */ +class AccountIdentityReader( + private val account: Account, +) { + private val cache get() = account.cache + private val pubkey get() = account.signer.pubKey + + fun read( + method: String, + argument: String?, + ): String? = + when (method) { + "getProfile" -> profileJson() + "getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors) + "getMutes" -> + jsonStringArray( + // PUBLIC mutes only — the decrypted flow (muteList.flow) also holds the user's + // private mutes, which a napplet must never see. Read the event's plaintext tags. + account.muteList + .getMuteList() + ?.publicMutes() + ?.filterIsInstance() + ?.map { it.pubKey } + .orEmpty(), + ) + "getBlocked" -> + jsonStringArray( + // PUBLIC blocks only, for the same reason as getMutes. + account.blockPeopleList + .getBlockList() + ?.publicUsersIdSet() + .orEmpty(), + ) + "getRelays" -> relaysJson() + "getList" -> listJson(argument) + "getZaps" -> zapsJson() + "getBadges" -> badgesJson() + else -> null + } + + private fun jsonStringArray(items: Iterable): String = buildJsonArray { items.forEach { add(it) } }.toString() + + /** Builds a `@napplet/nap` `ProfileData` object (note `displayName`, not `display_name`) from kind-0. */ + private fun profileJson(): String { + val md = account.userMetadata.getUserMetadataEvent()?.contactMetaData() ?: return "null" + return buildJsonObject { + md.name?.let { put("name", it) } + md.displayName?.let { put("displayName", it) } + md.about?.let { put("about", it) } + md.picture?.let { put("picture", it) } + md.banner?.let { put("banner", it) } + md.nip05?.let { put("nip05", it) } + md.lud16?.let { put("lud16", it) } + md.website?.let { put("website", it) } + }.toString() + } + + /** Builds `{ "": { "read": bool, "write": bool }, ... }` from the user's NIP-65 list. */ + private fun relaysJson(): String { + val relays = account.nip65RelayList.getNIP65RelayList()?.relays() ?: return "null" + return buildJsonObject { + relays.forEach { info -> + putJsonObject(info.relayUrl.url) { + put("read", info.type.isRead()) + put("write", info.type.isWrite()) + } + } + }.toString() + } + + /** + * `identity.getList(listType)` → the public tag values of the user's NIP-51 list of that type + * (e.g. the e/a entries of "bookmarks", the t hashtags of "interests"). Unknown list types → null. + */ + private fun listJson(listType: String?): String? { + val kind = listKinds[listType?.trim()?.lowercase()] ?: return null + val event = + cache + .filter(Filter(kinds = listOf(kind), authors = listOf(pubkey))) + .mapNotNull { it.event } + .maxByOrNull { it.createdAt } + val entries = + event + ?.tags + ?.filter { it.size >= 2 && it[0] in ENTRY_TAGS } + ?.map { it[1] } + .orEmpty() + return buildJsonArray { entries.forEach { add(it) } }.toString() + } + + /** `identity.getZaps` → `ZapReceipt[]` ({eventId, sender, amount, content?}) the user received, from cache. */ + private fun zapsJson(): String { + val receipts = + cache + .filter(Filter(kinds = listOf(9735), tags = mapOf("p" to listOf(pubkey)))) + .mapNotNull { it.event as? LnZapEvent } + return buildJsonArray { + receipts.forEach { zap -> + addJsonObject { + put("eventId", zap.zappedPost().firstOrNull() ?: "") + put("sender", zap.zappedRequestAuthor() ?: "") + put("amount", zap.amount() ?: 0L) + zap.zapRequest + ?.content + ?.takeIf { it.isNotBlank() } + ?.let { put("content", it) } + } + } + }.toString() + } + + /** `identity.getBadges` → `Badge[]` ({id, name?, description?, image?, thumbs?, awardedBy}) awarded to the user, from cache. */ + private fun badgesJson(): String { + val awards = + cache + .filter(Filter(kinds = listOf(8), tags = mapOf("p" to listOf(pubkey)))) + .mapNotNull { it.event as? BadgeAwardEvent } + return buildJsonArray { + awards.forEach { award -> + val address = award.awardDefinition().firstOrNull() ?: return@forEach + val definition = cache.getAddressableNoteIfExists(address)?.event as? BadgeDefinitionEvent + addJsonObject { + put("id", definition?.dTag() ?: address.dTag) + definition?.name()?.let { put("name", it) } + definition?.description()?.let { put("description", it) } + definition?.image()?.let { put("image", it) } + definition?.thumb()?.let { thumb -> put("thumbs", buildJsonArray { add(thumb) }) } + put("awardedBy", award.pubKey) + } + } + }.toString() + } + + companion object { + /** NIP-51 replaceable list kinds keyed by the napplet list-type string. */ + private val listKinds = + mapOf( + "mute" to 10000, + "mutes" to 10000, + "pins" to 10001, + "pin" to 10001, + "bookmarks" to 10003, + "communities" to 10004, + "channels" to 10005, + "interests" to 10015, + "emojis" to 10030, + ) + + /** Tag names whose value is a list entry (vs metadata like d/title/description). */ + private val ENTRY_TAGS = setOf("e", "a", "p", "t", "word", "r", "emoji") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt new file mode 100644 index 0000000000..89bf3ecf31 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -0,0 +1,297 @@ +/* + * 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.napplet.gateways + +import android.content.Context +import android.content.res.Configuration +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.napplet.NappletBroker +import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletNotification +import com.vitorpamplona.amethyst.commons.napplet.NappletNotifyGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletResourceGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletStorage +import com.vitorpamplona.amethyst.commons.napplet.NappletThemeColors +import com.vitorpamplona.amethyst.commons.napplet.NappletThemeGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult +import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator +import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator +import com.vitorpamplona.amethyst.napplet.NappletConsentSummary +import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator +import com.vitorpamplona.amethyst.napplet.buildConnectInfo +import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import java.io.ByteArrayInputStream +import kotlin.time.Duration.Companion.seconds + +/** + * The account adapter: turns a signed-in [Account] into a configured [NappletBroker] by wiring the + * platform gateway implementations (relay publish/query, consent, wallet/NWC, resource fetch, + * identity reads, blob upload). The broker itself owns the trust boundary; this class only supplies + * the Android/account-backed plumbing. Built once per account and cached by the caller. + */ +class AccountNappletGateways( + private val account: Account, + private val context: Context, + private val ledger: NappletPermissionLedger, + private val storage: NappletStorage, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, + private val signerLedger: NostrSignerPermissionLedger? = null, +) { + private val consentSummary = NappletConsentSummary(context) + + // Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor + // routing, Onion-Location discovery/rewriting, Blossom cache and pool as the + // rest of the app, instead of a private client that has to re-wire all of it. + private val resourceFetcher = NappletResourceFetcher(account, httpClient) + private val identityReader = AccountIdentityReader(account) + + fun broker(): NappletBroker { + val relay = + object : NappletRelayGateway { + override suspend fun publish(event: Event): List { + val relays = account.computeRelayListToBroadcast(event) + account.client.publish(event, relays) + return relays.map { it.url } + } + + override suspend fun query(filters: List): List = queryEvents(filters) + } + + val consent = + NappletConsentPrompt { identity, capability, request -> + NappletConsentCoordinator.requestConsent( + context = context, + info = consentSummary.info(identity, capability, request), + ) + } + + val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(invoice) } + val resource = NappletResourceGateway { url, coordinate -> resourceFetcher.fetch(url, coordinate) } + val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) } + val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) } + val theme = NappletThemeGateway { currentThemeColors() } + val notify = + object : NappletNotifyGateway { + override suspend fun create( + coordinate: String, + title: String, + body: String, + ): String { + val id = NappletNotificationStore.create(coordinate, title, body) + runCatching { postSystemNotification(id, title, body) } + return id + } + + override suspend fun list(coordinate: String): List = NappletNotificationStore.list(coordinate) + + override suspend fun dismiss( + coordinate: String, + id: String, + ) { + NappletNotificationStore.dismiss(coordinate, id) + runCatching { NotificationManagerCompat.from(context).cancel(id.hashCode()) } + } + } + + val connectPrompt = + NostrConnectPrompt { identity -> + NappletConnectCoordinator.requestConnect( + context = context, + info = buildConnectInfo(context, identity), + ) + } + + val signerConsent = + NostrSignerConsentPrompt { identity, op, request -> + NappletSignerConsentCoordinator.requestConsent( + context = context, + info = buildSignerConsentInfo(context, identity, op, request), + ) + } + + return NappletBroker(account.signer, ledger, consent, signerLedger = signerLedger, nostrConnectPrompt = connectPrompt, signerConsentPrompt = signerConsent, relay = relay, storage = storage, wallet = wallet, resource = resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify) + } + + /** + * Posts a napplet's notification to the system tray (best-effort: a missing POST_NOTIFICATIONS + * grant just no-ops). The in-app registry in [NappletNotificationStore] is the source of truth for + * the napplet's own `notify.list`/`dismiss`; this is the user-visible surface. + */ + private fun postSystemNotification( + id: String, + title: String, + body: String, + ) { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return + + val channel = + NotificationChannelCompat + .Builder(NOTIFY_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(context.getString(R.string.napplet_cap_notify)) + .build() + manager.createNotificationChannel(channel) + val notification = + NotificationCompat + .Builder(context, NOTIFY_CHANNEL_ID) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(title.ifBlank { context.getString(R.string.napplet_cap_notify) }) + .setContentText(body) + .setAutoCancel(true) + .build() + try { + manager.notify(id.hashCode(), notification) + } catch (_: SecurityException) { + // Best effort: if the user revoked permission between the check and here, just no-op. + } + } + + /** + * The host theme colors a napplet maps to its CSS variables (`theme.get`): Amethyst's brand + * purple plus a background/text pair chosen from the current dark/light mode. Read-only and + * cheap; the napplet falls back to its own defaults if this is ever unavailable. + */ + private fun currentThemeColors(): NappletThemeColors { + val night = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + return if (night) { + NappletThemeColors(background = "#0E0E10", text = "#E6E6E6", primary = AMETHYST_PURPLE) + } else { + NappletThemeColors(background = "#FFFFFF", text = "#1A1A1A", primary = AMETHYST_PURPLE) + } + } + + /** + * Uploads [bytes] to the user's first Blossom server (kind:10063) with a signed authorization + * event, via the app's existing [BlossomUploader]. Returns null when there's no server or the + * upload fails. Consent is enforced by the broker before this runs. + */ + private suspend fun uploadBlob( + bytes: ByteArray, + contentType: String, + filename: String?, + ): NappletUploadResult? { + val server = + account.blossomServers + .getBlossomServersList() + ?.servers() + ?.firstOrNull() ?: return null + val hash = sha256(bytes).toHexKey() + val result = + runCatching { + BlossomUploader().upload( + inputStream = ByteArrayInputStream(bytes), + hash = hash, + length = bytes.size.toLong(), + baseFileName = filename, + contentType = contentType, + alt = null, + sensitiveContent = null, + serverBaseUrl = server, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = { h, size, alt -> account.createBlossomUploadAuth(h, size, alt) }, + context = context, + ) + }.getOrNull() ?: return null + val url = result.url ?: return null + return NappletUploadResult(url, result.sha256, result.size, result.type) + } + + /** Bounded live relay fetch (EOSE/timeout) for all [filters], merged with the local cache, newest-first. */ + private suspend fun queryEvents(filters: List): List { + if (filters.isEmpty()) return emptyList() + val relays = account.homeRelays.flow.value + val fromRelays = + if (relays.isEmpty()) { + emptyList() + } else { + runCatching { + account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds) + }.getOrDefault(emptyList()) + } + val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } } + + val merged = + (fromRelays + fromCache) + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + val limit = filters.mapNotNull { it.limit }.maxOrNull() + return limit?.let { merged.take(it) } ?: merged + } + + /** + * Pays [invoice] via the user's connected NWC wallet, returning the preimage on success. + * Throws (→ `Failed`) when no wallet is connected, the wallet reports an error, or it does not + * respond in time — so the applet never silently believes a payment succeeded. + */ + private suspend fun payInvoiceViaNwc(invoice: String): String? { + if (account.nip47SignerState.defaultWalletUri.value == null) { + throw IllegalStateException("No Lightning wallet is connected.") + } + + val result = CompletableDeferred() + account.sendZapPaymentRequestFor(invoice, null) { response -> + when (response) { + is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage) + is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed.")) + is NwcErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Wallet error.")) + else -> result.completeExceptionally(RuntimeException("Unexpected wallet response.")) + } + } + return withTimeout(WALLET_TIMEOUT) { result.await() } + } + + companion object { + private val QUERY_TIMEOUT = 8.seconds + private val WALLET_TIMEOUT = 60.seconds + + /** Amethyst's brand purple (`AmethystPurple`, commons Colors.kt), exposed as the theme primary. */ + private const val AMETHYST_PURPLE = "#9A82DB" + + /** Notification channel for napplet-originated notifications (NAP `notify`). */ + private const val NOTIFY_CHANNEL_ID = "napplet_notifications" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt new file mode 100644 index 0000000000..a5e67039e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -0,0 +1,219 @@ +/* + * 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.napplet.gateways + +import android.util.Base64 +import com.vitorpamplona.amethyst.commons.napplet.NappletResource +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +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 +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.URLDecoder + +/** + * Fetches a resource URL on an applet's behalf — the applet has no direct network + * (`connect-src 'none'`), so every `resource.bytes` is brokered through here. Handles `data:`, + * `https:`, and `blossom:` URLs; blossom blobs are content-addressed and **sha256-verified** before + * returning, so a wrong server can never substitute the blob. + * + * Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared + * [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than + * standing up a private client — means napplet blob fetches inherit the same passive + * `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, connection pool + * and DNS as every other HTTP role. Tor-or-clearnet is chosen per request from the calling + * applet's [NappletNetworkRegistry] mode (locked napplets are pinned to Tor; nSites follow the + * user's per-site toggle), so a brokered fetch routes exactly like that applet's own page loads. + * Built per account (so it reads the right Blossom server list); consent is enforced by the + * broker before [fetch] ever runs. + */ +class NappletResourceFetcher( + private val account: Account, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, +) { + /** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */ + suspend fun fetch( + url: String, + coordinate: String, + ): NappletResource? = + withContext(Dispatchers.IO) { + // Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise. + NappletNetworkRegistry.awaitReady() + val client = httpClient(NappletNetworkRegistry.useTor(coordinate)) + when { + url.startsWith("data:") -> decodeDataUrl(url) + url.startsWith("https://") -> { + runCatching { + client + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { r -> + if (!r.isSuccessful) return@withContext null + val body = r.body.bytes() + val type = r.header("Content-Type") ?: "application/octet-stream" + NappletResource(body, type) + } + }.getOrNull() + } + url.startsWith("blossom:") -> fetchBlossom(url, client) + url.startsWith("nostr:") -> resolveNostr(url) + else -> null + } + } + + /** + * Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed` + * carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to + * a bounded relay fetch; `npub`/`nprofile` resolve the author's kind-0 metadata event. Returns + * the event JSON as `application/json`, or null when nothing resolves. + */ + private suspend fun resolveNostr(url: String): NappletResource? { + val entity = Nip19Parser.uriToRoute(url)?.entity ?: return null + val event = + when (entity) { + is NEmbed -> entity.event + is NNote -> resolveEvent(entity.hex) + is NEvent -> resolveEvent(entity.hex) + is NAddress -> resolveAddress(entity.address()) + is NPub -> resolveReplaceable(0, entity.hex) + is NProfile -> resolveReplaceable(0, entity.hex) + else -> null + } ?: return null + return NappletResource(event.toJson().encodeToByteArray(), "application/json") + } + + /** A non-replaceable event by id: local cache first, then a bounded relay fetch. */ + private suspend fun resolveEvent(id: String): Event? = + account.cache.getNoteIfExists(id)?.event + ?: fetchOne(Filter(ids = listOf(id))) + + /** A parameterized-replaceable event by address: local cache first, then a bounded relay fetch. */ + private suspend fun resolveAddress(address: Address): Event? = + account.cache.getAddressableNoteIfExists(address)?.event + ?: fetchOne(Filter(kinds = listOf(address.kind), authors = listOf(address.pubKeyHex), tags = mapOf("d" to listOf(address.dTag)))) + + /** A replaceable event (e.g. kind-0 metadata) by author: local cache first, then a bounded relay fetch. */ + private suspend fun resolveReplaceable( + kind: Int, + author: String, + ): Event? = + account.cache + .getAddressableNoteIfExists(Address(kind, author, "")) + ?.event + ?: fetchOne(Filter(kinds = listOf(kind), authors = listOf(author))) + + /** Bounded relay fetch for [filter]; returns the newest matching event, or null. */ + private suspend fun fetchOne(filter: Filter): Event? { + val relays = account.homeRelays.flow.value + if (relays.isEmpty()) return null + return runCatching { + account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS) + }.getOrDefault(emptyList()) + .maxByOrNull { it.createdAt } + } + + /** + * Fetches a `blossom:` (or `blossom://`) blob from the user's Blossom servers + * (kind:10063) over [client], verifying the sha256 before returning — content-addressed, so a + * wrong server can never substitute the blob. Returns null for a malformed hash or if no server + * serves it. + */ + private fun fetchBlossom( + url: String, + client: OkHttpClient, + ): NappletResource? { + val hash = + url + .removePrefix("blossom://") + .removePrefix("blossom:") + .substringBefore('/') + .substringBefore('?') + .trim() + .lowercase() + if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null + + val servers = + account.blossomServers + .getBlossomServersList() + ?.servers() + .orEmpty() + for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { + val bytes = + runCatching { + client + .newCall( + Request + .Builder() + .url(candidate) + .get() + .build(), + ).execute() + .use { r -> + if (r.isSuccessful) r.body.bytes() else null + } + }.getOrNull() ?: continue + if (StaticSiteResolver.verify(bytes, hash)) { + return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") + } + } + return null + } + + /** Parses a `data:[][;base64],` URL into bytes + content type. */ + private fun decodeDataUrl(url: String): NappletResource? { + val comma = url.indexOf(',') + if (comma < 0) return null + val meta = url.substring("data:".length, comma) + val data = url.substring(comma + 1) + val isBase64 = meta.endsWith(";base64") + val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } + val bytes = + if (isBase64) { + runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null + } else { + URLDecoder.decode(data, "UTF-8").encodeToByteArray() + } + return NappletResource(bytes, contentType) + } + + companion object { + private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index ef0fc8550e..5287ce9eb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -27,7 +27,11 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.UrlParser object CachedRichTextParser { - private val richTextCache = LruCache(50) + // Global across every feed. Sized to hold the active feed's visible + prefetched + // (see PrefetchFeedMedia) working set plus a few other feeds' recent entries, so + // pre-parsed bodies survive until the render reads them and feed switches don't + // thrash. Each entry is one note's parsed segments — typically single-digit KB. + private val richTextCache = LruCache(500) private val isMarkdownCache = LruCache(200) private fun hashCodeCache( @@ -37,7 +41,14 @@ object CachedRichTextParser { authorPubKey: String?, ): Int { var result = content.hashCode() - result = 31 * result + tags.lists.hashCode() + // Content-addressed (memoized contentHash), not identity — `lists` is an + // Array>, so a fresh instance with the same tag content must + // map to the same entry. This lets an off-thread pre-parse (the feed media + // prefetcher) populate the exact entry the renderer later looks up, turning + // the scroll-time parse into a cache hit. The parse is a pure function of + // tag content, so equal content sharing one entry is correct. The hash is + // cached on the instance so this stays O(1)-amortized per tag list. + result = 31 * result + tags.contentHash() if (callbackUri != null) { result = 31 * result + callbackUri.hashCode() } @@ -47,6 +58,12 @@ object CachedRichTextParser { return result } + fun trimToSize(maxItems: Int) { + richTextCache.trimToSize(maxItems) + // isMarkdownCache is sized at 40% of richTextCache; preserve the ratio + isMarkdownCache.trimToSize(maxItems * 2 / 5) + } + fun cachedText( content: String, tags: ImmutableListOfLists, @@ -196,7 +213,14 @@ object CachedRichTextParser { // checks that need to know "how much non-space text has // appeared on the current line". if (isNewLine) { - if (c != ' ' && c != '\t') { + // A blank line is still "at line start": skipping '\n'/'\r' + // here keeps isNewLine true so a heading/blockquote/list + // marker on the next line (the standard `\n\n#` spacing) is + // still recognized as line-leading. Without the newline + // exclusion, the second '\n' of a blank line flipped + // isNewLine to false and ATX headings after a blank line + // went undetected. + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { isNewLine = false nonSpaceCharCountOnLine = 1 lastNonSpaceChar = c diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt new file mode 100644 index 0000000000..664b55ed61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -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() + // 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> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt new file mode 100644 index 0000000000..28a0d64fb0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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() + // 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> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 9eb62ea3b7..977c0d5959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -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 { - 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, + pointer: NDebit, + onError: (String, String, User?) -> Unit, + onProgress: (percent: Float) -> Unit, + context: Context, + ): List { + 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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt index 887e7f9c6f..690c4ed46c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.service.cashu import android.util.LruCache -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken -import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import kotlinx.collections.immutable.ImmutableList object CachedCashuParser { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt index cb76b27bcb..42f09e77dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt @@ -20,25 +20,28 @@ */ package com.vitorpamplona.amethyst.service.cashu -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken -import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser -import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +/** + * Thin Amethyst adapter over the protocol-level [CashuTokenB64Parser] in + * Quartz: it adds the off-main-thread guard and wraps the result in the UI's + * [GenericLoadable] so composables can render loading/error/loaded states. + */ class CashuParser { fun parse(cashuToken: String): GenericLoadable> { checkNotInMainThread() - if (cashuToken.startsWith("cashuA")) { - return V3Parser.parseCashuA(cashuToken) - } + val parsed = + CashuTokenB64Parser.parse(cashuToken) + ?: return GenericLoadable.Error("Could not parse this cashu token") - if (cashuToken.startsWith("cashuB")) { - return V4Parser.parseCashuB(cashuToken) - } + if (parsed.isEmpty()) return GenericLoadable.Error("No token found") - return GenericLoadable.Error("Could not parse this cashu token") + return GenericLoadable.Loaded(parsed.toImmutableList()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index 88a0d471c2..d9d13813de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -21,22 +21,29 @@ package com.vitorpamplona.amethyst.service.cashu.melt import android.content.Context -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.utils.asTextOrNull -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType +import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.coroutines.executeAsync import kotlin.coroutines.cancellation.CancellationException +/** + * Redeems an externally-received cashu token straight to a Lightning address + * via NUT-05 melt (`POST /v1/melt/quote/bolt11` + `POST /v1/melt/bolt11`). + * + * This is the "Redeem" button on a received cashu token preview — distinct + * from the NIP-60 wallet's own send-LN flow ([com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps.meltToLightning]), + * which spends the user's stored proofs and rolls change back into the wallet. + * Here there is no wallet: the proofs come straight off the pasted token and + * any leftover stays with the mint. + * + * Migrated off the deprecated pre-v1 `/melt` + `/checkfees` endpoints (gone on + * CDK and other modern mints) onto the same NUT-05 client the wallet uses, so + * it also picks up NUT-02 per-input fee handling for free. + */ class MeltProcessor { suspend fun melt( token: CashuToken, @@ -44,189 +51,65 @@ class MeltProcessor { okHttpClient: (String) -> OkHttpClient, context: Context, ): MeltResult { - val baseInvoice = - LightningAddressResolver().lnAddressInvoice( - lnAddress = lud16, - // Make invoice and leave room for fees - milliSats = token.totalAmount * 1000, - message = "Calculate Fees for Cashu", - okHttpClient = okHttpClient, - onProgress = {}, - context = context, - ) - - val fees = - feeCalculator( - mintAddress = token.mint, - invoice = baseInvoice, - okHttpClient = okHttpClient, - context = context, - ) - - val invoice = - LightningAddressResolver().lnAddressInvoice( - lnAddress = lud16, - // Make invoice and leave room for fees - milliSats = (token.totalAmount - fees) * 1000, - message = "Redeem Cashu", - okHttpClient = okHttpClient, - onProgress = {}, - context = context, - ) - - meltInvoice(token, invoice, okHttpClient, context) - - return MeltResult( - token = token, - invoice = invoice, - fees = fees, - ) - } - - fun melt( - token: CashuToken, - lud16: String, - okHttpClient: (String) -> OkHttpClient, - onSuccess: (String, String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - // TODO: Implement Cashu token melting via Lightning invoice - } - - suspend fun feeCalculator( - mintAddress: String, - invoice: String, - okHttpClient: (String) -> OkHttpClient, - context: Context, - ): Int = try { - val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint - val client = okHttpClient(url) + val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient)) + val proofs = token.proofs - val factory = JsonNodeFactory.instance + // A Lightning address must commit to an amount before we know the + // fees, so probe with an invoice for the full token value to learn + // the LN fee_reserve, then add the NUT-02 input fee the mint + // charges on these proofs. + val probeInvoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + milliSats = token.totalAmount * 1000, + message = "Calculate Fees for Cashu", + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + val probeQuote = ops.requestMeltQuote(probeInvoice) + val fees = probeQuote.feeReserve + ops.inputFeeFor(proofs) - val jsonObject = factory.objectNode() - jsonObject.put("pr", invoice) - - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = - Request - .Builder() - .url(url) - .post(requestBody) - .build() - - client.newCall(request).executeAsync().use { response -> - withContext(Dispatchers.IO) { - val body = response.body.string() - val tree = jacksonObjectMapper().readTree(body) - - val feeCost = tree?.get("fee")?.asInt() - - if (feeCost == null) { - val msg = - tree - ?.get("detail") - ?.asTextOrNull() - ?.split('.') - ?.getOrNull(0) - ?.ifBlank { null } - - throw LightningAddressResolver.LightningAddressError( - stringRes(context, R.string.cashu_failed_redemption), - if (msg != null) { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) - } - - feeCost - } + val sendable = token.totalAmount - fees + if (sendable <= 0) { + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_failed_redemption), + stringRes( + context, + R.string.cashu_failed_redemption_explainer_error_msg, + "Token value ${token.totalAmount} does not cover fees $fees", + ), + ) } + + // Real invoice for (total − fees), quote it, then melt without + // requesting change — there is no wallet to hold leftover proofs, + // so the unused fee_reserve stays with the mint. + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + milliSats = sendable * 1000, + message = "Redeem Cashu", + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + val quote = ops.requestMeltQuote(invoice) + ops.meltProofs(quote, proofs, requestChange = false) + + return MeltResult( + token = token, + invoice = invoice, + fees = fees.toInt(), + ) } catch (e: Exception) { if (e is CancellationException) throw e + if (e is LightningAddressResolver.LightningAddressError) throw e throw LightningAddressResolver.LightningAddressError( stringRes(context, R.string.cashu_failed_redemption), stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), ) } - - private suspend fun meltInvoice( - token: CashuToken, - invoice: String, - okHttpClient: (String) -> OkHttpClient, - context: Context, - ) { - try { - val url = token.mint + "/melt" // Melt cashu tokens at Mint - val client = okHttpClient(url) - - val factory = JsonNodeFactory.instance - - val jsonObject = factory.objectNode() - - jsonObject.replace( - "proofs", - factory.arrayNode(token.proofs.size).apply { - token.proofs.forEach { - addObject().apply { - put("amount", it.amount) - put("id", it.id) - put("secret", it.secret) - put("C", it.C) - } - } - }, - ) - - jsonObject.put("pr", invoice) - - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = - Request - .Builder() - .url(url) - .post(requestBody) - .build() - - client.newCall(request).executeAsync().use { response -> - withContext(Dispatchers.IO) { - val body = response.body.string() - val tree = jacksonObjectMapper().readTree(body) - - val successful = tree?.get("paid")?.asText() == "true" - - if (!successful) { - val msg = - tree - ?.get("detail") - ?.asTextOrNull() - ?.split('.') - ?.getOrNull(0) - ?.ifBlank { null } - - throw LightningAddressResolver.LightningAddressError( - stringRes(context, R.string.cashu_failed_redemption), - if (msg != null) { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) - } - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - throw LightningAddressResolver.LightningAddressError( - stringRes(context, R.string.cashu_successful_redemption), - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt index d700a6dd1e..ec31f05624 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.cashu.melt -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken class MeltResult( val token: CashuToken, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt deleted file mode 100644 index 1e7c4b98f7..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.cashu.v3 - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import java.util.Base64 -import kotlin.coroutines.cancellation.CancellationException - -class V3Parser { - companion object { - fun parseCashuA(cashuToken: String): GenericLoadable> { - try { - val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readValue(String(Base64.getDecoder().decode(base64token))) - - if (cashu.token == null) { - return GenericLoadable.Error("No token found") - } - - val converted = - cashu.token.map { token -> - val proofs = token.proofs - val mint = token.mint - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt deleted file mode 100644 index c5b29f2683..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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.cashu.v4 - -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.Proof -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.decodeFromByteArray -import java.util.Base64 -import kotlin.coroutines.cancellation.CancellationException - -class V4Parser { - companion object { - @OptIn(ExperimentalSerializationApi::class) - fun parseCashuB(cashuToken: String): GenericLoadable> { - try { - val base64token = cashuToken.replace("cashuB", "") - val parser = Cbor { ignoreUnknownKeys = true } - val v4Token = parser.decodeFromByteArray(Base64.getUrlDecoder().decode(base64token)) - val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found") - - val converted = - v4proofs.map { id -> - val proofs = - id.p.map { - Proof( - it.a, - id.i.toHexKey(), - it.s, - it.c.toHexKey(), - ) - } - val mint = v4Token.m - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index c2ebbb38ac..3c29d78546 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.eventCache +import android.content.ComponentCallbacks2 import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -32,33 +33,58 @@ class MemoryTrimmingService( ) { var isTrimmingMemoryMutex = AtomicBoolean(false) + /** + * Tiered pruning scaled to the OS memory-pressure level. + * + * Tier 1 — mild pressure (UI hidden, running-moderate): + * Sweep stale WeakRefs, drop expired and superseded-replaceable events. + * Safe to run frequently; no UI-visible side effects. + * + * Tier 2 — low memory (running-low, background): + * Tier 1 + old chat messages + unobserved thread replies / reactions. + * May cause feeds to re-fetch content that was scrolled past. + * + * Tier 3 — critical / imminent kill (running-critical, moderate, complete): + * Tier 2 + sever all observer links + drop every event from muted/blocked users. + * Aggressive; triggers recomposition wherever StateFlows were cleared. + */ private fun doTrim( account: Collection, otherAccounts: List, + level: Int, ) { + // Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are + // not currently held by the UI, so it is safe and inexpensive at any pressure level. cache.cleanMemory() cache.cleanObservers() + cache.pruneExpiredEvents() + cache.prunePastVersionsOfReplaceables() - account.forEach { - cache.pruneHiddenEvents(it) - cache.pruneHiddenMessages(it) + if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { + // Tier 2: medium pressure — drop events from muted/blocked users + account.forEach { + cache.pruneHiddenEvents(it) + cache.pruneHiddenMessages(it) + } } - val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() - cache.pruneOldMessages() - cache.pruneRepliesAndReactions(accounts) - cache.prunePastVersionsOfReplaceables() - cache.pruneExpiredEvents() + if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + // Tier 3: critical pressure — drop old messages and unobserved reactions + val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() + cache.pruneOldMessages() + cache.pruneRepliesAndReactions(accounts) + } } suspend fun run( account: Collection, otherAccounts: List, + level: Int = ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, ) { if (isTrimmingMemoryMutex.compareAndSet(false, true)) { - Log.d("ServiceManager", "Trimming Memory") + Log.d("ServiceManager", "Trimming Memory (level=$level)") try { - doTrim(account, otherAccounts) + doTrim(account, otherAccounts, level) } finally { isTrimmingMemoryMutex.getAndSet(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index 8b20239483..1a8cf64c42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -146,6 +146,9 @@ class ThumbnailDiskCache( } val tempFile = File(cacheDir, "$key.tmp") + // The cache dir can be wiped while the app runs (system cache trim, + // user "Clear cache"); recreate it or the write fails with ENOENT. + cacheDir.mkdirs() tempFile.outputStream().use { out -> scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt index 2425b34226..7e8e997b8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt @@ -30,6 +30,10 @@ import java.text.NumberFormat data class InvoiceAmount( val invoice: String, val amount: String?, + /** Free-text BOLT-11 description, when the invoice carries one. */ + val description: String? = null, + /** Expiration as epoch seconds, when the invoice decodes. */ + val expiresAt: Long? = null, ) object CachedLnInvoiceParser { @@ -49,7 +53,8 @@ object CachedLnInvoiceParser { null } - val lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) + val details = LnInvoiceUtil.parseDetails(myInvoice) + val lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount, details?.description, details?.expiresAt()) lnInvoicesCache.put(lnbcWord, lnInvoice) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt new file mode 100644 index 0000000000..4540a537c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.location.Location +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash + +/** + * Android `Location` glue for the platform-agnostic [GeoHash] that now lives in + * Quartz. The encode/decode/neighbor logic is shared in `commonMain`; only these + * conversions to/from the Android `Location` type stay here. + */ +fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) + +fun GeoHash.toLocation(): Location = + Location("").also { + it.latitude = centerLat + it.longitude = centerLon + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 46b719eccf..298799e119 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 5860829872..8b048326b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -69,8 +69,11 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -174,7 +177,13 @@ class EventNotificationConsumer( val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach if (matchingNote != null) { - if (!event.isTaggedUser(accountHex)) return@forEach + // Public chat replies into my messages often omit the `p` + // tag; relax the gate for them (tagsAnEventByUser keeps it + // scoped to messages actually replying to me). + val taggedOrPublicChatReply = + event.isTaggedUser(accountHex) || + NotificationFeedFilter.isNotifiablePublicChatReply(matchingNote, accountHex) + if (!taggedOrPublicChatReply) return@forEach if (!NotificationFeedFilter.tagsAnEventByUser(matchingNote, accountHex)) return@forEach } @@ -251,15 +260,18 @@ class EventNotificationConsumer( is CommentEvent -> notify(event, account) + is ChannelMessageEvent -> notify(event, account) + is PictureEvent, is VideoNormalEvent, is VideoShortEvent, is VideoHorizontalEvent, is VideoVerticalEvent, - is ChannelMessageEvent, is PollEvent, is GitPatchEvent, is GitIssueEvent, + is GitPullRequestEvent, + is GitPullRequestUpdateEvent, is HighlightEvent, is LongTextNoteEvent, is WikiNoteEvent, @@ -755,7 +767,12 @@ class EventNotificationConsumer( // p-tag match already enforced by consumeFromCache; no redundant // isTaggedUser re-check needed. - val reactedPostId = event.originalPost().firstOrNull() ?: return + // NIP-25: when a reaction carries multiple `e` tags (e.g. both the + // thread root and the replied-to note), the LAST one is the event + // actually being reacted to. Using the first tag would surface the + // root in the tray when the like was really on a reply. This matches + // the in-app card, which resolves the target via replyTo.lastOrNull(). + val reactedPostId = event.originalPost().lastOrNull() ?: return val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) // Drop reactions on muted threads, hidden authors, etc. @@ -766,14 +783,24 @@ class EventNotificationConsumer( val userPicture = author.profilePicture() val reactionContent = event.content - val reactionSymbol = - when { - reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19" - reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E" - else -> reactionContent - } - val title = "$reactionSymbol $user" + // NIP-30 custom emoji: content is ":shortcode:" backed by an ["emoji", shortcode, url] + // tag. The image can't be rendered as text, so it is surfaced as a badge on the author's + // avatar (see sendReactionNotification) and the title keeps just the author's name. + val customEmojiUrl = CustomEmoji.createEmojiMap(event.tags)[reactionContent] + + val title = + if (customEmojiUrl != null) { + user + } else { + val reactionSymbol = + when { + reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19" + reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E" + else -> reactionContent + } + "$reactionSymbol $user" + } val reactedContent = reactedNote @@ -813,6 +840,7 @@ class EventNotificationConsumer( userPicture, noteUri, applicationContext, + emojiUrl = customEmojiUrl, ) } @@ -865,6 +893,32 @@ class EventNotificationConsumer( notifyReply(event, account, parentContent, threadRoot) } + private suspend fun notify( + event: ChannelMessageEvent, + account: Account, + ) { + Log.d(TAG, "New Public Chat Message to Notify") + // Age + self-author gates run centrally in dispatchForAccount. + val note = LocalCache.getNoteIfExists(event.id) ?: return + + // A reply into one of my messages in this channel — even when the + // sender didn't p-tag me. Render it as a reply (threaded + inline + // reply action) grouped by channel so a busy room collapses into one + // notification instead of spamming. Falls back to a plain mention when + // I'm only p-tagged (a citation, not a reply to my message). + if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) { + val parentContent = + note.replyTo + ?.lastOrNull() + ?.event + ?.content + val threadRoot = event.channelId() ?: event.id + notifyReply(event, account, parentContent, threadRoot) + } else { + notifyMention(event, account) + } + } + private suspend fun notifyReply( event: Event, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 67407a0907..37e6fb2f96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -203,7 +203,14 @@ class NotificationDispatcher( // always miss for replies into addressable posts. val note = LocalCache.getNoteIfExists(event) ?: return@predicate false pubkeys.any { pubkey -> - event.isTaggedUser(pubkey) && + // Public chat replies into my own messages often + // omit the `p` tag; relax the gate for them (the + // tagsAnEventByUser check still scopes it to + // messages actually replying to me). + ( + event.isTaggedUser(pubkey) || + NotificationFeedFilter.isNotifiablePublicChatReply(note, pubkey) + ) && NotificationFeedFilter.tagsAnEventByUser(note, pubkey) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 93a556a01a..de2b6c2694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -39,13 +39,16 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch /** @@ -79,6 +82,10 @@ class NotificationRelayService : Service() { private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" + // Throttle interval for refreshing the persistent notification's relay count. + // Keeps notification updates well under Android's rate limit (~10/s). + private const val NOTIFICATION_REFRESH_MS = 1000L + const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" fun start(context: Context) { @@ -125,7 +132,6 @@ class NotificationRelayService : Service() { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var relayServiceCollectorJob: Job? = null private var connectedRelayCount = 0 - private var foregroundStarted = false override fun onBind(intent: Intent?): IBinder? = null @@ -133,7 +139,7 @@ class NotificationRelayService : Service() { super.onCreate() Log.d(TAG, "Service created") createNotificationChannel() - initializeForeground() + ensureForeground() } override fun onStartCommand( @@ -142,9 +148,16 @@ class NotificationRelayService : Service() { startId: Int, ): Int { Log.d(TAG, "Starting service") - // Safety: also call startForeground from onStartCommand in case - // onCreate didn't complete before onStartCommand fired (ntfy #1520) - initializeForeground() + // Every startForegroundService() call re-arms Android's "must call + // startForeground() within the timeout" requirement — including the repeated + // calls MainActivity.onResume fires on each resume, even when the service is + // already running and already foregrounded. We therefore call startForeground() + // on EVERY start command, not just the first. Skipping it on later starts (the + // old `if (foregroundStarted) return` guard) left the re-armed requirement + // unsatisfied and triggered ForegroundServiceDidNotStartInTimeException, which + // crashes the whole app. startForeground() is idempotent — repeated calls just + // refresh the existing notification. + if (!ensureForeground()) return START_NOT_STICKY startRelayConnection() return START_STICKY } @@ -199,10 +212,20 @@ class NotificationRelayService : Service() { super.onDestroy() } - private fun initializeForeground() { - if (foregroundStarted) return + /** + * Promotes the service to the foreground. Called on creation AND on every + * [onStartCommand], because each [Context.startForegroundService] re-arms the + * "must call startForeground() within the timeout" requirement and the old code's + * early-return-when-already-started skipped it, crashing the app with + * ForegroundServiceDidNotStartInTimeException. Calling startForeground() repeatedly + * is safe — it just refreshes the existing notification. + * + * Returns true if the service is foregrounded, false if promotion failed (in which + * case the service has already been told to stop). + */ + private fun ensureForeground(): Boolean { try { - val notification = buildNotification(0) + val notification = buildNotification(connectedRelayCount) ServiceCompat.startForeground( this, NOTIFICATION_ID, @@ -213,16 +236,26 @@ class NotificationRelayService : Service() { 0 }, ) - foregroundStarted = true + return true } catch (e: Exception) { + // Any failure to promote to the foreground leaves the service in a + // "started but not foregrounded" zombie state. Once startForegroundService() + // has been called, Android expects startForeground() within the timeout; if it + // never lands it fires ForegroundServiceDidNotStartInTimeException and crashes + // the whole app. Tearing the service down clears the OS's fgRequired flag, + // which cancels that pending timeout, so we stopSelf() on EVERY failure path — + // not just ForegroundServiceStartNotAllowedException. Other causes include + // OEM-specific RemoteException/IllegalStateException and resource lookup + // failures while building the notification. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException ) { Log.w(TAG, "Foreground service start not allowed, stopping self") - stopSelf() } else { - Log.e(TAG, "Failed to start foreground", e) + Log.e(TAG, "Failed to start foreground, stopping self", e) } + stopSelf() + return false } } @@ -242,6 +275,7 @@ class NotificationRelayService : Service() { * drafts, and relay list changes. Since the service keeps the client connected, * those subscriptions remain active on the relays. */ + @OptIn(FlowPreview::class) private fun startRelayConnection() { relayServiceCollectorJob?.cancel() relayServiceCollectorJob = @@ -253,13 +287,22 @@ class NotificationRelayService : Service() { } launch { - Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> - val count = relays.size - if (count != connectedRelayCount) { - connectedRelayCount = count - updateNotification(count) + // sample() caps how often we touch the notification. During feed + // load/teardown connectedRelaysFlow churns dozens of times per second; + // posting on every delta blows past Android's notification rate limit + // (~10/s), which silently drops updates and leaves the visible count + // stuck on a stale intermediate value. One refresh per second stays + // well under the limit and always lands the settled count. + Amethyst.instance.client + .connectedRelaysFlow() + .sample(NOTIFICATION_REFRESH_MS) + .collectLatest { relays -> + val count = relays.size + if (count != connectedRelayCount) { + connectedRelayCount = count + updateNotification(count) + } } - } } } } @@ -272,10 +315,15 @@ class NotificationRelayService : Service() { private fun buildNotification(connectedRelays: Int): Notification { val contentText = - if (connectedRelays > 0) { - getString(R.string.always_on_notif_connected, connectedRelays) - } else { - getString(R.string.always_on_notif_connecting) + when { + connectedRelays <= 0 -> getString(R.string.always_on_notif_connecting) + // Foreground: the pool also holds the feed/finder outbox relays, so the + // count reflects all connections, not just the inbox. Backgrounded, the + // feeds tear down and only inbox + DM relays remain. + MainActivity.isResumed -> + pluralStringRes(this, R.plurals.always_on_notif_connected_foreground, connectedRelays, connectedRelays) + else -> + pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays) } val openAppIntent = @@ -294,7 +342,7 @@ class NotificationRelayService : Service() { .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) .setContentText(contentText) - .setSmallIcon(R.drawable.amethyst) + .setSmallIcon(R.drawable.amethyst_service) .setContentIntent(pendingIntent) .setOngoing(true) .setSilent(true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index 3f4c9c6483..5f3cf8eb5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -29,11 +29,13 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -198,8 +200,19 @@ class NotificationReplyReceiver : BroadcastReceiver() { val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return val template = - when (targetEvent) { - is TextNoteEvent -> { + when { + // A brand-new Amethyst kind-1 thread root is replied to with a NIP-22 + // kind 1111 Comment instead of a kind 1 reply. + targetEvent is TextNoteEvent && + targetEvent.isNewThread() && + targetEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) -> { + CommentEvent.replyBuilder( + msg = replyText, + replyingTo = EventHintBundle(targetEvent), + ) + } + + targetEvent is TextNoteEvent -> { TextNoteEvent.build( note = replyText, replyingTo = EventHintBundle(targetEvent), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 27b56d05bd..1f5eb63239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -20,12 +20,16 @@ */ package com.vitorpamplona.amethyst.service.notifications +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF import android.graphics.drawable.BitmapDrawable import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat @@ -40,6 +44,7 @@ import coil3.request.allowHardware import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -221,6 +226,7 @@ object NotificationUtils { pictureUrl: String?, uri: String, applicationContext: Context, + emojiUrl: String? = null, ) { getOrCreateReactionChannel(applicationContext) val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id) @@ -231,6 +237,7 @@ object NotificationUtils { messageTitle = messageTitle, time = time, pictureUrl = pictureUrl, + badgeUrl = emojiUrl, uri = uri, channelId = channelId, notificationGroupKey = REACTION_GROUP_KEY, @@ -423,6 +430,36 @@ object NotificationUtils { } } + /** + * Draws the badge image (e.g. a NIP-30 custom-emoji reaction) onto the bottom-right corner + * of the base avatar. Returns the base unchanged if the badge can't be loaded, or the badge + * alone if there is no base avatar. + */ + private suspend fun overlayBadge( + base: Bitmap?, + badgeUrl: String, + applicationContext: Context, + ): Bitmap? { + val badge = loadBitmap(badgeUrl, applicationContext) ?: return base + if (base == null) return badge + + return withContext(Dispatchers.Default) { + val result = base.copy(base.config ?: Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(result) + val badgeSize = minOf(result.width, result.height) * 0.45f + val dest = + RectF( + result.width - badgeSize, + result.height - badgeSize, + result.width.toFloat(), + result.height.toFloat(), + ) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { isFilterBitmap = true } + canvas.drawBitmap(badge, null, dest, paint) + result + } + } + private suspend fun NotificationManager.sendDMNotificationStyled( id: String, messageBody: String, @@ -625,6 +662,7 @@ object NotificationUtils { summaryText: String, applicationContext: Context, inlineReply: InlineReplyTarget? = null, + badgeUrl: String? = null, ) { val notId = id.hashCode() @@ -632,6 +670,10 @@ object NotificationUtils { val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) } + // For custom-emoji (NIP-30) reactions, the emoji is an image URL that can't render + // as text in the notification, so overlay it as a badge on the author's avatar. + val largeIcon = badgeUrl?.let { overlayBadge(bitmap, it, applicationContext) } ?: bitmap + val contentIntent = Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } @@ -662,7 +704,7 @@ object NotificationUtils { .setContentTitle(messageTitle) .setContentText(messageBody) .setStyle(NotificationCompat.BigTextStyle().bigText(messageBody)) - .setLargeIcon(bitmap) + .setLargeIcon(largeIcon) .setContentIntent(contentPendingIntent) .setPublicVersion(builderPublic.build()) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -751,4 +793,35 @@ object NotificationUtils { fun NotificationManager.cancelNotifications() { cancelAll() } + + /** + * Dismisses the tray notification posted for [eventId] — used to auto-clear a + * notification once the user reads the underlying event in-app. + * + * Per-event notifications are keyed by `id.hashCode()` (see [sendNotification] + * and [sendDMNotificationStyled]), so hashing the same event id targets exactly + * the notification posted for it. Cancelling an id that isn't currently shown is + * a harmless no-op. After removing the child, any group summary left without + * children is cancelled too so the tray doesn't keep an empty summary around. + */ + fun NotificationManager.dismissNotificationForEvent(eventId: HexKey) { + val notId = eventId.hashCode() + + // Most events the user reads never had a tray notification (regular feed + // items), so bail out before touching anything when nothing is posted for it. + if (activeNotifications.none { it.id == notId }) return + + cancel(notId) + cancelChildlessGroupSummaries() + } + + private fun NotificationManager.cancelChildlessGroupSummaries() { + val active = activeNotifications + for (summary in active) { + if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue + val group = summary.notification.group ?: continue + val hasChildren = active.any { it.id != summary.id && it.notification.group == group } + if (!hasChildren) cancel(summary.id) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 7cb4d33624..794c3ff703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -40,8 +40,13 @@ class DualHttpClientManager( scope: CoroutineScope, dns: SurgeDns, shouldBridgeBlossomCache: (() -> Boolean)? = null, + // Required (not nullable): every general-purpose HTTP client we mint must be + // wired into the OnionLocationCache so the app's `.onion`-routing behavior + // is uniform across image, upload, NIP-05, money, preview, and push roles. + // See [OkHttpClientFactory] kdoc. + onionCache: OnionLocationCache, ) : IHttpClientManager { - val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache) + val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt index d190c885d9..bc9ce6dbfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt @@ -36,8 +36,9 @@ class DualHttpClientManagerForRelays( isMobileDataProvider: StateFlow, scope: CoroutineScope, dns: SurgeDns, + onionCache: OnionLocationCache, ) : IHttpClientManager { - val factory = OkHttpClientFactoryForRelays(userAgent, dns) + val factory = OkHttpClientFactoryForRelays(userAgent, dns, onionCache) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt index 48764445e2..8a1b8eb164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt @@ -22,18 +22,31 @@ package com.vitorpamplona.amethyst.service.okhttp import android.os.Build -internal fun isEmulator(): Boolean = - Build.FINGERPRINT.startsWith("generic") || - Build.FINGERPRINT.lowercase().contains("emulator") || - Build.MODEL.contains("google_sdk") || - Build.MODEL.lowercase().contains("droid4x") || - Build.MODEL.contains("Emulator") || - Build.MODEL.contains("Android SDK built for x86") || - Build.MANUFACTURER.contains("Genymotion") || - (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || - "google_sdk" == Build.PRODUCT || - Build.HARDWARE.contains("goldfish") || - Build.HARDWARE.contains("ranchu") || - Build.HARDWARE.contains("vbox86") || - Build.HARDWARE.contains("nox") || - Build.HARDWARE.contains("cuttlefish") +// Kotlin types android.os.Build's static fields as non-null String, but the JVM stub +// used in unit tests (testOptions.unitTests.isReturnDefaultValues = true) leaves them +// null. A naive `.startsWith` would NPE there. Coalesce to empty so unit tests can +// instantiate the OkHttp factories — production behavior is unchanged (real Android +// always populates these). +internal fun isEmulator(): Boolean { + val fingerprint = Build.FINGERPRINT ?: "" + val model = Build.MODEL ?: "" + val manufacturer = Build.MANUFACTURER ?: "" + val brand = Build.BRAND ?: "" + val device = Build.DEVICE ?: "" + val product = Build.PRODUCT ?: "" + val hardware = Build.HARDWARE ?: "" + return fingerprint.startsWith("generic") || + fingerprint.lowercase().contains("emulator") || + model.contains("google_sdk") || + model.lowercase().contains("droid4x") || + model.contains("Emulator") || + model.contains("Android SDK built for x86") || + manufacturer.contains("Genymotion") || + (brand.startsWith("generic") && device.startsWith("generic")) || + "google_sdk" == product || + hardware.contains("goldfish") || + hardware.contains("ranchu") || + hardware.contains("vbox86") || + hardware.contains("nox") || + hardware.contains("cuttlefish") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index b41ae1250f..7c67aafef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -32,6 +32,22 @@ import java.net.Proxy import java.time.Duration import java.util.concurrent.TimeUnit +/** + * Builder for the general-purpose (non-relay) OkHttp client used by image + * downloads, NIP-96/Blossom uploads, NIP-05 lookups, LNURL/money, link + * previews, push registration, etc. Each [DualHttpClientManager] holds one + * of these and rebuilds clients on Tor proxy / mobile-data changes. + * + * [OnionLocationCache] is **required**: every OkHttp client minted by this + * factory installs [OnionLocationInterceptor] on the no-proxy and Tor-proxied + * variants so any HTTP/WebSocket response that carries an `Onion-Location` + * header (NIP-11 docs, image hosts, blossom servers, NIP-96 endpoints, LNURL + * providers, anything) populates the cache. Tor-proxied clients additionally + * install [OnionUrlRewriteInterceptor] so subsequent requests are rerouted + * to the cached `.onion` and avoid exit nodes entirely. Treating the cache as + * required (not nullable) prevents a future call site from silently disabling + * onion-routing for an entire HTTP role. + */ class OkHttpClientFactory( keyCache: EncryptionKeyCache, val userAgent: String, @@ -43,6 +59,7 @@ class OkHttpClientFactory( * useful for tests or pre-configuration call sites. */ val shouldBridgeBlossomCache: (() -> Boolean)? = null, + private val onionCache: OnionLocationCache, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) @@ -75,6 +92,14 @@ class OkHttpClientFactory( .connectionPool(connectionPool) .dns(dns) .eventListenerFactory(MediaCallEventListenerFactory(dispatcher, connectionPool, dns)) + // Some media hosts (e.g. blossom.primal.net) silently drop idle HTTP/2 + // connections between the bursts of a feed scroll. Without a keepalive + // ping, OkHttp can pull such a dead connection from the 5-min pool and + // the request stalls until the read timeout (30s wifi / 90s mobile), + // which shows up as "the first image after a pause takes forever". + // An HTTP/2 ping detects the dead connection in seconds and lets + // retryOnConnectionFailure re-issue the request on a fresh one. + .pingInterval(Duration.ofSeconds(HTTP2_PING_INTERVAL_SECS)) .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) @@ -83,6 +108,11 @@ class OkHttpClientFactory( } // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) + // Passively populates [onionCache] from any HTTP/WebSocket response + // that carries an `Onion-Location` header. Application-scoped so the + // cache key is always the original clearnet host (see kdoc on + // [OnionLocationInterceptor]). + .addInterceptor(OnionLocationInterceptor(onionCache)) .build() private var lastProxy: Proxy? = null @@ -99,6 +129,10 @@ class OkHttpClientFactory( return rootClient .newBuilder() .proxy(proxy) + // Only the Tor-routed variant rewrites outbound URLs to known + // `.onion`s — clearnet clients must never try to resolve `.onion` + // (DNS would fail, and we don't want fingerprintable lookups). + .apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) } .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) @@ -128,4 +162,11 @@ class OkHttpClientFactory( } fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT)) + + companion object { + // HTTP/2 keepalive ping for pooled media connections. Short enough to + // evict a silently-dropped connection well before the read timeout would + // otherwise stall a request for the full 30s/90s. + const val HTTP2_PING_INTERVAL_SECS: Long = 10 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index a5e3619a60..7704771de1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -30,6 +30,7 @@ import java.time.Duration class OkHttpClientFactoryForRelays( userAgent: String, private val dns: SurgeDns, + private val onionCache: OnionLocationCache, ) { companion object { // by picking a random proxy port, the connection will fail as it should. @@ -61,6 +62,7 @@ class OkHttpClientFactoryForRelays( .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) + .addInterceptor(OnionLocationInterceptor(onionCache)) .build() private var lastProxy: Proxy? = null @@ -77,6 +79,7 @@ class OkHttpClientFactoryForRelays( return rootClient .newBuilder() .proxy(proxy) + .apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) } .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 9f2ad864a6..c89de32554 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -133,12 +133,16 @@ class OkHttpWebSocket( class Builder( val httpClient: (NormalizedRelayUrl) -> OkHttpClient, + val canDial: (NormalizedRelayUrl) -> Boolean = { true }, ) : WebsocketBuilder { // Called when connecting. override fun build( url: NormalizedRelayUrl, out: WebSocketListener, ) = OkHttpWebSocket(url, httpClient, out) + + // Gates the dial — false skips it (e.g. a Tor-routed relay before Tor is ready). + override fun canConnect(url: NormalizedRelayUrl) = canDial(url) } override fun disconnect() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt new file mode 100644 index 0000000000..b93b50cc0a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt @@ -0,0 +1,68 @@ +/* + * 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.okhttp + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * Maps clearnet hostnames to their advertised .onion equivalents, populated + * passively by [OnionLocationInterceptor] from any HTTP/WebSocket response + * that carries an `Onion-Location` header. + * + * The [OnionUrlRewriteInterceptor] consults this cache on every outbound + * request when Tor is active, transparently redirecting to the .onion address + * so Tor-routed connections avoid exit nodes entirely. + * + * Entries expire after [TTL_MS] so that a server that rotates or decommissions + * its `.onion` address does not permanently break Tor connectivity. After expiry + * the next request goes out via Tor exit nodes (or clearnet on the no-proxy + * client); if the server still advertises `Onion-Location` the mapping is + * refreshed automatically. + */ +class OnionLocationCache { + private data class Entry( + val onionUrl: String, + val expiresAtMs: Long, + ) + + private val cache = ConcurrentHashMap() + + fun put( + host: String, + onionUrl: String, + ) { + cache[host] = Entry(onionUrl, System.currentTimeMillis() + TTL_MS) + } + + fun get(host: String): String? { + val entry = cache[host] ?: return null + if (System.currentTimeMillis() > entry.expiresAtMs) { + cache.remove(host) + return null + } + return entry.onionUrl + } + + companion object { + val TTL_MS: Long = TimeUnit.HOURS.toMillis(24) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt new file mode 100644 index 0000000000..75edd59553 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt @@ -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.service.okhttp + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Application interceptor that passively captures `Onion-Location` headers from + * every HTTP response — NIP-11 documents, WebSocket upgrade handshakes (101), + * image/media servers, anything — and records the mapping into + * [OnionLocationCache]. + * + * No extra requests are made; discovery is a free by-product of traffic that + * already happens. When the Tor-enabled client later connects to the same host, + * [OnionUrlRewriteInterceptor] swaps in the cached .onion address so the + * connection avoids exit nodes entirely. + * + * Registered as an application interceptor so that `chain.request().url.host` + * always returns the original clearnet hostname — the correct cache key. If it + * were a network interceptor it would run after [OnionUrlRewriteInterceptor] + * has already rewritten the host to `.onion`, causing cache entries from + * onion-routed responses to be stored under the `.onion` hostname instead of + * the clearnet hostname that [OnionUrlRewriteInterceptor] actually looks up. + */ +class OnionLocationInterceptor( + private val cache: OnionLocationCache, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val onionLocation = response.header("Onion-Location") + if (onionLocation != null) { + cache.put(chain.request().url.host, onionLocation) + } + return response + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt new file mode 100644 index 0000000000..ec0e04d3d4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Application interceptor that rewrites outbound requests to use a known + * .onion address when one is available in [OnionLocationCache]. + * + * Only added to Tor-enabled OkHttpClient instances, so .onion substitution + * never happens on clearnet connections (where .onion DNS would not resolve). + * + * Scheme is derived from the `Onion-Location` header value to keep the request + * in the correct protocol family: + * - WebSocket originals (`ws`/`wss`) → `ws` or `wss` based on the onion scheme + * - HTTP originals (`http`/`https`) → `http` or `https` based on the onion scheme + * + * A `.onion` server advertising `http://` for a clearnet `https://` host is + * intentional and safe: the Tor circuit provides end-to-end encryption + * equivalent to TLS for onion services, so an `http://` onion connection is + * not a downgrade in practice. + * + * Port and path are preserved from the onion URL and original request + * respectively. An unparseable cached URL falls through to the original. + */ +class OnionUrlRewriteInterceptor( + private val cache: OnionLocationCache, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val onionUrlStr = cache.get(original.url.host) ?: return chain.proceed(original) + val onionUrl = runCatching { onionUrlStr.toHttpUrl() }.getOrNull() ?: return chain.proceed(original) + + val newScheme = + when (original.url.scheme) { + "ws", "wss" -> if (onionUrl.scheme == "https") "wss" else "ws" + "http", "https" -> if (onionUrl.scheme == "https") "https" else "http" + else -> onionUrl.scheme + } + + val rewritten = + original + .newBuilder() + .url( + original.url + .newBuilder() + .scheme(newScheme) + .host(onionUrl.host) + .port(onionUrl.port) + .build(), + ).build() + + return chain.proceed(rewritten) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquare.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquare.kt new file mode 100644 index 0000000000..6b99a14611 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlayerSquare.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * True when the inline-feed audio player should be sized as a square. + * + * Audio is detected two ways: the imeta [isAudioMime] (known up front, no resize) and the runtime + * [tracksAreAudio] (covers bare-URL audio once tracks resolve). Voice notes keep their seek-bar strip, + * and the full-screen dialog fills the screen, so both opt out. + */ +internal fun shouldSquareAudioPlayer( + isFullscreen: Boolean, + isVoiceNote: Boolean, + isAudioMime: Boolean, + tracksAreAudio: Boolean, +): Boolean = !isFullscreen && !isVoiceNote && (isAudioMime || tracksAreAudio) + +/** + * Maximum height of the square. The player is always full width; below this height it is a true + * square (1:1), and above it the height is capped (full width, shorter than square). + */ +internal val AUDIO_SQUARE_MAX_HEIGHT: Dp = 400.dp + +/** Square side in px: the full width, but never taller than the cap. */ +internal fun audioSquareSide( + widthPx: Int, + maxHeightPx: Int, +): Int = minOf(widthPx, maxHeightPx) + +/** + * Sizes the player as a square: full width, height = min(width, [maxHeight]). Only meaningful with a + * bounded incoming width (the feed cell); with an unbounded width it measures normally and does + * nothing, so it is safe to leave in the chain unconditionally if ever needed. + */ +internal fun Modifier.audioSquare(maxHeight: Dp = AUDIO_SQUARE_MAX_HEIGHT): Modifier = + layout { measurable, constraints -> + val width = constraints.maxWidth + // Unbounded width, or first layout pass before the cell has a width: measure normally and let + // the next pass impose the square once a real width arrives. + if (!constraints.hasBoundedWidth || width == 0) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } + val side = audioSquareSide(width, maxHeight.roundToPx()) + val placeable = measurable.measure(Constraints.fixed(width, side)) + layout(width, side) { placeable.placeRelative(0, 0) } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index f1fce2f1f9..79478bdc34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -81,6 +81,16 @@ fun GetVideoController( ).onEach { state -> Log.d("PlaybackService") { "Controller instance: ${state.controller}" } + // A warm-pool ExoPlayer can be handed back still carrying a prior + // PlaybackException (e.g. a decoder-init failure from an earlier acquire). The + // re-prepare below clears it before WatchPlaybackErrors ever attaches, so this + // is the only place the stale error — and its decoder/codec cause chain — is + // observable. Logged so a "Can't play this video" blink that self-heals can be + // attributed to warm-pool reuse rather than a genuinely undecodable stream. + state.controller.playerError?.let { err -> + Log.w(ERROR_LOG_TAG) { "Controller arrived carrying error for ${mediaItem.item.mediaId}: ${err.describe()}" } + } + // The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda // sets it to 0f when the player is acquired, so the controller arrives at 0f. // Read first and only push an IPC if the value actually needs to change — @@ -110,6 +120,11 @@ fun GetVideoController( val targetMediaId = mediaItem.item.mediaId val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId if (needsLoad) { + // Cold load: a fresh decoder/codec instance gets allocated here. If a + // second controller for the same URI is still alive (see liveControllers + // in PlaybackServiceClient), this prepare() is where MediaCodec.start() + // can collide and fail. + Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" } state.controller.setMediaItem(mediaItem.item) state.controller.prepare() } else if (state.controller.playbackState == Player.STATE_IDLE) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 2cae673cab..30896bee0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -38,6 +39,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame @@ -52,6 +54,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradie import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation +import com.vitorpamplona.amethyst.service.playback.composable.wavefront.rememberIsAudioTrack import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.ui.components.getDialogWindow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -129,9 +132,22 @@ fun RenderVideoPlayer( WatchPlaybackErrors(controllerState) + // Audio files have no video dimensions, so without this the player collapses to a thin strip and + // the controls get crammed. Size it square (capped) so the visualizer and controls get room. + // Voice notes keep their seek-bar strip; the full-screen dialog fills the screen. + val tracksAreAudio by rememberIsAudioTrack(controllerState.controller) + val squarePlayer = + shouldSquareAudioPlayer( + isFullscreen = isFullscreen, + isVoiceNote = mediaItem.src.waveformData != null, + isAudioMime = mediaItem.src.mimeType?.startsWith("audio/") == true, + tracksAreAudio = tracksAreAudio, + ) + val playerModifier = if (squarePlayer) borderModifier.audioSquare() else borderModifier + Box( modifier = - borderModifier + playerModifier .onSizeChanged { containerWidth[0] = it.width } .pointerInput(isLive, controllerState) { detectTapGestures( @@ -180,10 +196,14 @@ fun RenderVideoPlayer( modifier = Modifier.align(Alignment.Center), ) + val visualizerStyle by accountViewModel.audioVisualizerFlow().collectAsStateWithLifecycle() AudioPlayingAnimation( - controllerState, - mediaItem.src.waveformData, - Modifier.fillMaxSize().align(Alignment.Center), + controllerState = controllerState, + waveform = mediaItem.src.waveformData, + mediaId = mediaItem.src.videoUri, + style = visualizerStyle, + isAudio = tracksAreAudio, + modifier = Modifier.fillMaxSize().align(Alignment.Center), hasBlurhash = hasBlurhash, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 5df1c13240..45f72cf3fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -193,7 +193,12 @@ fun VideoView( DisplayBlurHash( blurhash, null, - contentScale, + // The placeholder bitmap is decoded at the blurhash's DCT component-grid aspect + // (e.g. a 5x5 grid -> a square bitmap), NOT the real media shape. When `ratio` is + // known the Box is already sized to the true aspect, so crop the placeholder to + // fill it. Without this, FillWidth letterboxes the square placeholder inside the + // taller portrait box — the "square blurhash on a twice-as-tall space" bug. + if (ratio != null) ContentScale.Crop else contentScale, if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier, thumbhash = thumbhash, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt index 7a1c073a9b..8141b4a80f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt @@ -20,10 +20,55 @@ */ package com.vitorpamplona.amethyst.service.playback.composable +import android.os.SystemClock +import androidx.annotation.OptIn import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.delay + +// Debug tag for the playback-error lifecycle. Logs every appearance, clear, synthetic-stall raise +// and recovery so a transient decoder-init collision (which self-recovers on a later attempt) can +// be told apart from a genuinely undecodable stream in a field logcat. See WatchPlaybackErrors. +internal const val ERROR_LOG_TAG = "PlaybackError" + +/** + * Flattens a [PlaybackException] into a single line: error code, message, and the full nested + * cause chain (e.g. `DecoderInitializationException <- MediaCodec.CodecException`). The cause + * chain is what distinguishes "format truly unsupported" from "decoder failed to start while a + * second controller held the codec" — both surface as the same top-level renderer error with + * `format_supported=YES`. + * + * Internal (not private) so [GetVideoController] can log the same detail at controller-acquire + * time — a warm-pool player can arrive already in ERROR and get re-prepared (cleared) before + * this watcher ever attaches, so the acquire site is the only place that error is observable. + */ +internal fun PlaybackException.describe(): String { + val causeChain = + generateSequence(cause) { it.cause } + .joinToString(" <- ") { "${it::class.simpleName}: ${it.message}" } + .ifEmpty { "none" } + return "code=$errorCodeName($errorCode) msg=$message causes=[$causeChain]" +} + +// How often the decode-stall watchdog samples the controller's position/buffer. +private const val STALL_POLL_INTERVAL_MS = 1_000L + +// The decoder is only considered "fed" once it has comfortably more than bufferForPlaybackMs +// (750 ms in feedTunedLoadControl) of media ready ahead of the playhead. Below this we treat a +// frozen playhead as ordinary network starvation, not a decode failure, and leave it alone. +private const val STALL_MIN_BUFFER_AHEAD_MS = 2_000L + +// How long the playhead may stay frozen while the decoder is fed before we declare the stream +// undecodable. A healthy player crosses bufferForPlaybackMs and starts rendering within a second +// or two, so 8 s of fed-but-frozen buffering is unambiguous. +private const val STALL_TIMEOUT_MS = 8_000L /** * Mirrors the MediaController's terminal-error state into [MediaControllerState.playbackError] @@ -33,6 +78,12 @@ import androidx.media3.common.Player * ContentFrame then renders nothing and the user is left staring at a blank box. Surfacing the * exception gives [RenderVideoPlayer] something to render and lets the user open the URL * externally instead. + * + * Some codec failures never surface as a [PlaybackException] at all: a software HEVC decoder that + * can't keep up (e.g. iPhone-recorded `hvc1` video on a device without a HEVC hardware decoder) + * just parks the player in [Player.STATE_BUFFERING] forever — the buffer fills to the LoadControl + * cap, yet the playhead never leaves 0 and no error is ever raised. [watchForDecodeStall] polls + * for that signature and synthesizes a [PlaybackException] so the same overlay kicks in. */ @Composable fun WatchPlaybackErrors(controllerState: MediaControllerState) { @@ -43,18 +94,42 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { // Prime from the controller's current state — a warm-pool player may already be in ERROR // when we attach, in which case onPlayerErrorChanged will not fire again until prepare(). errorState.value = controller.playerError + controller.playerError?.let { + Log.w(ERROR_LOG_TAG) { "Primed with existing error on ${controller.currentMediaItem?.mediaId}: ${it.describe()}" } + } val listener = object : Player.Listener { override fun onPlayerErrorChanged(error: PlaybackException?) { + if (error != null) { + Log.w(ERROR_LOG_TAG) { "Error raised on ${controller.currentMediaItem?.mediaId}: ${error.describe()}" } + } else if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Error cleared on ${controller.currentMediaItem?.mediaId}" } + } errorState.value = error } + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + // A new item on a pooled player starts fresh; drop any error from the old one. + if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Error dropped on media transition (reason=$reason) -> ${mediaItem?.mediaId}" } + errorState.value = null + } + } + override fun onPlaybackStateChanged(state: Int) { - // Any successful transition out of IDLE/ERROR (typically after a manual retry - // via controller.prepare()) clears the overlay so the player can render. - if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) { - if (errorState.value != null) errorState.value = null + // A successful transition to READY (the renderer produced output) is the only + // real recovery — clear the overlay then. We deliberately do NOT clear on + // STATE_BUFFERING: the synthetic decode-stall error below is raised *while* + // buffering, and clearing on every buffering event would wipe it instantly. + if (state == Player.STATE_READY) { + if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Recovered (STATE_READY) on ${controller.currentMediaItem?.mediaId} — clearing overlay" } + errorState.value = null + } } } } @@ -64,4 +139,69 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { controller.removeListener(listener) } } + + LaunchedEffect(controllerState) { + watchForDecodeStall(controller, errorState) + } +} + +/** + * Polls [controller] for the silent-decode-stall signature and, once seen continuously for + * [STALL_TIMEOUT_MS], writes a synthetic [PlaybackException] so the browser-fallback overlay shows. + * + * Stall signature: the player wants to play and is stuck in [Player.STATE_BUFFERING] with at least + * [STALL_MIN_BUFFER_AHEAD_MS] of media buffered ahead (so the decoder is fed, not network-starved), + * yet the playhead has not advanced. A genuine mid-stream rebuffer fails the buffer-ahead guard — + * its buffer is depleted, which is precisely why it rebuffers — so it is never flagged. + */ +@OptIn(UnstableApi::class) +private suspend fun watchForDecodeStall( + controller: Player, + errorState: MutableState, +) { + var unproductiveSinceMs = -1L + var lastPosition = Long.MIN_VALUE + + // delay() is cancellable, so the loop exits when the LaunchedEffect is torn down. + while (true) { + delay(STALL_POLL_INTERVAL_MS) + + val position = controller.currentPosition + val progressed = position != lastPosition + lastPosition = position + + val decoderFedButFrozen = + controller.playbackState == Player.STATE_BUFFERING && + controller.playWhenReady && + controller.bufferedPosition - position >= STALL_MIN_BUFFER_AHEAD_MS + + if (decoderFedButFrozen && !progressed) { + val now = SystemClock.elapsedRealtime() + if (unproductiveSinceMs < 0) { + unproductiveSinceMs = now + } else if (now - unproductiveSinceMs >= STALL_TIMEOUT_MS && errorState.value == null) { + Log.w(ERROR_LOG_TAG) { + "Synthetic decode-stall after ${STALL_TIMEOUT_MS}ms fed-but-frozen " + + "(pos=$position buffered=${controller.bufferedPosition}) on ${controller.currentMediaItem?.mediaId}" + } + errorState.value = + PlaybackException( + "Video decoding stalled with a full buffer — likely an unsupported codec", + null, + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + ) + } + } else { + unproductiveSinceMs = -1L + + // "If it works, just play": if the playhead is advancing again the stream is decoding + // after all (a slow device that eventually produced a frame, a recovered hiccup), so + // drop the stall overlay. Real decoder errors leave the player IDLE with a frozen + // playhead, so they never progress here and are left for the STATE_READY listener. + if (progressed && errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Playhead progressed to $position — clearing stall overlay on ${controller.currentMediaItem?.mediaId}" } + errorState.value = null + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt index 28afe97b33..05da0bf1c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt @@ -20,52 +20,173 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.wavefront +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.Tracks +import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.audio.delayedByFrames import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.playback.playerPool.PcmTapRegistry +import kotlinx.coroutines.flow.emptyFlow fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_TYPE_VIDEO } +/** + * Observes whether the controller's current tracks are audio-only, updating live via the player's + * track-change listener. Shared by [AudioPlayingAnimation] (to decide what to draw) and the player + * container (to decide whether to size the player as a square). + */ +@Composable +fun rememberIsAudioTrack(controller: Player): State { + val isAudio = remember(controller) { mutableStateOf(controller.currentTracks.isAudio()) } + DisposableEffect(controller) { + val listener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + isAudio.value = tracks.isAudio() + } + } + controller.addListener(listener) + // Re-read after registering so a track change racing the initial remember{} read isn't missed. + isAudio.value = controller.currentTracks.isAudio() + onDispose { controller.removeListener(listener) } + } + return isAudio +} + +// Output-latency compensation. The spectrum is tapped upstream of the AudioTrack output buffer (and, +// over Bluetooth, the codec/transmission lag downstream of it), so the visual leads the speaker and +// must be delayed to match. Each hop is one 1024-sample FFT frame (~23 ms at 44.1 kHz). Tuned on a +// Pixel 9a against the 120-BPM beat of the synthetic demo clip (1 beat = 500 ms): wired/speaker wants +// ~20 hops, Bluetooth ~27 hops (the extra ~7 hops / ~160 ms is the BT codec latency). +// +// DEVICE/CODEC-SPECIFIC tuned constants. Android exposes no reliable output latency, and runtime +// auto-detection from player.currentPosition was tried and rejected (~3.4x off — it measures decode +// buffer depth, not the perceptual sync point). [rememberIsBluetoothOutput] only switches between +// these two presets by route; the BT figure is a per-codec average, so it may be off on other gear. +private const val FEED_VISUALIZER_DELAY_FRAMES = 20 +private const val BT_VISUALIZER_DELAY_FRAMES = 27 + @Composable fun AudioPlayingAnimation( controllerState: MediaControllerState, waveform: WaveformData?, + mediaId: String, + style: VisualizerStyle, + isAudio: Boolean, modifier: Modifier = Modifier, hasBlurhash: Boolean = false, ) { - if (hasBlurhash) return + if (!isAudio) return - var isAudio by remember { mutableStateOf(controllerState.controller.currentTracks.isAudio()) } + // Dim any blurhash/cover backdrop behind whatever we draw, for contrast and consistency across + // every visible style. OFF draws nothing, so the cover still shows cleanly there. + val drawModifier = if (hasBlurhash) modifier.background(Color.Black.copy(alpha = 0.45f)) else modifier - DisposableEffect(controllerState.controller) { - val listener = - object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - super.onTracksChanged(tracks) - isAudio = tracks.isAudio() - } - } - controllerState.controller.addListener(listener) - onDispose { controllerState.controller.removeListener(listener) } + if (waveform != null) { + // NIP-A0 voice notes etc. that ship a precomputed waveform keep their seek bar. + Waveform(waveform, controllerState, drawModifier) + return } - if (isAudio) { - if (waveform != null) { - Waveform(waveform, controllerState, modifier) - } else { - FakeWaveformAnimation( - mediaControllerState = controllerState, - modifier = modifier, - ) + when (style) { + // CLASSIC keeps its compact fixed-height wave (it does not stretch to fill the square). + VisualizerStyle.CLASSIC -> FakeWaveformAnimation(mediaControllerState = controllerState, modifier = drawModifier) + VisualizerStyle.STATIC -> { + // StaticRenderer ignores the flow and shows a frozen frame. + val empty = remember { emptyFlow() } + AudioVisualizer(style = VisualizerStyle.STATIC, spectrum = empty, modifier = drawModifier.audioVisualizerHeight()) + } + VisualizerStyle.OFF -> Unit + VisualizerStyle.BARS, + VisualizerStyle.WAVES, + VisualizerStyle.RADIAL, + VisualizerStyle.AURORA, + -> { + val bluetooth by rememberIsBluetoothOutput() + val delayFrames = if (bluetooth) BT_VISUALIZER_DELAY_FRAMES else FEED_VISUALIZER_DELAY_FRAMES + val spectrum = remember(mediaId, delayFrames) { PcmTapRegistry.spectrumFor(mediaId).delayedByFrames(delayFrames) } + AudioVisualizer(style = style, spectrum = spectrum, modifier = drawModifier.audioVisualizerHeight()) } } } + +/** + * Tracks whether audio is currently routed to a Bluetooth output, updating live as devices connect or + * disconnect. Bluetooth adds codec/transmission latency downstream of AudioTrack, so the visualizer + * needs a larger delay there. Keys off CONNECTED A2DP/LE output devices — a good proxy since A2DP + * captures media playback, and Android exposes no reliable "active media route" before API 31. + */ +@Composable +private fun rememberIsBluetoothOutput(): State { + val context = LocalContext.current + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } + val isBluetooth = remember { mutableStateOf(audioManager.isBluetoothOutput()) } + DisposableEffect(audioManager) { + val callback = + object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array?) { + isBluetooth.value = audioManager.isBluetoothOutput() + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + isBluetooth.value = audioManager.isBluetoothOutput() + } + } + audioManager?.registerAudioDeviceCallback(callback, null) + onDispose { audioManager?.unregisterAudioDeviceCallback(callback) } + } + return isBluetooth +} + +private fun AudioManager?.isBluetoothOutput(): Boolean { + if (this == null) return false + return getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device -> + device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + device.type == AudioDeviceInfo.TYPE_BLE_HEADSET || + device.type == AudioDeviceInfo.TYPE_BLE_SPEAKER + } +} + +/** + * Fills the available height only when the parent gives a bounded height clearly larger than + * [fallback] (the full-screen media dialog); otherwise uses the fixed [fallback] strip. This avoids + * filling a small bounded feed cell and avoids collapsing to zero under the feed's unbounded lazy + * height. Always fills width. + */ +private fun Modifier.audioVisualizerHeight(fallback: Dp = 72.dp): Modifier = + fillMaxWidth().layout { measurable, constraints -> + val fallbackPx = fallback.roundToPx() + val targetHeight = + if (constraints.hasBoundedHeight) { + // Fill a clearly-large cell (full-screen dialog); otherwise use the fallback strip but + // never exceed the cell, so a small bounded cell can't overflow onto its neighbors. + if (constraints.maxHeight >= fallbackPx * 2) constraints.maxHeight else minOf(fallbackPx, constraints.maxHeight) + } else { + // Unbounded (lazy feed): the fallback strip avoids collapsing to zero. + fallbackPx + } + val placeable = measurable.measure(constraints.copy(minHeight = targetHeight, maxHeight = targetHeight)) + layout(placeable.width, targetHeight) { placeable.placeRelative(0, 0) } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index eaa31e31e6..3a46279de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -22,10 +22,16 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import android.content.Context import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.TeeAudioProcessor import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher @@ -38,18 +44,48 @@ class ExoPlayerBuilder( val videoCache: VideoCache, val dataSourceFactory: DataSource.Factory, ) { - fun build(context: Context): ExoPlayer = - ExoPlayer - .Builder(context) + fun build(context: Context): ExoPlayer { + val sink = PcmTapRegistry.newSink() + + val renderersFactory = + object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink = + DefaultAudioSink + .Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(TeeAudioProcessor(sink))) + .build() + } + + return ExoPlayer + .Builder(context, renderersFactory) .apply { setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) setLoadControl(feedTunedLoadControl()) }.build() .apply { + PcmTapRegistry.registerPlayer(this, sink) + addListener( + object : Player.Listener { + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + PcmTapRegistry.bind(mediaItem?.mediaId, sink) + } + }, + ) + PcmTapRegistry.bind(currentMediaItem?.mediaId, sink) addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) } + } companion object { // Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index dbdc7f1012..e38aa51c0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -117,8 +117,20 @@ class ExoPlayerPool( if (preferredMediaId != null) { val warm = takeWarm(preferredMediaId) if (warm != null) { - Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } - return warm + // A warm player can error *after* it was pooled clean — its decoder dies + // asynchronously while paused (emulator surface reclaim, codec loss). releasePlayer + // can't catch that (the error appears post-release), so it's caught here at acquire: + // never hand a stale PlaybackException to a controller. Release the dead player and + // fall through to a clean cold/fresh one — a guaranteed setMediaItem+prepare ahead. + val error = warm.playerError + if (error != null) { + Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" } + PcmTapRegistry.unregisterPlayer(warm) + warm.release() + } else { + Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } + return warm + } } } return coldPool.poll() ?: builder.build(context) @@ -148,6 +160,19 @@ class ExoPlayerPool( mutex.withLock { if (player.isReleased) return@withLock + // A player that errored out (decoder-init failure, decode error) must never be + // returned to either pool. Kept warm, it hands the stale PlaybackException straight + // back to the next acquire of the same URI — the "Can't play this video" flash traced + // to warm-pool reuse. Its failed MediaCodec instance is also suspect. Drop it so the + // pool builds a clean replacement on the next miss. + val error = player.playerError + if (error != null) { + Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" } + PcmTapRegistry.unregisterPlayer(player) + player.release() + return@withLock + } + val mediaId = player.currentMediaItem?.mediaId if (mediaId != null && warmSlotsCap > 0) { // Warm path: keep the player paused but loaded so a quick scroll-back to the @@ -209,10 +234,31 @@ class ExoPlayerPool( coldPool.add(player) } } else { + PcmTapRegistry.unregisterPlayer(player) player.release() // Release if pool is full. } } + /** + * Evicts all warm (paused-with-buffer) players back to the cold pool without touching active + * players. Safe to call under memory pressure: warm players are idle; active players are + * checked out via [acquirePlayer] and are not held in either pool. + */ + fun releaseWarmPool() { + val evicted = + synchronized(warmPoolLock) { + val copy = warmPool.toList() + warmPool.clear() + copy + } + if (evicted.isEmpty()) return + scope.launch { + mutex.withLock { + evicted.forEach { demoteToCold(it.player) } + } + } + } + fun destroy() { scope .launch { @@ -223,8 +269,14 @@ class ExoPlayerPool( warmPool.clear() copy } - warmSnapshot.forEach { it.player.release() } - coldPool.forEach { it.release() } + warmSnapshot.forEach { + PcmTapRegistry.unregisterPlayer(it.player) + it.player.release() + } + coldPool.forEach { + PcmTapRegistry.unregisterPlayer(it) + it.release() + } coldPool.clear() } }.invokeOnCompletion { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 06170fcee4..92b6fa2300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -93,8 +93,32 @@ class MediaSessionPool( .Builder(appContext) .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) .setDataSourceFactory(dataSourceFactory) + // Cap decoded artwork to the platform's own metadata bitmap limit so the legacy + // MediaSession path never has to re-scale it. media3 would otherwise size-limit the + // bitmap using Resources.getSystem()'s config_mediaMetadataBitmapMaxSize, which on + // several OEM ROMs (e.g. LineageOS/peridot) is unresolvable and falls back to the full + // screen width. That over-sized bitmap then gets re-scaled by the framework inside + // android.media.session.MediaSession.setMetadata(), and some of those ROMs recycle the + // *source* bitmap during MediaMetadata.Builder.scaleBitmap(). media3's CacheBitmapLoader + // caches that now-recycled bitmap and hands it back on the next metadata update, which + // crashes with "cannot use a recycled source in createBitmap". Pre-sizing to the same + // limit the framework uses keeps build() from ever calling scaleBitmap(). + .setMaximumOutputDimension(mediaMetadataBitmapMaxSize()) .build() + /** + * Mirrors how android.media.session.MediaSession derives its metadata bitmap ceiling: the + * framework dimension config_mediaMetadataBitmapMaxSize, resolved from the app context so it + * matches the value the platform compares against. Falls back to AOSP's 320dp default when the + * (hidden, framework-internal) resource can't be resolved by name. + */ + private fun mediaMetadataBitmapMaxSize(): Int { + val resources = appContext.resources + val id = resources.getIdentifier("config_mediaMetadataBitmapMaxSize", "dimen", "android") + val resolved = if (id != 0) resources.getDimensionPixelSize(id) else 0 + return if (resolved > 0) resolved else (DEFAULT_METADATA_BITMAP_DP * resources.displayMetrics.density).toInt() + } + // protects from LruCache killing playing sessions private val playingMap = mutableMapOf() @@ -271,5 +295,9 @@ class MediaSessionPool( companion object { private val CLEANUP_INTERVAL_NS = TimeUnit.MINUTES.toNanos(1) + + // AOSP default for config_mediaMetadataBitmapMaxSize, used when the framework resource + // can't be resolved by name on a given ROM. + private const val DEFAULT_METADATA_BITMAP_DP = 320 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt new file mode 100644 index 0000000000..8416f986db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.TeeAudioProcessor +import com.vitorpamplona.amethyst.commons.audio.AudioWindow +import com.vitorpamplona.amethyst.commons.audio.Fft +import com.vitorpamplona.amethyst.commons.audio.Spectrum +import com.vitorpamplona.amethyst.commons.audio.normalizeToPeakInPlace +import com.vitorpamplona.amethyst.commons.audio.toLogBins +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.ConcurrentHashMap + +/** + * A Media3 [TeeAudioProcessor.AudioBufferSink] that turns the decoded 16-bit PCM of the + * currently-playing track into a live [Spectrum] stream via FFT + log binning, emitting into the + * per-media-id flow [PcmTapRegistry] has bound it to. Only 16-bit PCM is handled; offload/passthrough + * bypass the processor chain so the flow simply stays empty (visualizer idles, never stale). + */ +@OptIn(UnstableApi::class) +class SpectrumAudioBufferSink( + private val fftSize: Int = 1024, + private val binCount: Int = 48, +) : TeeAudioProcessor.AudioBufferSink { + /** The per-media-id flow this sink currently feeds; set by [PcmTapRegistry.bind]. */ + @Volatile + internal var output: MutableSharedFlow? = null + + /** The media id this sink is currently bound to; protects its flow from eviction. */ + @Volatile + internal var boundMediaId: String? = null + + private val window = AudioWindow.hann(fftSize) + private val mono = ShortArray(fftSize) + private val scratch = FloatArray(fftSize) + private val re = DoubleArray(fftSize) + private val im = DoubleArray(fftSize) + private val mags = FloatArray(fftSize / 2 + 1) + private var filled = 0 + private var channels = 1 + private var encoding = C.ENCODING_PCM_16BIT + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flush( + sampleRateHz: Int, + channelCount: Int, + encoding: Int, + ) { + this.channels = channelCount.coerceAtLeast(1) + this.encoding = encoding + filled = 0 + output?.resetReplayCache() + } + + override fun handleBuffer(buffer: ByteBuffer) { + if (encoding != C.ENCODING_PCM_16BIT) return + val pcm = buffer.order(ByteOrder.LITTLE_ENDIAN) + while (pcm.remaining() >= 2 * channels) { + val sample = pcm.short // first channel of the frame + if (channels > 1) pcm.position(pcm.position() + (channels - 1) * 2) // skip remaining channels + mono[filled++] = sample + if (filled == fftSize) { + emitSpectrum() + filled = 0 + } + } + } + + // Reuses pre-allocated working buffers; the only per-frame allocation is the published bins. + private fun emitSpectrum() { + AudioWindow.shortsToWindowedInto(mono, window, scratch) + Fft.magnitudesInto(scratch, re, im, mags) + // Skip the DC bin (index 0): toLogBins ignores it, so letting a DC/offset component be the + // peak would scale every audible bin toward zero and wash the spectrum out. + mags.normalizeToPeakInPlace(fromIndex = 1) + output?.tryEmit(Spectrum(mags.toLogBins(binCount))) + } +} + +/** + * Routes each pooled player's decoded-PCM spectrum to a stable per-media-id flow the UI subscribes + * to by media URL. Flows are created on demand (so a UI subscriber can attach before playback binds + * the sink) and the map is bounded: past [MAX_TRACKED_FLOWS], the least-recently-used flows that no + * live sink is currently feeding are evicted, so a long feed session can't grow the map without limit. + */ +@OptIn(UnstableApi::class) +object PcmTapRegistry { + private const val MAX_TRACKED_FLOWS = 64 + + private val lock = Any() + + // access-order LinkedHashMap → eldest (least-recently-used) entries iterate first for eviction. + private val flowsByMediaId = LinkedHashMap>(16, 0.75f, true) + private val sinkByPlayer = ConcurrentHashMap() + + fun newSink(): SpectrumAudioBufferSink = SpectrumAudioBufferSink() + + fun registerPlayer( + playerKey: Any, + sink: SpectrumAudioBufferSink, + ) { + sinkByPlayer[playerKey] = sink + } + + /** Points [sink]'s output at the flow for [mediaId] (the item it now plays), or detaches it. */ + fun bind( + mediaId: String?, + sink: SpectrumAudioBufferSink, + ) { + // Mutate the sink's binding under the same lock that guards flow eviction (flowFor is + // reentrant), so the eviction guard never observes a half-updated boundMediaId. + synchronized(lock) { + sink.boundMediaId = mediaId + sink.output = mediaId?.let { flowFor(it) } + } + } + + fun unregisterPlayer(playerKey: Any) { + synchronized(lock) { + sinkByPlayer.remove(playerKey)?.let { + it.output = null + it.boundMediaId = null + } + } + } + + /** Stable spectrum stream for a media URL; frames arrive once a player binds to it and plays. */ + fun spectrumFor(mediaId: String): Flow = flowFor(mediaId) + + private fun flowFor(mediaId: String): MutableSharedFlow = + synchronized(lock) { + flowsByMediaId.getOrPut(mediaId) { + if (flowsByMediaId.size >= MAX_TRACKED_FLOWS) { + val iter = flowsByMediaId.entries.iterator() + while (iter.hasNext() && flowsByMediaId.size >= MAX_TRACKED_FLOWS) { + val entry = iter.next() + // Never evict a flow a live sink is currently feeding, nor one a composable is + // still collecting — otherwise a later bind() would create a fresh instance the + // sink feeds while the old subscriber keeps collecting the dead one (blank viz). + val fedByLiveSink = sinkByPlayer.values.any { it.boundMediaId == entry.key } + val stillCollected = entry.value.subscriptionCount.value > 0 + if (!fedByLiveSink && !stillCollected) iter.remove() + } + } + MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index c73b028646..4b818f1c8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.playback.service import android.app.ForegroundServiceStartNotAllowedException +import android.content.ComponentCallbacks2 import android.content.Intent import android.net.Uri import android.os.Build @@ -163,6 +164,14 @@ class PlaybackService : MediaSessionService() { } } + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + poolNoProxy?.exoPlayerPool?.releaseWarmPool() + poolWithProxy?.exoPlayerPool?.releaseWarmPool() + } + } + override fun onDestroy() { Log.d("PlaybackService", "PlaybackService.onDestroy") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index d641cd8d0c..7339bce641 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.callbackFlow import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -46,6 +47,13 @@ object PlaybackServiceClient { // video, each lingering for the 60s keep-alive afterwards. val executorService: ExecutorService = Executors.newFixedThreadPool(4) + // Number of MediaControllers currently held alive (prepared and not yet released). Two + // controllers alive for the same videoUri at once is the signature of the decoder-init + // collision that surfaces as a transient "Can't play this video": the second one's + // MediaCodec.start() fails because the first still holds a codec instance. Logged on every + // prepare/release so the overlap is visible in a field logcat. + private val liveControllers = AtomicInteger(0) + fun shutdown() { executorService.shutdown() } @@ -83,7 +91,7 @@ object PlaybackServiceClient { .setConnectionHints(bundle) .buildAsync() - Log.d("PlaybackService") { "Preparing Controller $id $videoUri" } + Log.d("PlaybackService") { "Preparing Controller $id (live=${liveControllers.incrementAndGet()}) $videoUri" } controllerFuture.addListener( { @@ -108,7 +116,7 @@ object PlaybackServiceClient { ) awaitClose { - Log.d("PlaybackService") { "Releasing Controller $id $videoUri" } + Log.d("PlaybackService") { "Releasing Controller $id (live=${liveControllers.decrementAndGet()}) $videoUri" } try { MediaController.releaseFuture(controllerFuture) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/TorCircuitHealthTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/TorCircuitHealthTracker.kt new file mode 100644 index 0000000000..edc357cf2a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/TorCircuitHealthTracker.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +/** + * Detects the "Tor is Active but every circuit is dead" state and asks [onCircuitsDead] to + * self-heal it. + * + * Tor can reach [com.vitorpamplona.amethyst.ui.tor.TorServiceStatus.Active] — SOCKS proxy up, + * bootstrap "succeeded" off cached consensus — while no exit circuit actually works (the + * `ExitTimeout` / `RESOLVEFAILED` barrage). The lifecycle watchdogs in + * [com.vitorpamplona.amethyst.ui.tor.TorManager] can't see this: they only arm while status is + * `Connecting`. The relay layer is the only place that has both halves of the signal — per-relay + * success ([RelayConnectionListener.onConnected]) and failure + * ([RelayConnectionListener.onCannotConnect]) — plus knowledge of which relays are Tor-routed. + * + * The discriminator (the whole point) is a *sustained* failure streak: fire only when, while Tor + * is Active and connectivity is up, there is an unbroken run of Tor-routed failures that (a) holds + * at least [FAIL_THRESHOLD] failures and (b) has lasted at least [SUSTAINED_MS], with **zero** + * Tor-routed successes interrupting it. Any Tor success ends the streak — one good open means + * circuits work, so suppress. + * + * The [SUSTAINED_MS] floor is essential, not cosmetic: the instant Tor flips Active the pool dials + * the whole Tor-routed relay set at once, and on freshly built circuits a burst of ≥8 can fail + * *before* the first Tor relay completes its handshake. Counting failures in a short window would + * fire ~2s into a perfectly healthy warmup and wipe the good client (observed on device). Requiring + * the streak to span [SUSTAINED_MS] gives a natural post-Active warmup grace — a real success + * during warmup clears the streak; only genuinely dead circuits keep failing for the full span. + * A gap longer than [SUSTAINED_MS] between failures also restarts the streak, so sparse unrelated + * failures never accumulate. + * + * Gating on connectivity avoids resetting Tor during a general network outage (where clearnet + * relays fail too). Arti emits no per-stream success log, so this success signal can only come from + * the relay layer — a failure-only signal can't tell "all dead" from "some dead", and would reset + * Tor every time a few dead relays are dialed. + * + * Register with [register] and tear down with [unregister]. All callbacks arrive on relay/OkHttp + * threads, so the streak state is guarded by an intrinsic lock. + */ +class TorCircuitHealthTracker( + private val client: INostrClient, + private val isTorRouted: (NormalizedRelayUrl) -> Boolean, + private val isTorActive: () -> Boolean, + private val isConnectivityActive: () -> Boolean, + private val onCircuitsDead: () -> Unit, + private val nowMs: () -> Long = System::currentTimeMillis, +) { + /** Epoch-millis of the first failure in the current unbroken streak, or -1 when no streak is open. */ + private var streakStartMs: Long = -1L + + /** Epoch-millis of the most recent failure in the current streak. */ + private var lastFailureMs: Long = -1L + + /** Failures in the current streak. */ + private var streakCount: Int = 0 + + private val listener = + object : RelayConnectionListener { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + if (!isTorRouted(relay.url)) return + // One Tor success means circuits work — end the streak. + synchronized(this@TorCircuitHealthTracker) { resetStreak() } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + if (!isTorRouted(relay.url)) return + if (!isTorActive() || !isConnectivityActive()) return + + val now = nowMs() + val fire = + synchronized(this@TorCircuitHealthTracker) { + // Start a fresh streak on the first failure, or whenever the gap since the + // last failure is too long for them to count as the same sustained outage. + if (streakStartMs < 0 || now - lastFailureMs > SUSTAINED_MS) { + streakStartMs = now + streakCount = 0 + } + streakCount++ + lastFailureMs = now + + if (streakCount >= FAIL_THRESHOLD && now - streakStartMs >= SUSTAINED_MS) { + resetStreak() // re-arm; a fresh streak must build before firing again + true + } else { + false + } + } + + if (fire) { + Log.w(TAG) { "Tor Active but $FAIL_THRESHOLD+ Tor relays failed for ${SUSTAINED_MS}ms+ with no success — requesting self-heal" } + onCircuitsDead() + } + } + } + + private fun resetStreak() { + streakStartMs = -1L + lastFailureMs = -1L + streakCount = 0 + } + + fun register() { + client.addConnectionListener(listener) + } + + fun unregister() { + client.removeConnectionListener(listener) + } + + companion object { + const val TAG = "TorCircuitHealthTracker" + + /** Unbroken Tor-routed failures needed (alongside the [SUSTAINED_MS] floor) to declare circuits dead. */ + const val FAIL_THRESHOLD = 8 + + /** + * The failure streak must span at least this long before it counts as a dead transport. + * Doubles as the post-Active warmup grace and the max gap between failures of one streak. + */ + const val SUSTAINED_MS = 30_000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index 9c5c15f1cc..f17945b416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -25,8 +25,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -36,17 +38,32 @@ fun RelayAuthSubscription( accountViewModel: AccountViewModel, dataSource: AuthCoordinator, ) { - // different screens get different states - // even if they are tracking the same tag. + val account = accountViewModel.account + val state = remember(accountViewModel) { - ScreenAuthAccount(accountViewModel.account) + ScreenAuthAccount(account) } - DisposableEffect(state) { + val ledger = + remember(accountViewModel) { + RelayAuthPermissionLedger( + store = Amethyst.instance.relayAuthPermissionStore, + globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, + isInMyRelayList = { relayUrl -> + val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false + normalized !in account.blockedRelayList.flow.value && + normalized in account.trustedRelays.flow.value + }, + ) + } + + DisposableEffect(state, ledger) { dataSource.subscribe(state) + dataSource.subscribeLedger(ledger) onDispose { dataSource.unsubscribe(state) + dataSource.unsubscribeLedger(ledger) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 80c11d79d5..e8e1252cea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -43,30 +44,56 @@ class AuthCoordinator( NostrSignerSync() } + @Volatile private var relayLedgers: List = emptyList() + + fun subscribeLedger(ledger: RelayAuthPermissionLedger) { + synchronized(this) { relayLedgers = relayLedgers + ledger } + } + + fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) { + synchronized(this) { relayLedgers = relayLedgers - ledger } + } + val receiver = RelayAuthenticator( client, scope, - signWithAllLoggedInUsers = { authTemplate -> - val results = - authWithAccounts.distinct().mapNotNull { - if (it.signer.isWriteable()) { - try { - it.signer.sign(authTemplate) - } catch (e: Exception) { - Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) - null + signWithAllLoggedInUsers = { relayUrl, authTemplate -> + val currentLedgers = relayLedgers + val shouldAuth = + if (currentLedgers.isEmpty()) { + true + } else { + var allow = false + for (ledger in currentLedgers) { + if (ledger.decide(relayUrl.url) == RelayAuthDecision.ALLOW) { + allow = true + break } - } else { - null } + allow } - // Always auth, even with random keys - if (!results.isEmpty()) { - results + if (shouldAuth) { + // distinct() returns Set (the key type U of ListWithUniqueSetCache) + val results = + authWithAccounts.distinct().mapNotNull { + if (it.signer.isWriteable()) { + try { + it.signer.sign(authTemplate) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + null + } + } else { + null + } + } + + // Always auth, even with random keys + if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate)) } else { - listOf(tempAccount.sign(authTemplate)) + emptyList() } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt new file mode 100644 index 0000000000..67e2b22817 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import kotlinx.coroutines.flow.first +import java.io.File +import java.security.MessageDigest + +/** + * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides + * live in one `datastore/relay_auth.preferences_pb` file; a SHA-256 prefix of the URL is the + * key so the URL itself is safe in the file (stored separately for reverse-lookup in [allDecisions]). + */ +class DataStoreRelayAuthPermissionStore( + private val filesDir: File, +) : RelayAuthPermissionStore { + constructor(context: Context) : this(context.applicationContext.filesDir) + + private val store: DataStore by lazy { + PreferenceDataStoreFactory.create( + produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") }, + ) + } + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { + val raw = store.data.first()[decisionKey(relayUrl)] ?: return null + return runCatching { RelayAuthDecision.valueOf(raw) }.getOrNull() + } + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + store.edit { + it[urlKey(relayUrl)] = relayUrl + it[decisionKey(relayUrl)] = decision.name + } + } + + override suspend fun clearDecision(relayUrl: String) { + store.edit { + it.remove(decisionKey(relayUrl)) + it.remove(urlKey(relayUrl)) + } + } + + override suspend fun allDecisions(): Map { + val prefs = store.data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(DECISION_PREFIX)) continue + val hash = name.removePrefix(DECISION_PREFIX) + val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue + val decision = runCatching { RelayAuthDecision.valueOf(value as String) }.getOrNull() ?: continue + result[url] = decision + } + return result + } + + private fun decisionKey(relayUrl: String) = stringPreferencesKey("$DECISION_PREFIX${hash(relayUrl)}") + + private fun urlKey(relayUrl: String) = stringPreferencesKey("$URL_PREFIX${hash(relayUrl)}") + + companion object { + private const val DECISION_PREFIX = "allow:" + private const val URL_PREFIX = "url:" + + private fun hash(relayUrl: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt new file mode 100644 index 0000000000..e7c5c69ca2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy + +/** + * Decides whether Amethyst should authenticate with a given relay (NIP-42). + * + * Decision order: + * 1. Per-relay override stored in [store] — always wins. + * 2. [globalPolicy]: + * - [RelayAuthPolicy.ALWAYS] → [RelayAuthDecision.ALLOW] + * - [RelayAuthPolicy.NEVER] → [RelayAuthDecision.DENY] + * - [RelayAuthPolicy.IF_IN_MY_LIST] → [RelayAuthDecision.ALLOW] iff [isInMyRelayList] returns true. + */ +class RelayAuthPermissionLedger( + val store: RelayAuthPermissionStore, + val globalPolicy: () -> RelayAuthPolicy, + val isInMyRelayList: (String) -> Boolean = { false }, +) { + /** The authorization verdict for [relayUrl]. */ + suspend fun decide(relayUrl: String): RelayAuthDecision { + store.loadDecision(relayUrl)?.let { return it } + return when (globalPolicy()) { + RelayAuthPolicy.ALWAYS -> RelayAuthDecision.ALLOW + RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY + RelayAuthPolicy.IF_IN_MY_LIST -> + if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY + } + } + + /** Stores a per-relay override for [relayUrl]. */ + suspend fun setDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) = store.storeDecision(relayUrl, decision) + + /** Removes the per-relay override for [relayUrl], reverting to the global policy. */ + suspend fun clearDecision(relayUrl: String) = store.clearDecision(relayUrl) + + /** All per-relay overrides — for the settings screen. */ + suspend fun allDecisions(): Map = store.allDecisions() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt new file mode 100644 index 0000000000..4aefae6ba6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.diagnostics + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic connection listener for the DM loading path — both NIP-17 gift wraps + * (kind 1059 / 21059) and NIP-04 legacy DMs (kind 4). + * + * It folds the per-relay timeline — REQ sent, connect/disconnect, NOTICE / CLOSED + * rejection and OK failures — into the single `DMPagination` log tag with an + * elapsed-time prefix, so a slow cold boot can be attributed (connection? relay + * response?) and a silent failure to load (e.g. a relay answering CLOSED + * "auth-required" / "restricted") becomes visible. Per-event lines are omitted to + * keep the trail readable, but the NIP-42 AUTH handshake (challenge in, AUTH out, + * accept/reject) IS logged: an auth-walled relay's whole load hinges on whether + * that round-trip closes and a re-REQ follows, so it has to be attributable here. + * + * The connection listener fires for EVERY relay the app talks to (hundreds, under + * the outbox model). To keep this readable we only log relays that are part of the + * DM path: a relay is "learned" the first time we send it a kind:1059/21059/4 + * REQ, and only those relays' connect/auth/notice lines are emitted thereafter. + */ +class DmRelayDiagnosticsLogger( + val client: INostrClient, +) { + private val startMs = System.currentTimeMillis() + + private fun at() = System.currentTimeMillis() - startMs + + // Subscription ids whose REQ carried a DM kind, so we can attribute their EOSE/CLOSED. + private val dmSubIds = mutableSetOf() + + // Relays we've seen on the DM path, so connect/auth/notice noise from the + // hundreds of unrelated follow/outbox relays is filtered out. + private val dmPathRelays = mutableSetOf() + + // Ids of the AUTH events we've sent to DM relays, so the relay's OK can be tagged "AUTH accepted / + // REJECTED" (the OK that decides whether the post-auth re-REQ will actually be served) instead of + // being lost among ordinary event OKs. + private val authEventIds = mutableSetOf() + + private fun isDmRelay(relay: IRelayClient) = relay.url in dmPathRelays + + private val listener = + object : RelayConnectionListener { + override fun onConnecting(relay: IRelayClient) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] connecting ${relay.url.url}" } + } + + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + if (isDmRelay(relay)) { + Log.d(TAG) { "[+${at()}ms] connected ${relay.url.url} (ping ${pingMillis}ms${if (compressed) ", compressed" else ""})" } + } + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is AuthCmd) { + // Our reply to a relay's NIP-42 challenge. Remember the id so the relay's OK can be + // tagged as the auth result below. Only for relays already on the DM path. + if (isDmRelay(relay)) { + authEventIds.add(cmd.event.id) + Log.d(TAG) { "[+${at()}ms] AUTH -> ${relay.url.url} success=$success (id ${cmd.event.id.take(8)})" } + } + return + } + if (!isDmReq(cmdStr)) return + dmPathRelays.add(relay.url) + reqSubId(cmdStr)?.let { dmSubIds.add(it) } + Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is AuthMessage -> + // The relay's NIP-42 challenge. Without it (and the AUTH/OK that follow) an + // auth-walled relay can never serve, so it's the first link to look for. + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] AUTH challenge <- ${relay.url.url} '${msg.challenge.take(40)}'" } + + is NoticeMessage -> + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } + + is ClosedMessage -> + if (msg.subId in dmSubIds) { + Log.d(TAG) { "[+${at()}ms] CLOSED <- ${relay.url.url} sub=${msg.subId} reason='${msg.message}'" } + } + + is OkMessage -> + if (msg.eventId in authEventIds) { + // The auth result: "accepted" means a syncFilters re-REQ should now be served; + // "REJECTED" means this relay will keep refusing and never serve our DMs. + Log.d(TAG) { "[+${at()}ms] AUTH ${if (msg.success) "accepted" else "REJECTED"} <- ${relay.url.url} '${msg.message}'" } + } else if (!msg.success && isDmRelay(relay)) { + Log.d(TAG) { "[+${at()}ms] OK(fail) <- ${relay.url.url} '${msg.message}'" } + } + + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] disconnected ${relay.url.url}" } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] CANNOT CONNECT ${relay.url.url}: $errorMessage" } + } + } + + init { + client.addConnectionListener(listener) + } + + fun destroy() { + client.removeConnectionListener(listener) + } + + companion object { + private const val TAG = "DMPagination" + + // The kinds a DM-path REQ carries: NIP-17 gift wraps (1059 + 21059) and NIP-04 legacy DMs + // (4). Matched exactly against the filter's "kinds" array — never as a substring of the whole + // command, since a pubkey hex or timestamp can incidentally contain "1059" or "4". + private val DM_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, PrivateDmEvent.KIND) + + private val KINDS_ARRAY = Regex("\"kinds\":\\[([0-9,\\s]*)]") + + // Extracts the subscription id from a `["REQ","",{...}]` command string. + private val REQ_SUB_ID = Regex("^\\[\"REQ\",\"([^\"]+)\"") + + private fun reqSubId(cmdStr: String) = REQ_SUB_ID.find(cmdStr)?.groupValues?.get(1) + + /** True only when one of the REQ's `kinds` arrays actually contains a DM kind. */ + private fun isDmReq(cmdStr: String): Boolean = + KINDS_ARRAY.findAll(cmdStr).any { match -> + match.groupValues[1] + .split(',') + .any { it.trim().toIntOrNull() in DM_KINDS } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt new file mode 100644 index 0000000000..39d1370a16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic for "which account is bringing which relays into the DM filters". For each DM + * subscription it prints the account whose relays are used and breaks the relay set down by the + * source list each relay comes from (NIP-65 inbox/outbox, the DM-relay-list, the private-storage + * outbox, local relays). Use it to trace an unexpected relay — e.g. a write-only NIP-65 relay that + * only the NIP-04 (home+dm) path queries — back to the list it leaks in from. + */ +object DmRelayLog { + private const val TAG = "DMPagination" + + fun log( + label: String, + account: Account, + ) = Log.d(TAG) { + val pk = account.userProfile().pubkeyHex.take(8) + val inbox = account.nip65RelayList.inboxFlow.value + val outbox = account.nip65RelayList.outboxFlow.value + val dmList = account.dmRelayList.flow.value + val priv = account.privateStorageRelayList.flow.value + val local = account.localRelayList.flow.value + buildString { + append("[$label] account=$pk relays by source:") + appendSource("nip65In", inbox) + appendSource("nip65Out", outbox) + appendSource("dmList", dmList) + appendSource("private", priv) + appendSource("local", local) + } + } + + private fun StringBuilder.appendSource( + name: String, + relays: Collection, + ) { + if (relays.isNotEmpty()) append(" $name=${relays.map { it.url }}") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9357f8fee5..87084e060f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datasource.ProfileAppRecommendationsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler @@ -46,15 +47,19 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler @@ -68,8 +73,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus @@ -97,8 +104,8 @@ class RelaySubscriptionsCoordinator( // loaders of content that is not yet in the device. // they are active when looking at events, users, channels. val channelFinder = ChannelFinderFilterAssemblyGroup(client) - val eventFinder = EventFinderFilterAssembler(client) val userFinder = UserFinderFilterAssembler(client, cache, failureTracker) + val eventFinder = EventFinderFilterAssembler(client, cache, userFinder) // active when searching or tagging users. val search = SearchFilterAssembler(client, scope, cache) @@ -112,6 +119,7 @@ class RelaySubscriptionsCoordinator( val profile = UserProfileFilterAssembler(client) val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) + val urls = UrlFilterAssembler(client) val relayFeed = RelayFeedFilterAssembler(client) val relayInfoNip66 = RelayInfoNip66FilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) @@ -120,6 +128,8 @@ class RelaySubscriptionsCoordinator( val polls = PollsFilterAssembler(client) val pictures = PicturesFilterAssembler(client) + val workouts = WorkoutsFilterAssembler(client) + val gitRepositories = GitRepositoriesFilterAssembler(client) val calendars = CalendarsFilterAssembler(client) val products = ProductsFilterAssembler(client) val shorts = ShortsFilterAssembler(client) @@ -136,8 +146,12 @@ class RelaySubscriptionsCoordinator( val podcasts = PodcastsFilterAssembler(client) val onePodcast = OnePodcastFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) + val napplets = NappletsFilterAssembler(client) + val connectedApps = ConnectedAppsFilterAssembler(client) + val nsites = NsitesFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) + val profileAppRecommendations = ProfileAppRecommendationsFilterAssembler(client) val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client) val communitiesList = CommunitiesListFilterAssembler(client) @@ -166,6 +180,8 @@ class RelaySubscriptionsCoordinator( discovery, polls, pictures, + workouts, + gitRepositories, calendars, products, shorts, @@ -185,6 +201,7 @@ class RelaySubscriptionsCoordinator( softwareApps, badges, profileBadges, + profileAppRecommendations, browseEmojiSets, communitiesList, channelFinder, @@ -199,6 +216,7 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, + urls, relayFeed, relayInfoNip66, chess, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 6b28e608be..e654e9df0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot. import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -49,10 +50,17 @@ class AccountQueryState( class AccountFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // Live tail: the recent week of gift wraps, always open at the top for new messages. + val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys) + + // History: older gift wraps, loaded on demand in bounded one-shot slices. + val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys) + val group = listOf( AccountMetadataEoseManager(client, ::allKeys), - AccountGiftWrapsEoseManager(client, ::allKeys), + giftWraps, + giftWrapsHistory, AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), MarmotGroupEventsEoseManager(client, ::allKeys), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 10cca27dc1..aeef3e5da1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -40,6 +40,8 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -84,6 +86,8 @@ val NotificationsPerKeyKinds2 = GitReplyEvent.KIND, GitIssueEvent.KIND, GitPatchEvent.KIND, + GitPullRequestEvent.KIND, + GitPullRequestUpdateEvent.KIND, HighlightEvent.KIND, CommentEvent.KIND, CalendarDateSlotEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 8f5484a1af..1ddf8e8028 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -20,8 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -29,58 +33,68 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * Always-on **live tail** for the account's NIP-17 gift wraps (kind 1059). It keeps a fixed + * one-week floor with no upper bound, so the messages list is usable on boot and new incoming + * messages always stream in. It deliberately never widens: pulling older history is the job of + * [AccountGiftWrapsHistoryEoseManager], which fetches the past in bounded, one-shot slices so a + * widen never re-streams what this tail already holds. + */ class AccountGiftWrapsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() + // The initial-load tracker drives the boot spinner: it stays true until every DM relay has + // settled (EOSE / CLOSED / cannot-connect) on the one-week tail. + private val windowLoad = WindowLoadTracker("giftwrap.live") + val loadingMore: StateFlow = windowLoad.loading + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // Only loads DMs if the account is writeable - return if (key.account.isWriteable()) { - val relays = key.account.dmRelays.flow.value - Log.d("MarmotDbg") { - "AccountGiftWrapsEoseManager.updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… " + - "subscribing kind:1059 on ${relays.size} dmRelay(s): ${relays.map { it.url }}" - } - relays.flatMap { relay -> - filterGiftWrapsToPubkey( - relay = relay, - pubkey = user(key).pubkeyHex, - since = since?.get(relay)?.time, - ) - } - } else { - Log.d("MarmotDbg") { "AccountGiftWrapsEoseManager.updateFilter: account not writeable, skipping" } - emptyList() + if (!key.account.isWriteable()) { + windowLoad.setExpectedRelays(emptySet()) + return emptyList() + } + val relays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays(relays.toSet()) + val sinceTime = DmHistoryTuning.recentBoundary() + DmRelayLog.log("giftwrap.live", key.account) + Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (no until) on ${relays.size} relay(s): ${relays.map { it.url }}" } + return relays.flatMap { relay -> + filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = sinceTime) } } - val userJobMap = mutableMapOf>() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } }, ) - return super.newSub(key) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) } override fun endSub( @@ -90,4 +104,8 @@ class AccountGiftWrapsEoseManager( super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } + + companion object { + private const val TAG = "DMPagination" + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt new file mode 100644 index 0000000000..93f94d2af0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail + * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay, on demand**. + * + * There is no proactive walk: each relay advances exactly one page when the UI calls [advance] for it, + * and then **parks** at its window limit. The on-screen window-limit markers are the drivers — a relay + * pages only while its marker is visible, and keeps paging (page after page) as long as it stays visible + * (see the rooms-list / conversation feed views). So a spam-dense relay never floods: the user has to + * scroll through its messages to pull more, and nothing is fetched while its marker is off screen. + * + * The per-relay cursors live on the account's [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList] + * (so they share the lifetime of the cached gift-wraps); this class binds the single-active + * [BackwardRelayPager] orchestrator to them on [newSub], builds the gift-wrap REQ filters, and forwards + * relay callbacks into the pager. A relay is *done* once it answers an empty page; one that won't answer + * (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] flips once every relay + * is either done or stalled. + */ +class AccountGiftWrapsHistoryEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + private val pager = BackwardRelayPager("giftwrap.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + if (!key.account.isWriteable()) return emptyList() + // Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a + // page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't + // re-REQ it — it stays parked until the UI advances it again. + val relays = key.account.dmRelays.flow.value + val armed = pager.armedRelays(relays) + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("giftwrap.history", key.account) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + Log.d(TAG) { "[giftwrap.history] REQ ${relay.url} until ${daysAgo(until)}d, limit=${pager.pageLimit}" } + filterGiftWrapsToPubkey(relay = relay, pubkey = key.account.userProfile().pubkeyHex, since = null, until = until, limit = pager.pageLimit) + } + } + + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d(TAG) { "[giftwrap.history] advanceAll (empty-feed bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: AccountQueryState): Subscription { + // Repoint the single-active orchestrator at this account's gift-wrap cursors (on its ChatroomList) + // and the relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(key.account.chatroomList.giftWrapHistory, key.account.scope) { key.account.dmRelays.flow.value } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: AccountQueryState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.giftWrapHistory + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } + } + // No auto-advance: the relay parks here until its marker asks for the next page. + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } + + companion object { + private const val TAG = "DMPagination" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index 29a6767490..3e00e65548 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -23,9 +23,12 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.AddressableAuthorRelayLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @@ -38,11 +41,14 @@ class EventFinderQueryState( @Stable class EventFinderFilterAssembler( client: INostrClient, + cache: LocalCache, + userFinder: UserFinderFilterAssembler, ) : ComposeSubscriptionManager() { val group = listOf( NoteEventLoaderSubAssembler(client, ::allKeys), EventWatcherSubAssembler(client, ::allKeys), + AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder), ) override fun invalidateFilters() = group.forEach { it.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt new file mode 100644 index 0000000000..5f81cb8d22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState + +/** + * Bridges missing-addressable-note authors into [UserFinderFilterAssembler]. + * + * When an [AddressableNote] stub (event == null) references an author whose NIP-65 relay list + * has not been loaded yet, [potentialRelaysToFindAddress] returns an empty set and the event + * is never fetched. This manager detects those cases and injects a [UserFinderQueryState] for + * the author into the existing [UserFinderFilterAssembler], which already knows how to fetch + * kind-0 / kind-10002 and resolve outbox relays via [UserOutboxFinderSubAssembler]. Once the + * relay list arrives, [EventFinderFilterAssembler] is invalidated and can query the correct relay. + */ +class AddressableAuthorRelayLoaderSubAssembler( + val cache: LocalCache, + val allKeys: () -> Set, + val userFinder: UserFinderFilterAssembler, +) : IEoseManager { + private val activeSubscriptions = mutableSetOf() + + override fun invalidateFilters(ignoreIfDoing: Boolean) { + val needed = mutableSetOf() + + allKeys().forEach { key -> + val note = key.note + if (note is AddressableNote && note.event == null) { + val author = cache.getOrCreateUser(note.address.pubKeyHex) + if (author.authorRelayList() == null) { + needed.add(UserFinderQueryState(author, key.account)) + } + } + } + + val toAdd = needed - activeSubscriptions + val toRemove = activeSubscriptions - needed + + userFinder.subscribe(toAdd.toList()) + userFinder.unsubscribe(toRemove.toList()) + + activeSubscriptions.clear() + activeSubscriptions.addAll(needed) + } + + override fun destroy() { + userFinder.unsubscribe(activeSubscriptions.toList()) + activeSubscriptions.clear() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 30db8918c5..447b4f68d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -150,9 +150,15 @@ fun observeUserBanner( fun observeUserPicture( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ): State { // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) + // Callers that already hold a single shared subscription for the same user + // (e.g. an author avatar that also observes the contact-card score) can pass + // subscribe = false to avoid setting up a redundant relay subscription. + if (subscribe) { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + } // Subscribe in the LocalCache for changes that arrive in the device val flow = @@ -476,9 +482,14 @@ fun observeUserIsFollowingChannel( fun observeUserContactCardsScore( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ): State { // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) + // See observeUserPicture: pass subscribe = false when a shared subscription + // for the same user already exists. + if (subscribe) { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + } // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(user) { user.cards().rankFlow(accountViewModel.account.trustProviderList) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index f5a792034d..3d5248207c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount @@ -52,7 +53,6 @@ import okio.ForwardingSource import okio.source import java.io.File import java.io.InputStream -import java.util.Base64 class BlossomUploader { fun Context.getFileName(uri: Uri): String? = @@ -120,11 +120,6 @@ class BlossomUploader { }.mergeLocalMetadata(localMetadata) } - fun encodeAuth(event: BlossomAuthorizationEvent): String { - val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) - return "Nostr $encodedNIP98Event" - } - suspend fun upload( inputStream: InputStream, hash: HexKey, @@ -147,7 +142,7 @@ class BlossomUploader { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it) } ?: "" - val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val apiUrl = BlossomServerUrl.upload(serverBaseUrl) val client = okHttpClient(apiUrl) val requestBuilder = Request.Builder() @@ -187,7 +182,7 @@ class BlossomUploader { } httpAuth(hash, length, alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let { - requestBuilder.addHeader("Authorization", encodeAuth(it)) + requestBuilder.addHeader("Authorization", it.toAuthorizationHeader()) } contentType?.let { requestBuilder.addHeader("Content-Type", it) } @@ -206,7 +201,7 @@ class BlossomUploader { convertToMediaResult(parseResults(body.string())) } } else { - val errorMessage = response.headers.get("X-Reason") + val errorMessage = response.headers.get(BlossomServerUrl.REASON_HEADER) val explanation = HttpStatusMessages.resourceIdFor(response.code) if (errorMessage != null) { @@ -253,12 +248,12 @@ class BlossomUploader { val requestBuilder = Request.Builder() httpAuth(hash, "Deleting $hash")?.let { - requestBuilder.addHeader("Authorization", encodeAuth(it)) + requestBuilder.addHeader("Authorization", it.toAuthorizationHeader()) } val request = requestBuilder - .url(apiUrl.removeSuffix("/") + "/$hash.$extension") + .url(BlossomServerUrl.blob(apiUrl, hash, extension)) .delete() .build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt new file mode 100644 index 0000000000..bfb18e2e8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt @@ -0,0 +1,50 @@ +/* + * 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.workouts.health + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType + +/** + * A finished workout read from Health Connect and mapped to the fields Amethyst + * can publish as a NIP-101e kind 1301 event. Platform-neutral and free of any + * Health Connect types so it can feed the navigation route and the suggestion + * UI directly. + * + * [id] is the Health Connect record id, used to remember which sessions the + * user has already handled (accepted or dismissed) so each is offered once. + */ +@Immutable +data class DetectedWorkout( + val id: String, + val exercise: ExerciseType, + val title: String?, + val startTimeEpochSeconds: Long, + val durationSeconds: Long, + val distanceMeters: Double?, + val calories: Int?, + val avgHeartRate: Int?, + val maxHeartRate: Int?, + val steps: Int?, + val elevationGainMeters: Double?, + /** Human-readable name of the app/device that wrote the record (e.g. "Samsung Health"). */ + val source: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/ExerciseTypeMapper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/ExerciseTypeMapper.kt new file mode 100644 index 0000000000..bec70a2327 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/ExerciseTypeMapper.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.workouts.health + +import androidx.health.connect.client.records.ExerciseSessionRecord +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType + +/** + * Maps a Health Connect [ExerciseSessionRecord.exerciseType] integer to the + * NIP-101e [ExerciseType] verb. Returns null for activity types Amethyst's + * workout vocabulary cannot represent, so those sessions are skipped rather + * than mislabeled. + */ +object ExerciseTypeMapper { + fun toExerciseType(healthConnectType: Int): ExerciseType? = + when (healthConnectType) { + ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, + ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL, + -> ExerciseType.RUNNING + + ExerciseSessionRecord.EXERCISE_TYPE_WALKING, + -> ExerciseType.WALKING + + ExerciseSessionRecord.EXERCISE_TYPE_BIKING, + ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY, + -> ExerciseType.CYCLING + + ExerciseSessionRecord.EXERCISE_TYPE_HIKING, + -> ExerciseType.HIKING + + ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL, + ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER, + -> ExerciseType.SWIMMING + + ExerciseSessionRecord.EXERCISE_TYPE_ROWING, + ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE, + -> ExerciseType.ROWING + + ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING, + ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS, + -> ExerciseType.STRENGTH + + ExerciseSessionRecord.EXERCISE_TYPE_YOGA, + -> ExerciseType.YOGA + + else -> null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt new file mode 100644 index 0000000000..9daaaedb05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt @@ -0,0 +1,238 @@ +/* + * 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.workouts.health + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.aggregate.AggregationResult +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ElevationGainedRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.TotalCaloriesBurnedRecord +import androidx.health.connect.client.request.AggregateRequest +import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.time.TimeRangeFilter +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import java.time.Duration +import java.time.Instant +import kotlin.math.roundToInt + +/** + * Reads finished workouts from Android Health Connect — the single aggregator + * every Android health source funnels into (Samsung Health/Galaxy Watch, + * Google Fit, Fitbit, Garmin Connect, Strava, …) — and maps each session to a + * [DetectedWorkout] ready to become a NIP-101e kind 1301 event. + * + * Read-only. The caller decides when to request permission; this class never + * prompts on its own. + */ +class HealthConnectManager( + private val context: Context, +) { + private val client: HealthConnectClient by lazy { HealthConnectClient.getOrCreate(context) } + + companion object { + private const val TAG = "HealthConnectManager" + + /** How far back the New Workout carousel looks for workouts to offer. */ + const val LOOKBACK_DAYS = 7L + + private const val DEFAULT_SOURCE = "Health Connect" + + /** Friendly names for well-known writers when their app isn't installed to read a label from. */ + private val KNOWN_SOURCES = + mapOf( + "com.sec.android.app.shealth" to "Samsung Health", + "com.google.android.apps.fitness" to "Google Fit", + "com.google.android.apps.healthdata" to "Health Connect", + "com.fitbit.FitbitMobile" to "Fitbit", + "com.garmin.android.apps.connectmobile" to "Garmin Connect", + "com.strava" to "Strava", + "com.nike.plusgps" to "Nike Run Club", + ) + + /** Read permissions needed to map a workout. */ + val PERMISSIONS = + setOf( + HealthPermission.getReadPermission(ExerciseSessionRecord::class), + HealthPermission.getReadPermission(DistanceRecord::class), + HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class), + HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), + HealthPermission.getReadPermission(HeartRateRecord::class), + HealthPermission.getReadPermission(StepsRecord::class), + HealthPermission.getReadPermission(ElevationGainedRecord::class), + ) + + /** True when a Health Connect provider is installed and up to date on this device. */ + fun isAvailable(context: Context): Boolean = HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE + + /** True when the device has no Health Connect provider at all (vs needing an update). */ + fun needsProviderUpdate(context: Context): Boolean = HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED + } + + suspend fun grantedPermissions(): Set = client.permissionController.getGrantedPermissions() + + suspend fun hasAllPermissions(): Boolean { + // Some OEM builds report the provider as SDK_AVAILABLE yet fail to bind to the + // Health Connect service (e.g. RemoteException "Binding to service failed" on + // ITEL/low-end devices). Treat any such failure as "not granted" instead of + // letting it crash the app — the carousel then quietly stays in its prompt state. + val granted = + try { + grantedPermissions() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to read Health Connect granted permissions", e) + return false + } + val ok = granted.containsAll(PERMISSIONS) + if (!ok) { + Log.i(TAG) { "hasAllPermissions=false; missing=${PERMISSIONS - granted}" } + } else { + Log.i(TAG) { "hasAllPermissions=true (${granted.size} granted)" } + } + return ok + } + + /** + * All exercise sessions that ended within [since]..[now], mapped to + * [DetectedWorkout]. Sessions whose activity type Amethyst cannot represent + * are skipped. Returns an empty list (never throws) if Health Connect is + * unavailable or a read fails. + */ + suspend fun readNewWorkouts( + since: Instant, + now: Instant = Instant.now(), + ): List { + if (!isAvailable(context)) { + Log.i(TAG) { "readNewWorkouts: Health Connect unavailable (status=${HealthConnectClient.getSdkStatus(context)})" } + return emptyList() + } + + return try { + val response = + client.readRecords( + ReadRecordsRequest( + recordType = ExerciseSessionRecord::class, + timeRangeFilter = TimeRangeFilter.between(since, now), + ), + ) + Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" } + val mapped = response.records.mapNotNull { mapSession(it) } + Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} workout(s) after type/duration filtering" } + mapped + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to read workouts from Health Connect", e) + emptyList() + } + } + + private suspend fun mapSession(session: ExerciseSessionRecord): DetectedWorkout? { + val exercise = ExerciseTypeMapper.toExerciseType(session.exerciseType) + if (exercise == null) { + Log.i(TAG) { + "Skipping session: unmapped exerciseType=${session.exerciseType} " + + "title='${session.title}' from ${session.metadata.dataOrigin.packageName}" + } + return null + } + + val durationSeconds = Duration.between(session.startTime, session.endTime).seconds + if (durationSeconds <= 0) { + Log.i(TAG) { "Skipping session: non-positive duration ($durationSeconds s) for exerciseType=${session.exerciseType}" } + return null + } + + Log.i(TAG) { + "Mapped session: exerciseType=${session.exerciseType} -> $exercise, ${durationSeconds}s, " + + "from ${session.metadata.dataOrigin.packageName}" + } + + val totals = aggregate(session) + + return DetectedWorkout( + id = session.metadata.id, + exercise = exercise, + title = session.title?.takeIf { it.isNotBlank() }, + startTimeEpochSeconds = session.startTime.epochSecond, + durationSeconds = durationSeconds, + distanceMeters = totals?.get(DistanceRecord.DISTANCE_TOTAL)?.inMeters, + // Prefer active calories (what RUNSTR publishes); fall back to total for + // sources that only record total energy. Total includes basal burn, so it + // over-reports the workout if used as the primary figure. + calories = + ( + totals?.get(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)?.inKilocalories + ?: totals?.get(TotalCaloriesBurnedRecord.ENERGY_TOTAL)?.inKilocalories + )?.roundToInt(), + avgHeartRate = totals?.get(HeartRateRecord.BPM_AVG)?.toInt(), + maxHeartRate = totals?.get(HeartRateRecord.BPM_MAX)?.toInt(), + steps = totals?.get(StepsRecord.COUNT_TOTAL)?.toInt(), + elevationGainMeters = totals?.get(ElevationGainedRecord.ELEVATION_GAINED_TOTAL)?.inMeters, + source = resolveSourceName(session.metadata.dataOrigin.packageName), + ) + } + + /** + * Friendly name of the app/device that wrote the record. Resolves the + * Health Connect data-origin package to the installed app's label + * ("Samsung Health", "Google Fit", …); falls back to a known-package map, + * then the raw package, then "Health Connect" when nothing is available. + */ + private fun resolveSourceName(packageName: String): String { + if (packageName.isBlank()) return DEFAULT_SOURCE + runCatching { + val pm = context.packageManager + return pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + } + return KNOWN_SOURCES[packageName] ?: packageName + } + + /** Aggregates the optional metrics over the session window. Null if aggregation fails. */ + private suspend fun aggregate(session: ExerciseSessionRecord): AggregationResult? = + try { + client.aggregate( + AggregateRequest( + metrics = + setOf( + DistanceRecord.DISTANCE_TOTAL, + ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL, + TotalCaloriesBurnedRecord.ENERGY_TOTAL, + HeartRateRecord.BPM_AVG, + HeartRateRecord.BPM_MAX, + StepsRecord.COUNT_TOTAL, + ElevationGainedRecord.ELEVATION_GAINED_TOTAL, + ), + timeRangeFilter = TimeRangeFilter.between(session.startTime, session.endTime), + ), + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to aggregate workout metrics", e) + null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 5eac5e817f..eeef549f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.UriParser import kotlinx.coroutines.CancellationException @@ -58,6 +59,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import java.net.URLDecoder class MainActivity : AppCompatActivity() { companion object { @@ -147,6 +149,35 @@ fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) || fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=") +fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=") + +fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=") + +/** + * The Connected Apps permission-detail route for an app coordinate (`pubkey:dtag` for a napplet/nsite, + * `browser:` for a web client). Fired by the sandbox host so a running full-screen surface can + * jump straight to its editable permissions; the coordinate is URL-encoded into the [coordinate] param. + */ +fun connectedAppRoute(uri: String): Route.ConnectedAppDetail? { + val coordinate = + runCatching { + val raw = java.net.URI(uri.removePrefix("nostr:")).findParameterValue("coordinate") ?: return null + URLDecoder.decode(raw, Charsets.UTF_8.name()) + }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null + + return Route.ConnectedAppDetail(coordinate) +} + +fun urlRoute(uri: String): Route.Url? { + val url = + runCatching { + val rawUrl = java.net.URI(uri.removePrefix("nostr:")).findParameterValue("id") ?: return null + URLDecoder.decode(rawUrl, Charsets.UTF_8.name()) + }.getOrNull() ?: return null + + return UrlId.toScopeOrNull(url)?.let { Route.Url(it) } +} + fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.startsWith("amethyst+walletconnect:dlnwc?value=") || uri.startsWith("amethyst+walletconnect://dlnwc?value=") fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:") @@ -164,6 +195,12 @@ fun uriToRoute( if (isHashtagRoute(uri)) { return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase()) } + if (isUrlRoute(uri)) { + return urlRoute(uri) + } + if (isConnectedAppRoute(uri)) { + return connectedAppRoute(uri) + } val nip19 = Nip19Parser.uriToRoute(uri)?.entity if (nip19 != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index 125677c0c3..a341fd5a8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -109,7 +109,9 @@ object MediaSaverToDisk { client.newCall(request).executeAsync().use { response -> withContext(Dispatchers.IO) { - check(response.isSuccessful) + check(response.isSuccessful) { + "Failed to download $url: HTTP ${response.code} ${response.message}" + } val trimmedUrl = trimInlineMetaData(url) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index c83719af68..4c1f3263d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 826b849028..4d7a908ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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 = "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifter.kt new file mode 100644 index 0000000000..cb620f8294 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/PitchShifter.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import com.vitorpamplona.amethyst.commons.audio.AudioWindow +import kotlin.math.roundToInt + +/** + * Pure-Kotlin pitch shifter for mono float PCM. + * + * Shifts pitch by a frequency [ratio][shift] while preserving the original + * duration, by combining two textbook DSP steps: + * + * 1. **WSOLA time-stretch** (Waveform Similarity Overlap-Add) stretches the + * signal in time by the pitch ratio without changing its pitch. Unlike plain + * overlap-add, WSOLA searches a small window around each analysis frame for + * the offset that best correlates with the natural continuation of the + * previous frame, which avoids the phase-discontinuity "warble" of OLA. + * 2. **Linear resampling** back to the original length raises (or lowers) the + * pitch by the same ratio and restores the duration. + * + * This is a clean-room implementation of the standard WSOLA algorithm; it + * carries no third-party code and keeps the app free of GPL-licensed DSP + * dependencies. Quality is more than sufficient for voice anonymization, whose + * pitch factors are modest (~0.7x–1.5x). + */ +class PitchShifter( + /** Analysis/synthesis window length in samples. ~46 ms at 44.1 kHz. */ + private val frameSize: Int = 2048, + /** Half-radius (in samples) of the WSOLA cross-correlation search. */ + private val seekWindow: Int = 256, +) { + private val synthesisHop = frameSize / 2 + private val overlap = frameSize - synthesisHop + private val window = AudioWindow.hann(frameSize) + + /** + * Returns [input] pitch-shifted by [frequencyRatio] with the same length. + * + * @param frequencyRatio output-to-input frequency multiplier. `> 1` raises + * the pitch, `< 1` lowers it, `1.0` is a no-op copy. + */ + fun shift( + input: FloatArray, + frequencyRatio: Double, + ): FloatArray { + if (input.isEmpty()) return FloatArray(0) + if (frequencyRatio == 1.0) return input.copyOf() + + // Stretch in time by the ratio (pitch unchanged, length scaled), then + // resample back to the original length (pitch scaled by the ratio). + val stretched = timeStretch(input, frequencyRatio) + return resampleLinear(stretched, input.size) + } + + /** + * WSOLA time-stretch. Output length is approximately `input.size * stretch`. + */ + private fun timeStretch( + input: FloatArray, + stretch: Double, + ): FloatArray { + if (input.size < frameSize + seekWindow) { + // Too short for the overlap machinery; resampling alone still yields + // the requested length change without crashing on tiny clips. + return resampleLinear(input, (input.size * stretch).roundToInt().coerceAtLeast(1)) + } + + val analysisHop = synthesisHop / stretch + val outLength = (input.size * stretch).roundToInt() + frameSize + val out = FloatArray(outLength) + val norm = FloatArray(outLength) + + // Integer start of the current analysis frame inside the input. + var analysisStart = 0 + // Fractional nominal position; the next frame is sought around it. + var nominal = 0.0 + var synthesisPos = 0 + + while (analysisStart + frameSize < input.size && synthesisPos + frameSize < outLength) { + // Overlap-add the windowed analysis frame at the synthesis position. + for (i in 0 until frameSize) { + val w = window[i] + out[synthesisPos + i] += input[analysisStart + i] * w + norm[synthesisPos + i] += w + } + + synthesisPos += synthesisHop + nominal += analysisHop + + // The samples that should naturally follow what we just wrote: the + // tail of the current frame advanced by one synthesis hop. + val naturalStart = analysisStart + synthesisHop + if (naturalStart + overlap >= input.size) break + + // Search around the nominal next analysis position for the offset + // whose head best correlates with that natural continuation. + val center = nominal.roundToInt() + analysisStart = bestMatchOffset(input, naturalStart, center) + } + + // Normalize where windows overlapped to keep unity gain at the edges. + for (i in out.indices) { + if (norm[i] > 1e-6f) out[i] /= norm[i] + } + // Trim the padding tail to the expected stretched length. + val expected = (input.size * stretch).roundToInt() + return if (expected < out.size) out.copyOf(expected) else out + } + + /** + * Finds, within ±[seekWindow] of [center], the input offset whose + * `overlap`-length head best cross-correlates with the `overlap`-length + * segment starting at [naturalStart]. Returns a bounds-safe offset. + */ + private fun bestMatchOffset( + input: FloatArray, + naturalStart: Int, + center: Int, + ): Int { + val low = (center - seekWindow).coerceAtLeast(0) + val high = (center + seekWindow).coerceAtMost(input.size - frameSize - 1) + if (high <= low) return low.coerceIn(0, input.size - frameSize - 1) + + var bestOffset = low + var bestCorr = Double.NEGATIVE_INFINITY + for (offset in low..high) { + var corr = 0.0 + for (i in 0 until overlap) { + corr += input[naturalStart + i].toDouble() * input[offset + i] + } + if (corr > bestCorr) { + bestCorr = corr + bestOffset = offset + } + } + return bestOffset + } + + /** Linear-interpolation resample of [input] to exactly [targetLength] samples. */ + private fun resampleLinear( + input: FloatArray, + targetLength: Int, + ): FloatArray { + if (targetLength <= 0 || input.isEmpty()) return FloatArray(0) + if (input.size == 1) return FloatArray(targetLength) { input[0] } + + val out = FloatArray(targetLength) + val step = (input.size - 1).toDouble() / (targetLength - 1).coerceAtLeast(1) + for (i in 0 until targetLength) { + val pos = i * step + val idx = pos.toInt() + val frac = pos - idx + val a = input[idx] + val b = if (idx + 1 < input.size) input[idx + 1] else a + out[i] = (a + (b - a) * frac).toFloat() + } + return out + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt index 30899ba680..a694699322 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp 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.LoadingAnimation +import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index 4982f1ed31..4487a568fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -40,8 +40,8 @@ import androidx.compose.ui.unit.dp 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.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.service.uploads.isAvif -import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt index d89de35802..bedaa3a2c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt @@ -21,10 +21,13 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.Manifest +import android.content.ActivityNotFoundException import android.content.Context import android.net.Uri import android.os.Environment +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.height import androidx.compose.material3.IconButton @@ -51,6 +54,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -101,7 +105,9 @@ fun TakePicture(onPictureTaken: (ImmutableList) -> Unit) { if (it) { scope.launch(Dispatchers.IO) { cameraUri = getPhotoUri(context) - cameraUri?.let { launcher.launch(it) } + cameraUri?.let { uri -> + launcher.launchOrToast(context, uri) { onPictureTaken(persistentListOf()) } + } } } }, @@ -111,7 +117,9 @@ fun TakePicture(onPictureTaken: (ImmutableList) -> Unit) { LaunchedEffect(key1 = Unit) { launch(Dispatchers.IO) { cameraUri = getPhotoUri(context) - cameraUri?.let { launcher.launch(it) } + cameraUri?.let { uri -> + launcher.launchOrToast(context, uri) { onPictureTaken(persistentListOf()) } + } } } } else { @@ -135,6 +143,28 @@ fun PictureButton(onClick: () -> Unit) { } } +/** + * Launches the camera capture intent, switching to the main thread so the + * [ActivityResultLauncher] is invoked safely. Devices without a camera app throw + * [ActivityNotFoundException] from the underlying [android.app.Activity.startActivityForResult]; + * in that case we surface a toast and run [onUnavailable] to dismiss the capture flow instead of + * crashing. + */ +private suspend fun ActivityResultLauncher.launchOrToast( + context: Context, + uri: Uri, + onUnavailable: () -> Unit, +) { + withContext(Dispatchers.Main) { + try { + this@launchOrToast.launch(uri) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, R.string.no_camera_app_found, Toast.LENGTH_LONG).show() + onUnavailable() + } + } +} + fun getPhotoUri(context: Context): Uri = getMediaUri( context = context, @@ -188,7 +218,9 @@ fun TakeVideo(onVideoTaken: (ImmutableList) -> Unit) { if (it) { scope.launch(Dispatchers.IO) { videoUri = getVideoUri(context) - videoUri?.let { launcher.launch(it) } + videoUri?.let { uri -> + launcher.launchOrToast(context, uri) { onVideoTaken(persistentListOf()) } + } } } }, @@ -198,7 +230,9 @@ fun TakeVideo(onVideoTaken: (ImmutableList) -> Unit) { LaunchedEffect(key1 = Unit) { launch(Dispatchers.IO) { videoUri = getVideoUri(context) - videoUri?.let { launcher.launch(it) } + videoUri?.let { uri -> + launcher.launchOrToast(context, uri) { onVideoTaken(persistentListOf()) } + } } } } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt index f9fd2fc65a..0a53b6412e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt @@ -25,19 +25,14 @@ import android.media.MediaCodecInfo import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMuxer -import be.tarsos.dsp.AudioDispatcher -import be.tarsos.dsp.AudioEvent -import be.tarsos.dsp.AudioProcessor -import be.tarsos.dsp.WaveformSimilarityBasedOverlapAdd -import be.tarsos.dsp.io.TarsosDSPAudioFloatConverter -import be.tarsos.dsp.io.TarsosDSPAudioFormat -import be.tarsos.dsp.resample.RateTransposer import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.io.File +import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.abs @@ -57,9 +52,10 @@ data class AnonymizedResult( /** * Processes audio files to alter voice characteristics for privacy. * - * Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with - * rate transposition to shift pitch while preserving duration. Note that in TarsosDSP, - * pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch. + * Uses the in-house [PitchShifter] (WSOLA time-stretch + resampling) to shift pitch + * while preserving duration. Preset pitch factors work inversely: factor < 1 raises + * pitch, factor > 1 lowers pitch — so the shifter receives the reciprocal as its + * output-to-input frequency ratio. */ class VoiceAnonymizer { companion object { @@ -73,7 +69,7 @@ class VoiceAnonymizer { * * The process involves three stages: * 1. Decode input audio to PCM (0-30% progress) - * 2. Apply pitch shifting with TarsosDSP (30-70% progress) + * 2. Apply pitch shifting with [PitchShifter] (30-70% progress) * 3. Encode processed audio to AAC (70-100% progress) * * @param inputFile Source audio file (supports formats decodable by MediaCodec) @@ -101,10 +97,8 @@ class VoiceAnonymizer { onProgress(progress * 0.3f) } - val processedPcm = - processPcmWithTarsos(pcmData, preset, sampleRate) { progress -> - onProgress(0.3f + progress * 0.4f) - } + val processedPcm = applyPitchShift(pcmData, preset) + onProgress(0.7f) val waveform = extractWaveform(processedPcm, sampleRate) @@ -114,6 +108,10 @@ class VoiceAnonymizer { onProgress(1f) Result.success(AnonymizedResult(outputFile, waveform, duration)) + } catch (e: CancellationException) { + // Let cancellation propagate so structured concurrency works (e.g. the + // upload screen being dismissed cancels this job). + throw e } catch (e: Exception) { Log.e(TAG, "Failed to anonymize audio", e) Result.failure(e) @@ -176,7 +174,10 @@ class VoiceAnonymizer { val inputBufferIndex = decoder.dequeueInputBuffer(10000) if (inputBufferIndex < 0) return - val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!! + val inputBuffer = + requireNotNull(decoder.getInputBuffer(inputBufferIndex)) { + "Decoder input buffer $inputBufferIndex was null (codec in async mode?)" + } val sampleSize = extractor.readSampleData(inputBuffer, 0) if (sampleSize < 0) { @@ -232,7 +233,10 @@ class VoiceAnonymizer { } private fun extractPcmSamples(outputBufferIndex: Int) { - val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!! + val outputBuffer = + requireNotNull(decoder.getOutputBuffer(outputBufferIndex)) { + "Decoder output buffer $outputBufferIndex was null (codec in async mode?)" + } val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() while (shortBuffer.hasRemaining()) { pcmSamples.add(shortBuffer.get() / 32768f) @@ -292,11 +296,9 @@ class VoiceAnonymizer { release() } - private fun processPcmWithTarsos( + private fun applyPitchShift( pcmData: FloatArray, preset: VoicePreset, - sampleRate: Int, - onProgress: (Float) -> Unit, ): FloatArray { val baseFactor = preset.pitchFactor val factor = @@ -311,75 +313,11 @@ class VoiceAnonymizer { baseFactor } } - val totalSamples = pcmData.size - val processedSamples = ArrayList(totalSamples) - val wsola = - WaveformSimilarityBasedOverlapAdd( - WaveformSimilarityBasedOverlapAdd.Parameters.musicDefaults( - factor, - sampleRate.toDouble(), - ), - ) - val rateTransposer = RateTransposer(factor) - - val bufferSize = wsola.inputBufferSize - val overlap = wsola.overlap - - val tarsosDspFormat = - TarsosDSPAudioFormat( - sampleRate.toFloat(), - 16, - 1, - true, - false, - ) - - val collector = - object : AudioProcessor { - override fun process(audioEvent: AudioEvent): Boolean { - val buffer = audioEvent.floatBuffer - for (i in 0 until audioEvent.bufferSize) { - processedSamples.add(buffer[i]) - } - return true - } - - override fun processingFinished() { - // No-op: no cleanup needed - } - } - - val dispatcher = - AudioDispatcher( - FloatArrayAudioInputStream(pcmData, tarsosDspFormat, pcmData.size.toLong()), - bufferSize, - overlap, - ) - - wsola.setDispatcher(dispatcher) - dispatcher.addAudioProcessor(wsola) - dispatcher.addAudioProcessor(rateTransposer) - dispatcher.addAudioProcessor(collector) - - var samplesProcessed = 0 - val progressProcessor = - object : AudioProcessor { - override fun process(audioEvent: AudioEvent): Boolean { - samplesProcessed += audioEvent.bufferSize - onProgress((samplesProcessed.toFloat() / totalSamples).coerceIn(0f, 1f)) - return true - } - - override fun processingFinished() { - // No-op: no cleanup needed - } - } - dispatcher.addAudioProcessor(progressProcessor) - - dispatcher.run() - - return processedSamples.toFloatArray() + // Presets express factor inversely (factor > 1 lowers pitch), so the + // output-to-input frequency ratio is the reciprocal. + val frequencyRatio = 1.0 / factor + return PitchShifter().shift(pcmData, frequencyRatio) } private fun extractWaveform( @@ -421,7 +359,10 @@ class VoiceAnonymizer { val inputBufferIndex = encoder.dequeueInputBuffer(10000) if (inputBufferIndex < 0) return - val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!! + val inputBuffer = + requireNotNull(encoder.getInputBuffer(inputBufferIndex)) { + "Encoder input buffer $inputBufferIndex was null (codec in async mode?)" + } inputBuffer.clear() val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset) @@ -445,7 +386,7 @@ class VoiceAnonymizer { private fun queueSampleData( inputBufferIndex: Int, - inputBuffer: java.nio.ByteBuffer, + inputBuffer: ByteBuffer, samplesToWrite: Int, ) { writePcmSamplesToBuffer(inputBuffer, samplesToWrite) @@ -456,7 +397,7 @@ class VoiceAnonymizer { } private fun writePcmSamplesToBuffer( - inputBuffer: java.nio.ByteBuffer, + inputBuffer: ByteBuffer, samplesToWrite: Int, ) { for (i in 0 until samplesToWrite) { @@ -497,13 +438,16 @@ class VoiceAnonymizer { } private fun processOutputBuffer(outputBufferIndex: Int) { - val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!! + val outputBuffer = + requireNotNull(encoder.getOutputBuffer(outputBufferIndex)) { + "Encoder output buffer $outputBufferIndex was null (codec in async mode?)" + } writeToMuxerIfReady(outputBuffer) encoder.releaseOutputBuffer(outputBufferIndex, false) checkForEndOfStream() } - private fun writeToMuxerIfReady(outputBuffer: java.nio.ByteBuffer) { + private fun writeToMuxerIfReady(outputBuffer: ByteBuffer) { if (isMuxerStarted && bufferInfo.size > 0) { outputBuffer.position(bufferInfo.offset) outputBuffer.limit(bufferInfo.offset + bufferInfo.size) @@ -524,7 +468,7 @@ class VoiceAnonymizer { setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) } - private fun encodePcmToAac( + private suspend fun encodePcmToAac( pcmData: FloatArray, sampleRate: Int, outputFile: File, @@ -541,7 +485,7 @@ class VoiceAnonymizer { val inputFeeder = EncoderInputFeeder(encoder, pcmData, sampleRate, onProgress) val outputDrainer = EncoderOutputDrainer(encoder, muxer) - while (!outputDrainer.isDone) { + while (!outputDrainer.isDone && currentCoroutineContext().isActive) { inputFeeder.feedInput() outputDrainer.drainOutput() } @@ -559,44 +503,3 @@ class VoiceAnonymizer { release() } } - -private class FloatArrayAudioInputStream( - private val floatArray: FloatArray, - private val format: TarsosDSPAudioFormat, - private val frameLength: Long, -) : be.tarsos.dsp.io.TarsosDSPAudioInputStream { - private var position = 0 - - override fun getFormat(): TarsosDSPAudioFormat = format - - override fun getFrameLength(): Long = frameLength - - override fun read( - buffer: ByteArray, - offset: Int, - length: Int, - ): Int { - val converter = TarsosDSPAudioFloatConverter.getConverter(format) - val floatBuffer = FloatArray(length / 2) - val samplesToRead = minOf(floatBuffer.size, floatArray.size - position) - - if (samplesToRead <= 0) return -1 - - System.arraycopy(floatArray, position, floatBuffer, 0, samplesToRead) - position += samplesToRead - - converter.toByteArray(floatBuffer, samplesToRead, buffer, offset) - return samplesToRead * 2 - } - - override fun skip(bytesToSkip: Long): Long { - val samplesToSkip = (bytesToSkip / 2).toInt() - val actualSkip = minOf(samplesToSkip, floatArray.size - position) - position += actualSkip - return actualSkip.toLong() * 2 - } - - override fun close() { - // No-op: no cleanup needed - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index d97c2b38e2..4bb77aab58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -22,14 +22,12 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Card +import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle @@ -40,41 +38,39 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState 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 -import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.util.setText -import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.SmallishBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.text.NumberFormat @Composable fun CashuPreview( @@ -129,13 +125,32 @@ fun CashuPreview( fun CashuPreviewPreview() { ThemeComparisonColumn { CashuPreviewNew( - token = CashuToken("token", "mint", 32400, listOf()), + token = CashuToken("token", "https://mint.example.com", 32400, listOf(), "sat", "Thanks for the coffee!"), melt = { _, _, _ -> }, toast = { _, _ -> }, ) } } +/** + * Formats the token amount with its NUT-00 unit: sats stay sats, fiat units + * (denominated in minor units, e.g. usd = cents) are shown with two decimals, + * anything unknown is rendered verbatim rather than mislabeled as sats. + */ +private fun cashuAmountLabel( + token: CashuToken, + satsLabel: String, +): Pair { + val unit = token.unit + return when (unit) { + null, "sat" -> NumberFormat.getIntegerInstance().format(token.totalAmount) to satsLabel + "usd", "eur" -> + NumberFormat.getInstance().apply { minimumFractionDigits = 2 }.format(token.totalAmount / 100.0) to + unit.uppercase() + else -> NumberFormat.getIntegerInstance().format(token.totalAmount) to unit + } +} + @Composable fun CashuPreviewNew( token: CashuToken, @@ -144,107 +159,88 @@ fun CashuPreviewNew( ) { val context = LocalContext.current - Card( - modifier = CashuCardBorders, + PaymentCard( + title = stringRes(R.string.cashu), + icon = { + Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = null, + modifier = Size18Modifier, + tint = Color.Unspecified, + ) + }, + copyValue = token.token, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, + val satsLabel = stringRes(R.string.sats) + val (amount, unit) = remember(token) { cashuAmountLabel(token, satsLabel) } + + PaymentCardAmount(amount = amount, unit = unit) + + token.memo?.let { + PaymentCardDescription(it) + } + + Text( + text = stringRes(R.string.cashu_mint_label, token.mint.removePrefix("https://")), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier .fillMaxWidth() - .padding(10.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = CustomHashTagIcons.Cashu, - null, - modifier = Modifier.size(13.dp), - ) + .padding(top = 6.dp), + ) - Text( - text = stringRes(R.string.cashu), - fontSize = 12.sp, - modifier = Modifier.padding(start = 5.dp, bottom = 1.dp), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + var isRedeeming by remember { mutableStateOf(false) } + + Button( + onClick = { + isRedeeming = true + melt(token, context) { title, message -> + toast(title, message) + isRedeeming = false + } + }, + shape = ButtonBorder, + modifier = Modifier.weight(1f), + ) { + if (isRedeeming) { + LoadingAnimation() + } else { + ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onPrimary) + } + Spacer(StdHorzSpacer) + + Text(stringRes(R.string.cashu_redeem)) } - Text( - text = "${token.totalAmount} ${stringRes(id = R.string.sats)}", - fontSize = 20.sp, - modifier = Modifier.padding(top = 5.dp), - ) - Text( - text = "Mint: " + token.mint.replace("https://", ""), - fontSize = 7.sp, - color = Color.Gray, - modifier = Modifier.padding(start = 5.dp, bottom = 1.dp), - ) + Spacer(modifier = StdHorzSpacer) - Row(modifier = Modifier.padding(top = 5.dp)) { - var isRedeeming by remember { mutableStateOf(false) } + FilledTonalButton( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, "cashu://${token.token}".toUri()) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - FilledTonalButton( - onClick = { - isRedeeming = true - melt(token, context) { title, message -> - toast(title, message) - isRedeeming = false - } - }, - shape = SmallishBorder, - ) { - if (isRedeeming) { - LoadingAnimation() - } else { - ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onBackground) + context.startActivity(intent) + } catch (e: Exception) { + if (e is CancellationException) throw e + toast(stringRes(context, R.string.cashu), stringRes(context, R.string.cashu_no_wallet_found)) } - Spacer(StdHorzSpacer) - - Text( - "Redeem", - fontSize = 16.sp, - ) - } - - Spacer(modifier = StdHorzSpacer) - - FilledTonalButton( - onClick = { - try { - val intent = Intent(Intent.ACTION_VIEW, "cashu://${token.token}".toUri()) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - - context.startActivity(intent) - } catch (e: Exception) { - if (e is CancellationException) throw e - toast(stringRes(context, R.string.cashu), stringRes(context, R.string.cashu_no_wallet_found)) - } - }, - shape = SmallishBorder, - contentPadding = PaddingValues(0.dp), - ) { - OpenInNewIcon(Size18Modifier) - } - - Spacer(modifier = StdHorzSpacer) - - val clipboardManager = LocalClipboard.current - val scope = rememberCoroutineScope() - - FilledTonalButton( - onClick = { - scope.launch { - // Copying the token to clipboard - clipboardManager.setText(token.token) - } - }, - shape = SmallishBorder, - contentPadding = PaddingValues(0.dp), - ) { - CopyIcon(Size18Modifier) - } + }, + shape = ButtonBorder, + ) { + OpenInNewIcon(Size18Modifier) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt index a27f6d3228..6e25f4be6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt @@ -25,6 +25,7 @@ import android.content.Context import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import kotlinx.coroutines.CancellationException @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt index 821a246449..3eb265a4e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt @@ -25,6 +25,7 @@ import android.content.Intent import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary @Composable fun ClickablePhone(phone: String) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index 86e111ac27..b6016cf9e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -20,16 +20,20 @@ */ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent @Composable fun ClickableUrl( urlText: String, url: String, + style: TextStyle = LocalTextStyle.current, onError: (Int, Int) -> Unit = { _, _ -> }, ) { val uri = LocalUriHandler.current @@ -37,6 +41,7 @@ fun ClickableUrl( ClickableTextPrimary( text = urlText, + style = style, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, onClick = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index b204e673b1..9bf39bf031 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextDirection import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.payViaIntent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt index b9b695a559..e251521024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -24,9 +24,7 @@ import android.graphics.drawable.Animatable import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -77,9 +75,13 @@ fun GifVideoView( val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier val context = LocalContext.current + // Share the static-image sizing policy so animated media obeys it too: in Crop + // contexts (e.g. the multi-image gallery grid) the cell has a fixed width AND height, + // so the content must fill and crop rather than impose its own aspect ratio, which + // would overflow the cell and spill over neighbouring content like the reaction row. val containerModifier = - (if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier) - .fillMaxWidth() + borderModifier + .then(mediaSizingModifier(ratio, contentScale)) .let { if (onDialog != null) it.clickable { onDialog() } else it } Box( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt new file mode 100644 index 0000000000..d986429475 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/InlineQuoteRenderer.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier + +/** + * Strategy for rendering a note quoted inline in another note's text (a + * `nostr:nevent1...`/`nostr:note1...` mention with preview budget left, or a + * legacy `#[index]` event tag). + * + * Both rich-text paths (plain and markdown) funnel through [DisplayFullNote], + * which reads [LocalInlineQuoteRenderer], so providing a different renderer + * re-skins inline quotes for a whole subtree without threading a parameter + * through the parser stack. Chat bubbles use this to keep a quoted chat + * message in the chat reply design instead of the default quoted-note card + * (see `chatInlineQuoteRenderer`). + * + * A renderer draws only the quoted note itself; any leftover characters + * around the mention stay with the caller ([DisplayFullNote]). + */ +fun interface InlineQuoteRenderer { + @Composable + fun Render( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, + ) +} + +/** The quoted-note card used everywhere outside chats. */ +val DefaultInlineQuoteRenderer = + InlineQuoteRenderer { note, quotesLeft, backgroundColor, accountViewModel, nav -> + NoteCompose( + baseNote = note, + modifier = MaterialTheme.colorScheme.innerPostModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.LINE, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + +val LocalInlineQuoteRenderer = compositionLocalOf { DefaultInlineQuoteRenderer } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt new file mode 100644 index 0000000000..ea0f43dffc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.richtext.MathParser +import ru.noties.jlatexmath.JLatexMathDrawable + +/** + * Renders a LaTeX formula (the inner text of a `$...$` / `$$...$$` span) as an + * image, tinted to follow the current text color and sized to the current font. + * + * JLaTeXMath throws on malformed input, so anything it can't parse falls back to + * the raw delimited text rather than crashing the feed. + */ +@Composable +fun LatexEquation( + latex: String, + displayMode: Boolean, + leading: String = "", + trailing: String = "", +) { + val color = LocalContentColor.current.toArgb() + val density = LocalDensity.current + val fontSize = LocalTextStyle.current.fontSize + + // Inline math has no legitimate `\\` line break, so a doubled backslash is an + // over-escaped command (`\\ldots` → `\ldots`). Without this JLaTeXMath would + // read `\\` as a line break and render `ldots`/`cdots` as literal letters. + val formula = if (displayMode) latex else MathParser.collapseDoubledBackslashes(latex) + + // Display math renders a touch larger than the surrounding prose, matching + // the visual weight KaTeX gives block equations. + val textSizePx = + with(density) { + val base = if (fontSize.isUsable()) fontSize.toPx() else 16.sp.toPx() + if (displayMode) base * 1.2f else base + } + + val drawable = + remember(formula, color, textSizePx) { + runCatching { + JLatexMathDrawable + .builder(formula) + .textSize(textSizePx) + .color(color) + .align(JLatexMathDrawable.ALIGN_LEFT) + .build() + }.getOrNull() + } + + if (drawable == null) { + // Couldn't parse — show the raw delimited source so nothing is lost. + Text(leading + (if (displayMode) "$$$latex$$" else "$$latex$") + trailing) + return + } + + // Baseline alignment. The parent FlowRow centers each word vertically, so a + // tight equation bitmap floats above the line — most of a formula's mass sits + // above the baseline, so centering its box lifts the baseline above the text. + // We pad the box so that, once centered, the equation's baseline coincides + // with a surrounding text word's baseline. With text ascent/descent A/Dsc and + // icon total height H and depth-below-baseline Dp, the padded box satisfies + // `baseline-from-center = (A - Dsc) / 2` — the same value a Text box has — when + // pad = (A - Dsc) - H + 2*Dp (on top if positive, on bottom if negative). + val resolvedStyle = LocalTextStyle.current.let { if (fontSize.isUsable()) it else it.copy(fontSize = 16.sp) } + val measurer = rememberTextMeasurer() + val (drawTopPx, boxHeightPx) = + remember(drawable, resolvedStyle) { + val text = measurer.measure("Mg", resolvedStyle) + val ascent = text.firstBaseline + val descent = text.size.height - ascent + val iconHeight = drawable.intrinsicHeight.toFloat() + val iconDepth = drawable.icon().iconDepth.toFloat() + val pad = (ascent - descent) - iconHeight + 2f * iconDepth + maxOf(pad, 0f) to (iconHeight + maxOf(pad, 0f) + maxOf(-pad, 0f)) + } + + val widthDp = with(density) { drawable.intrinsicWidth.toDp() } + val boxHeightDp = with(density) { boxHeightPx.toDp() } + + // Wide display equations can overflow the column; allow them to scroll + // horizontally instead of being clipped. + val equationSize = Modifier.size(widthDp, boxHeightDp) + val equationModifier = if (displayMode) Modifier.horizontalScroll(rememberScrollState()).then(equationSize) else equationSize + + // Row keeps leading/trailing punctuation (the `(` and `)` in `($x$)`) hugging + // the equation rather than getting a word-gap from the parent FlowRow. + Row(verticalAlignment = Alignment.CenterVertically) { + if (leading.isNotEmpty()) { + Text(leading) + } + Canvas(modifier = equationModifier) { + drawIntoCanvas { canvas -> + val native = canvas.nativeCanvas + val checkpoint = native.save() + // Position the icon's baseline on the text baseline within the padded box. + native.translate(0f, drawTopPx) + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(native) + native.restoreToCount(checkpoint) + } + } + if (trailing.isNotEmpty()) { + Text(trailing) + } + } +} + +private fun TextUnit.isUsable(): Boolean = this != TextUnit.Unspecified && this.type == TextUnitType.Sp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 0a26b2fc92..de8722cd42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -28,8 +28,11 @@ import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +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.theme.HalfVertPadding @@ -39,11 +42,12 @@ fun LoadUrlPreview( urlText: String, callbackUri: String? = null, accountViewModel: AccountViewModel, + nav: INav? = null, ) { if (!accountViewModel.settings.showUrlPreview()) { ClickableUrl(urlText, url) } else { - LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel) + LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel, nav) } } @@ -53,6 +57,7 @@ fun LoadUrlPreviewDirect( urlText: String, callbackUri: String? = null, accountViewModel: AccountViewModel, + nav: INav? = null, ) { @Suppress("ProduceStateDoesNotAssignValue") val urlPreviewState by @@ -72,7 +77,7 @@ fun LoadUrlPreviewDirect( ) { state -> when (state) { is UrlPreviewState.Loaded -> { - RenderLoaded(state, url, callbackUri, accountViewModel) + RenderLoaded(state, url, callbackUri, accountViewModel, nav) } is UrlPreviewState.Loading -> { @@ -94,6 +99,7 @@ fun RenderLoaded( url: String, callbackUri: String? = null, accountViewModel: AccountViewModel, + nav: INav? = null, ) { when { state.previewInfo.mimeType.startsWith("image") -> { @@ -130,7 +136,14 @@ fun RenderLoaded( } else -> { - UrlPreviewCard(url, state.previewInfo) + UrlPreviewCard( + url, + state.previewInfo, + onUrlComments = + nav?.let { + { it.nav(Route.Url(url)) } + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt index c9b1f6c700..d38c8e14ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.layout.ContentScale import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PaymentCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PaymentCard.kt new file mode 100644 index 0000000000..6687ff7927 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PaymentCard.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.setText +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import kotlinx.coroutines.launch + +/** + * Shared scaffold for the inline payment cards rendered in the middle of a post + * (Lightning invoices, CLINK Offers, Cashu tokens): a tonal card with a small + * icon + label header, an optional copy-to-clipboard action, and the + * payment-specific body underneath. + */ +@Composable +fun PaymentCard( + title: String, + icon: @Composable () -> Unit, + copyValue: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + shape = QuoteBorder, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + icon() + + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + if (copyValue != null) { + CopyToClipboardButton(copyValue) + } + } + + content() + } + } +} + +@Composable +private fun CopyToClipboardButton(value: String) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val copiedMessage = stringRes(R.string.copied_to_clipboard) + + IconButton( + onClick = { + scope.launch { + clipboard.setText(value) + Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = stringRes(R.string.copy_to_clipboard), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Size18Modifier, + ) + } +} + +/** Centered headline amount with the unit rendered smaller on the same baseline. */ +@Composable +fun PaymentCardAmount( + amount: String, + unit: String, +) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 10.dp), + ) { + Text( + text = amount, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.alignByBaseline(), + ) + Text( + text = unit, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .alignByBaseline() + .padding(start = 6.dp), + ) + } +} + +/** Free-text description/memo attached to the payment. */ +@Composable +fun PaymentCardDescription(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 9822346a0b..6114b369ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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 @@ -79,6 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.MathSegment import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment import com.vitorpamplona.amethyst.commons.richtext.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PdfSegment @@ -91,6 +93,8 @@ import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.VideoSegment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment +import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.LocalCache @@ -107,10 +111,9 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.toShortDisplay -import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -118,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder -import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri import kotlinx.coroutines.Dispatchers @@ -394,6 +396,10 @@ fun RenderTextParagraph( FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(spaceWidth), + // Center items on the cross axis so a taller item (an equation, whose image + // is taller than a text line) sits centered on the line instead of hanging + // below the baseline. No-op for the common all-text row. + itemVerticalAlignment = Alignment.CenterVertically, ) { paragraph.words.forEach { word -> renderWord(word) @@ -501,10 +507,16 @@ 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, nav) + is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> Text(word.segmentText) + is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) + is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) @@ -539,14 +551,16 @@ private fun RenderWordWithPreview( is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel, nav) is NowhereLinkSegment -> NowhereLinkCard(word) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, nav) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) + is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) is HashTagSegment -> HashTag(word, nav) @@ -714,22 +728,9 @@ fun DisplayFullNote( accountViewModel: AccountViewModel, nav: INav, ) { - NoteCompose( - baseNote = note, - modifier = MaterialTheme.colorScheme.innerPostModifier, - isQuotedNote = true, - unPackReply = ReplyRenderType.LINE, - quotesLeft = quotesLeft - 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + LocalInlineQuoteRenderer.current.Render(note, quotesLeft, backgroundColor, accountViewModel, nav) - extraChars?.let { - Text( - it, - ) - } + extraChars?.let { Text(it) } } @Composable @@ -987,16 +988,7 @@ private fun DisplayNoteFromTag( nav: INav, ) { if (canPreview && quotesLeft > 0) { - NoteCompose( - baseNote = baseNote, - modifier = MaterialTheme.colorScheme.innerPostModifier, - isQuotedNote = true, - unPackReply = ReplyRenderType.LINE, - quotesLeft = quotesLeft - 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + LocalInlineQuoteRenderer.current.Render(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } else { ClickableTextPrimary( text = "@${baseNote.idNote().toShortDisplay()}", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index db02a35fbd..7e95230683 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPa import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl +import com.vitorpamplona.amethyst.commons.ui.components.forwardingPainter import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index e55429cf5b..cf0815b798 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -52,6 +52,7 @@ import kotlinx.coroutines.launch fun UrlPreviewCard( url: String, previewInfo: UrlInfoItem, + onUrlComments: (() -> Unit)? = null, ) { val uri = LocalUriHandler.current val popupExpanded = @@ -76,6 +77,15 @@ fun UrlPreviewCard( popupExpanded.value = false } } + onUrlComments?.let { + M3ActionRow( + icon = MaterialSymbols.Link, + text = stringRes(R.string.kind_comments), + ) { + popupExpanded.value = false + it() + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 93ab093d99..56b3440198 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -53,6 +53,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -71,10 +72,13 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.util.lerp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted @@ -206,6 +210,19 @@ fun ZoomableImageDialog( dialogWindow.attributes = attributes } + // Go fully immersive while the full-screen media viewer is open: hide both OS bars and + // restore them when the dialog closes. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user + // swipe to peek the bars. Applies to full-screen images and video alike (shared dialog). + val dialogView = LocalView.current + DisposableEffect(dialogWindow, dialogView) { + val controller = dialogWindow?.let { WindowInsetsControllerCompat(it, dialogView) } + controller?.apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.systemBars()) + } + onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) } + } + Box(modifier = Modifier.fillMaxSize()) { // Background surface that fades in as the content grows to fullscreen. Surface( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 6079a52f27..0e9f4eaba8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.toCoilModel +import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 87b751525e..4ff978ce84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -132,7 +132,7 @@ class MarkdownMediaRenderer( renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder) } else { renderInlineFullWidth(richTextStringBuilder) { - LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel) + LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index 67bc8981ee..424faa43f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -42,9 +42,9 @@ import com.halilibo.richtext.ui.material3.RichText import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem +import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UrlCachedPreviewer -import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt index 1fa92b3b68..1fefe235a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.launch * Text composable that opens [onClick] on a tap and copies [copyValue] to the * system clipboard on a long-press (with a Toast confirmation). * - * This is the long-press-to-copy counterpart of [com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary]. + * This is the long-press-to-copy counterpart of [com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary]. * It deliberately uses a plain [Text] + [combinedClickable] outer modifier * rather than an inline `LinkAnnotation.Clickable` inside an `AnnotatedString`, * because annotation-level clicks can't see a parent [combinedClickable]'s diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index abf9751322..61e00c0aa5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -109,7 +109,10 @@ fun RenderFeedContentState( when (state) { is FeedState.Empty -> onEmpty() is FeedState.FeedError -> onError(state.errorMessage) - is FeedState.Loaded -> onLoaded(state) + is FeedState.Loaded -> { + PrefetchLoadedFeedMedia(state, listState, accountViewModel) + onLoaded(state) + } is FeedState.Loading -> onLoading() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 1fe04f30f3..f32c7eafeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -32,7 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt new file mode 100644 index 0000000000..27d1601cf0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt @@ -0,0 +1,439 @@ +/* + * 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.feeds + +import android.content.Context +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.UrlCachedPreviewer +import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip71Video.image +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +/** How many notes on each side of the viewport to warm. */ +private const val PREFETCH_RADIUS = 3 + +private const val MIME_TYPE_KEY = "m" + +private const val DIM_KEY = "dim" + +/** + * Warms the feed notes just outside the viewport — on **both** sides, so scrolling + * either direction is covered — off the main thread, so they are ready by the time + * the user reaches them. For each note [warm] does three things: + * + * 1. **Pre-parses** the rich-text body into [CachedRichTextParser]'s shared cache + * using the exact same key the renderer will use, so the scroll-time + * composition becomes a cache hit instead of parsing on the UI thread. This is + * only possible for kinds whose render key we can reproduce (text notes and + * comments); other kinds still get their media/links prefetched (below), just + * without the render-cache warm. + * 2. **Prefetches images** — inline content images and video poster frames, from + * both NIP-92 imeta tags (every kind) and the body — into Coil's cache (gated + * on [AccountViewModel] `showImages()`), recording each decoded aspect ratio so + * the composable reserves the right space on first layout (no jump). + * 3. **Warms OpenGraph/link previews** for non-media URLs via [UrlCachedPreviewer] + * (gated on `showUrlPreview()`), so link cards render instantly. + * + * A `warmed` set keeps back-and-forth scrolling cheap: a note already processed is + * skipped entirely. `collectLatest` also cancels a stale pass as soon as the + * visible range changes. The only main-thread cost is reading the visible item + * keys in the snapshot flow. + * + * Video *bytes* are deliberately not prefetched — they are large, often HLS + * (whose prefix can't be cleanly pre-cached), and the player pool already starts + * fast once a video is on screen. Warming the poster covers the visible card. + */ +@Composable +fun PrefetchFeedMedia( + listState: LazyListState, + notes: ImmutableList, + accountViewModel: AccountViewModel, + radius: Int = PREFETCH_RADIUS, +) { + val visibleEnds = remember(listState) { listState.visibleEnds() } + PrefetchVisibleMedia(listState, visibleEnds, notes, accountViewModel, radius) +} + +/** Grid variant: identical behavior, driven by a [LazyGridState] (galleries, discover, products). */ +@Composable +fun PrefetchFeedMedia( + gridState: LazyGridState, + notes: ImmutableList, + accountViewModel: AccountViewModel, + radius: Int = PREFETCH_RADIUS, +) { + val visibleEnds = remember(gridState) { gridState.visibleEnds() } + PrefetchVisibleMedia(gridState, visibleEnds, notes, accountViewModel, radius) +} + +@Composable +private fun PrefetchVisibleMedia( + stateKey: Any, + visibleEnds: Flow, + notes: ImmutableList, + accountViewModel: AccountViewModel, + radius: Int, +) { + val context = LocalContext.current + val currentNotes by rememberUpdatedState(notes) + val warmed = remember(stateKey) { ConcurrentHashMap.newKeySet() } + + LaunchedEffect(stateKey, accountViewModel) { + visibleEnds.collectLatest { ends -> + withContext(Dispatchers.Default) { + currentNotes.notesAround(ends, radius).forEach { note -> + if (note.idHex !in warmed) { + note.warm(context, accountViewModel) + warmed.add(note.idHex) + } + } + } + } + } +} + +/** + * Prefetches media for a loaded feed. The shared entry point used by the feed + * dispatchers (`RenderFeedContentState` and `RenderFeedState`), so every list feed + * that routes through them gets prefetching without per-screen wiring. + */ +@Composable +fun PrefetchLoadedFeedMedia( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchFeedMedia(listState, items.list, accountViewModel) +} + +/** Grid variant of [PrefetchLoadedFeedMedia] for [LazyGridState]-backed feeds. */ +@Composable +fun PrefetchLoadedFeedMedia( + loaded: FeedState.Loaded, + gridState: LazyGridState, + accountViewModel: AccountViewModel, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchFeedMedia(gridState, items.list, accountViewModel) +} + +/** The keys (note `idHex`) of the first and last visible feed items. */ +private data class VisibleEnds( + val firstKey: String?, + val lastKey: String?, +) + +/** Emits the visible range ends, once per change. */ +private fun LazyListState.visibleEnds(): Flow = + snapshotFlow { + val visible = layoutInfo.visibleItemsInfo + VisibleEnds(visible.firstOrNull()?.key as? String, visible.lastOrNull()?.key as? String) + }.distinctUntilChanged() + +/** Grid equivalent of [visibleEnds]. */ +private fun LazyGridState.visibleEnds(): Flow = + snapshotFlow { + val visible = layoutInfo.visibleItemsInfo + VisibleEnds(visible.firstOrNull()?.key as? String, visible.lastOrNull()?.key as? String) + }.distinctUntilChanged() + +/** Up to [radius] notes immediately before the first visible and after the last visible. */ +private fun List.notesAround( + ends: VisibleEnds, + radius: Int, +): List = notesBefore(ends.firstKey, radius) + notesAfter(ends.lastKey, radius) + +private fun List.notesBefore( + key: String?, + count: Int, +): List { + val idx = key?.let { k -> indexOfFirst { it.idHex == k } } ?: -1 + if (idx <= 0) return emptyList() + return subList(maxOf(0, idx - count), idx) +} + +private fun List.notesAfter( + key: String?, + count: Int, +): List { + val idx = key?.let { k -> indexOfFirst { it.idHex == k } } ?: -1 + if (idx < 0) return emptyList() + return subList(minOf(idx + 1, size), minOf(idx + 1 + count, size)) +} + +/** Prefetches this note's media into Coil and warms its link previews, honoring the data-saver gates. */ +private fun Note.warm( + context: Context, + accountViewModel: AccountViewModel, +) { + val targets = collectWarmTargets() ?: return + + if (accountViewModel.settings.showImages()) { + targets.forEachImage(context::prefetchImage) + } + if (accountViewModel.settings.showUrlPreview()) { + targets.links.forEach { url -> + if (UrlCachedPreviewer.cache.get(url) == null) { + accountViewModel.urlPreview(url) {} + } + } + } +} + +/** The set of URLs worth warming ahead of a note scrolling into view. */ +private class WarmTargets { + // image URL -> a video URL whose layout box should also be seeded from this + // image's (poster's) decoded aspect ratio, or null for a plain image. + private val images = LinkedHashMap() + val links = LinkedHashSet() + + fun image(url: String) { + images.putIfAbsent(url, null) + } + + /** A video poster: warmed as an image, and its decoded ratio also seeds [videoUrl]'s box (if non-null). */ + fun videoPoster( + posterUrl: String, + videoUrl: String?, + ) { + // Never downgrade an existing video association back to null. + images[posterUrl] = videoUrl ?: images[posterUrl] + } + + fun link(url: String) { + links.add(url) + } + + fun forEachImage(action: (url: String, videoUrlForDims: String?) -> Unit) { + images.forEach { (url, videoUrl) -> action(url, videoUrl) } + } + + /** Harvest media and links from a fully parsed rich-text body. */ + fun harvest(state: RichTextViewerState) { + state.mediaList.forEach { media -> + when (media) { + is MediaUrlImage -> image(media.url) + is MediaUrlVideo -> + media.artworkUri?.let { poster -> + videoPoster(poster, media.url.takeIf { media.dim == null }) + } + } + } + state.urlSet.withScheme.forEach { url -> + if (!RichTextParser.isImageOrVideoUrl(url)) link(url) + } + } + + /** + * Lightweight discovery for kinds we don't render-warm: pull http(s) URLs from + * the body and classify them by extension. A bare content video has no poster + * we can cheaply warm, so it is skipped. + */ + fun harvestContentUrls(content: String) { + UrlParser().parseValidUrls(content).withScheme.forEach { url -> + when { + RichTextParser.isImageUrl(url) -> image(url) + RichTextParser.isVideoUrl(url) -> Unit + else -> link(url) + } + } + } + + /** + * Adds NIP-94 file-header media: blobs declared as top-level `url` tags (not + * imeta), as used by FileHeaderEvent and the gallery/file kinds. Only image + * files are prefetched — detected by the URL extension or the event's top-level + * `m` mime type (a hashed URL may carry no extension). The decode listener + * records their dims like any other image. + */ + fun addFileHeaderMedia(event: Event) { + val urls = event.tags.mapNotNull(UrlTag::parse) + if (urls.isEmpty()) return + val imageMime = event.tags.firstNotNullOfOrNull(MimeTypeTag::parse)?.startsWith("image/") == true + urls.forEach { url -> + if (imageMime || RichTextParser.isImageUrl(url)) image(url) + } + } +} + +/** + * Collects everything worth warming for this note. Reads NIP-92 imeta tags (every + * kind), then the free-text body: text notes and comments go through the shared + * render cache — which both warms the renderer and yields a fully classified body + * — while every other kind gets a cheap URL scan. Returns null only when the note + * has no event. + */ +private fun Note.collectWarmTargets(): WarmTargets? { + val ev = event ?: return null + val targets = WarmTargets() + + // Structured media declared in NIP-92 imeta tags (text-note attachments, and the + // kinds whose typed accessors wrap imetas() — Picture, Video). Video imeta carries + // its poster in the `image` field. + ev.tags.imetas().forEach { meta -> + if (meta.isImage()) targets.image(meta.url) + meta.image()?.forEach { poster -> targets.videoPoster(poster, meta.videoUrlForPosterDims()) } + } + + // NIP-94 file-header convention: the blob is a top-level `url` tag with sibling + // `m`/`dim`, not imeta (FileHeaderEvent and the gallery/file kinds). The generic + // imetas() above misses these entirely. + targets.addFileHeaderMedia(ev) + + // Free-text body. The reproducible-key kinds parse through the shared cache + // (which also warms the render); everything else is scanned for URLs only. + val rendered = warmRenderCache(ev) + if (rendered != null) { + targets.harvest(rendered) + } else { + targets.harvestContentUrls(ev.content) + } + + return targets +} + +/** + * Parses the body through [CachedRichTextParser] so the result lands in the exact + * entry the composition later looks up — turning the scroll-time parse into a cache + * hit. Returns the parsed state, or null when we can't reproduce the renderer's key. + * + * Deliberately limited to text notes and comments. The cache key is + * `(content, tags, callbackUri, authorPubKey)`, and each renderer passes its own + * shape: Picture uses `EmptyTagList`, Highlight uses `EmptyTagList` + `null` + * callbackUri, Video/Poll/Git pass derived content vars, etc. We can only reproduce + * the key for these two kinds (verified to hit at runtime). Parsing the others with + * a guessed key would miss at render time *and* pollute this shared cache with dead + * entries — evicting the text-note entries that do hit — so it would be a net loss. + * Their media and links are still prefetched via [WarmTargets.harvestContentUrls]. + */ +private fun Note.warmRenderCache(ev: Event): RichTextViewerState? { + val body = + when (ev) { + is TextNoteEvent -> { + // Matches RenderTextEvent: a subject is prepended when not already in the body. + val subject = ev.subject()?.ifBlank { null } + if (subject != null && !ev.content.contains(subject, ignoreCase = true)) { + "$subject\n\n${ev.content}" + } else { + ev.content + } + } + is CommentEvent -> ev.content + else -> return null + } + + val tags = ev.tags.toImmutableListOfLists() + // callbackUri = note.toNostrUri(), authorPubKey = null — the values the feed passes. + return CachedRichTextParser.parseText(body, tags, toNostrUri(), null) +} + +/** Enqueues an image prefetch that also records its decoded aspect ratio. */ +private fun Context.prefetchImage( + url: String, + videoUrlForDims: String?, +) { + runCatching { + imageLoader.enqueue( + ImageRequest + .Builder(this) + .data(url) + .listener(CaptureAspectRatio(url, videoUrlForDims)) + .build(), + ) + } +} + +/** + * Records the decoded aspect ratio of a prefetched image so the composable can + * reserve the right space on first layout (no jump) even when the note carries no + * imeta `dim` — the renderer reads `MediaAspectRatioCache.get(url)` as its + * fallback. We decode for the cache warm anyway, so this is essentially free. + * + * For a video poster, [videoUrlForDims] is the video URL; its box is seeded from + * the poster's ratio (a poster is a frame of the video), but only when nothing is + * cached yet, so we never stomp a real ratio. ExoPlayer's onVideoSizeChanged + * corrects it to the exact size once the video plays. + */ +private class CaptureAspectRatio( + private val imageUrl: String, + private val videoUrlForDims: String?, +) : ImageRequest.Listener { + override fun onSuccess( + request: ImageRequest, + result: SuccessResult, + ) { + val w = result.image.width + val h = result.image.height + MediaAspectRatioCache.add(imageUrl, w, h) + if (videoUrlForDims != null && MediaAspectRatioCache.get(videoUrlForDims) == null) { + MediaAspectRatioCache.add(videoUrlForDims, w, h) + } + } +} + +/** The video URL this imeta's poster should seed dims for, or null when the video declares its own dim. */ +private fun IMetaTag.videoUrlForPosterDims(): String? = url.takeIf { properties[DIM_KEY].isNullOrEmpty() } + +/** + * True only for image blobs. A missing mime type falls back to the URL extension; + * we never treat video imeta as an image (its poster is warmed separately). + */ +private fun IMetaTag.isImage(): Boolean { + val mimeType = properties[MIME_TYPE_KEY]?.firstOrNull() + return mimeType?.startsWith("image/") ?: RichTextParser.isImageUrl(url) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt index bd38e9d4b4..415d0b27eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt @@ -36,7 +36,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index c6d8f835d4..68430b7104 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -65,6 +65,8 @@ object ScrollStateKeys { const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed" const val COMMUNITIES_LIST = "CommunitiesListFeed" const val PICTURES_SCREEN = "PicturesFeed" + const val WORKOUTS_SCREEN = "WorkoutsFeed" + const val GIT_REPOSITORIES_SCREEN = "GitRepositoriesFeed" const val CALENDARS_SCREEN = "CalendarsFeed" const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed" const val PRODUCTS_SCREEN = "ProductsFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/UserBlockedFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/UserBlockedFeed.kt new file mode 100644 index 0000000000..2d597ce4f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/UserBlockedFeed.kt @@ -0,0 +1,47 @@ +/* + * 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.feeds + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun UserBlockedFeed(onUnblock: () -> Unit) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringRes(R.string.user_is_blocked_hidden)) + Spacer(modifier = StdVertSpacer) + OutlinedButton(onClick = onUnblock) { Text(text = stringRes(R.string.unblock_user)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index 44b025b61a..ab1b69fd10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.layouts +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -134,15 +135,20 @@ fun ChannelNamePreview() { } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatHeaderLayout( channelPicture: @Composable () -> Unit, firstRow: @Composable RowScope.() -> Unit, secondRow: @Composable RowScope.() -> Unit, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, ) { Row( - modifier = Modifier.clickable(onClick = onClick).padding(10.dp), + modifier = + Modifier + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(10.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(Size55Modifier) { channelPicture() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 5b96c44c16..6f36f0e63f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -34,21 +34,33 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarNestedScroll +import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingBarState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberDisappearingBarState +import com.vitorpamplona.amethyst.ui.components.getActivityWindow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch private enum class DisappearingSlot { Top, Bottom, Content, Fab } @@ -87,8 +99,11 @@ fun DisappearingScaffold( ) } - // Only wire the lifecycle observer when the scaffold actually moves its bars. - if (allowBarHide) ResetBarsOnResume(state) + // Only wire the lifecycle observer + system-bar control when the scaffold actually moves its bars. + if (allowBarHide) { + ResetBarsOnResume(state) + ImmersiveStatusBarEffect(state) + } // When bars are pinned, skip attaching the nested-scroll connection entirely. // The outer Surface provides the Material container color + onBackground as @@ -185,7 +200,10 @@ private fun ScaffoldLayout( val contentPlaceable = subcompose(DisappearingSlot.Content) { - CompositionLocalProvider(LocalDisappearingScaffoldPadding provides contentPadding) { + CompositionLocalProvider( + LocalDisappearingScaffoldPadding provides contentPadding, + LocalDisappearingBarState provides state, + ) { mainContent(contentPadding) } }.firstOrNull()?.measure( @@ -255,3 +273,70 @@ private fun ResetBarsOnResume(state: DisappearingBarState) { onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } } + +/** Fraction at which the tracked in-app bar is considered fully settled into the hidden edge. */ +private const val STATUS_BAR_HIDE_THRESHOLD = 0.999f + +/** + * Fraction the chrome must fall back below before the OS status bar reappears. Lower than + * [STATUS_BAR_HIDE_THRESHOLD] on purpose: this hysteresis gap means the finger that stops a fast + * scroll — which makes a tiny reverse reveal — does not immediately pop the status bar back. The + * user has to deliberately scroll the chrome back by ~30% before the status bar returns. + */ +private const val STATUS_BAR_SHOW_THRESHOLD = 0.7f + +/** + * Hides the OS status bar once the tracked in-app bar has settled into the hidden edge, and shows it + * the moment it starts coming back. The OS status bar is binary (cannot slide), so it is driven off + * the collapse fraction with a near-1 threshold, which means it toggles on settle rather than + * mid-drag. + * + * The status bar sits with the top in-app bar, so it tracks the top bar's collapse. The top and + * bottom bars can collapse at different rates (each clamps to its own height), so tracking the top + * fraction keeps the status bar in step with the chrome directly beneath it. On screens with no top + * bar (topHeightLimit == 0) it falls back to the bottom bar, so the status bar still hides in sync + * with the bottom navigation. + * + * Only the top status bar is touched; the bottom OS navigation bar is left alone. + * BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user swipe to peek the status bar while immersed. + * The status bar is always restored on dispose so leaving an immersive screen can never strand a + * hidden bar. + */ +@Composable +private fun ImmersiveStatusBarEffect(state: DisappearingBarState) { + val view = LocalView.current + if (view.isInEditMode) return + val window = getActivityWindow() ?: return + val controller = remember(window, view) { WindowInsetsControllerCompat(window, view) } + + LaunchedEffect(controller, state) { + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + var statusBarHidden = false + snapshotFlow { + if (state.topHeightLimit > 0f) state.topCollapsedFraction else state.bottomCollapsedFraction + }.distinctUntilChanged() + .collect { fraction -> + // Hysteresis: hide once fully settled, but only show again after the chrome has + // been pulled back past STATUS_BAR_SHOW_THRESHOLD. Between the two thresholds the + // status bar keeps its current state, so a stray reverse reveal can't flip it. + val shouldHide = + when { + fraction >= STATUS_BAR_HIDE_THRESHOLD -> true + fraction <= STATUS_BAR_SHOW_THRESHOLD -> false + else -> statusBarHidden + } + if (shouldHide != statusBarHidden) { + statusBarHidden = shouldHide + if (shouldHide) { + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else { + controller.show(WindowInsetsCompat.Type.statusBars()) + } + } + } + } + + DisposableEffect(controller) { + onDispose { controller.show(WindowInsetsCompat.Type.statusBars()) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b05ec5d244..79ce1181cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.ui.navigation import android.content.Intent import android.net.Uri +import android.os.Build import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -34,9 +37,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.content.IntentCompat import androidx.core.util.Consumer +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R @@ -50,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -59,9 +65,12 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute import com.vitorpamplona.amethyst.ui.note.PayViaIntentScreen import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen +import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen +import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.ProfileAppRecommendationsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen @@ -73,6 +82,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen @@ -107,17 +119,28 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPreloader import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.EmojiPackMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.MyEmojiListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.NostrAppScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitNewIssueScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryCodeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryIssuesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryPullsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen @@ -139,13 +162,18 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.AddToMusicPlaylistSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicTracksScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppDetailScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.NsitesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen @@ -156,10 +184,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.ProductsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.SendPaymentScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth.RelayAuthSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen @@ -170,6 +200,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsS import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AudioVisualizerSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen @@ -193,11 +224,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlPostScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlScreen 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.CashuMintRecommendationsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuMintsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen @@ -208,7 +243,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.wizard.CashuWalletCreatedScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.wizard.CashuWalletWizardScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.NewWorkoutScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutsScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.nip01Core.core.Address @@ -225,7 +264,27 @@ fun AppNavigation( val nav = rememberNav() AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { - BuildNavigation(accountViewModel, nav) + Box(Modifier.fillMaxSize()) { + BuildNavigation(accountViewModel, nav) + // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) + // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded + // preloader below and the full-screen launcher both need. Not API-gated: every device's + // launcher benefits, and it's the only preload step that runs below API 30. + FavoriteAppManifestPreloader(accountViewModel) + // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by + // holding their surfaces attached. Below the drawer (drawn by the layout above) and below + // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + EmbeddedTabLayer(bottomBarItems.favoriteIds()) + // Warm every pinned tab at startup so the first tap is instant (content already local). + EmbeddedTabPreloader(accountViewModel) + // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips + // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). + EmbeddedTabThemeWatcher() + } + } } NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) @@ -273,9 +332,21 @@ fun BuildNavigation( composableFromEndArgs { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } + composableFromEnd { ProfileAppRecommendationsScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } + composableFromEnd { WorkoutsScreen(accountViewModel, nav) } + composableFromEnd { GitRepositoriesScreen(accountViewModel, nav) } composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } + composableFromEnd { NappletsScreen(accountViewModel, nav) } + composableFromEnd { NsitesScreen(accountViewModel, nav) } + composableFromEnd { BrowserScreen(accountViewModel, nav) } + composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } + composableFromEndArgs { WebAppScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { NostrAppScreen(it.coordinate, accountViewModel, nav) } + composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } + composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } + composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } @@ -303,6 +374,7 @@ fun BuildNavigation( composableFromEnd { PodcastsScreen(accountViewModel, nav) } composableFromEndArgs { PodcastScreen(it.pubkey, accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } + composableFromEndArgs { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } @@ -315,9 +387,14 @@ fun BuildNavigation( composableFromEndArgs { WalletDetailScreen(it.walletId, accountViewModel, nav) } composableFromEnd { AddWalletScreen(accountViewModel, nav) } composableFromEndArgs { AddNwcWalletScreen(accountViewModel, nav, it.nip47) } - composableFromEnd { AddCashuWalletScreen(accountViewModel, nav) } + composableFromEnd { CashuMintsScreen(accountViewModel, nav) } + composableFromEndArgs { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) } composableFromEnd { CashuWalletScreen(accountViewModel, nav) } + composableFromEnd { CashuWalletWizardScreen(accountViewModel, nav) } + composableFromEnd { CashuWalletCreatedScreen(nav) } composableFromEnd { CashuWalletSettingsScreen(accountViewModel, nav) } + composableFromEnd { CashuMintRecommendationsScreen(accountViewModel, nav) } + composableFromBottomArgs { SendPaymentScreen(it.userHex, it.method, it.lnAddressOverride, it.btcAddressOverride, accountViewModel, nav) } composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } @@ -368,6 +445,7 @@ fun BuildNavigation( composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } + composableFromEnd { BookmarkedRepositoriesScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } @@ -375,6 +453,7 @@ fun BuildNavigation( composableFromEnd { ComposeSettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } @@ -404,9 +483,12 @@ fun BuildNavigation( composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ShareNoteAsImageScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { UrlScreen(it, accountViewModel, nav) } composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } @@ -414,6 +496,10 @@ fun BuildNavigation( composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryCodeScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryIssuesScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryPullsScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitNewIssueScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } @@ -496,6 +582,14 @@ fun BuildNavigation( ) } + composableFromBottomArgs { + NewWorkoutScreen( + prefill = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { HashtagPostScreen( hashtag = it.hashtag, @@ -509,6 +603,19 @@ fun BuildNavigation( ) } + composableFromBottomArgs { + UrlPostScreen( + url = it.url, + message = it.message, + attachment = it.attachment, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + composableFromBottomArgs { ReplyCommentPostScreen( replyId = it.replyTo, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 14b5ddcd52..145f145807 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -42,7 +42,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -83,20 +89,31 @@ fun AppBottomBar( ) return } + + // Favorite entries in the unified list resolve to a live favorite for their icon/label and to an + // embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet + // surface), so such a tab swaps in place rather than launching an activity from the bottom row. + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val isKeyboardState by keyboardAsState() if (isKeyboardState == KeyboardState.Closed) { - RenderBottomMenu(items, selectedRoute, accountViewModel, onClick) + RenderBottomMenu(items, favorites, selectedRoute, accountViewModel, onClick) } } @Composable private fun RenderBottomMenu( - items: List, + items: List, + favorites: List, selectedRoute: Route?, accountViewModel: AccountViewModel, nav: (Route) -> Unit, ) { - val defs = remember(items) { items.mapNotNull(NavBarCatalog::get) } + // Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan. + val favoritesById = remember(favorites) { favorites.associateBy { it.id } } + + // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() Column( modifier = @@ -115,14 +132,67 @@ private fun RenderBottomMenu( containerColor = MaterialTheme.colorScheme.background, tonalElevation = Size0dp, ) { - defs.forEach { def -> - val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } - HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav) + // Render in the user's saved order, built-ins and favorites interleaved. + items.forEach { entry -> + when (entry) { + is BottomBarEntry.BuiltIn -> { + val def = NavBarCatalog[entry.item] ?: return@forEach + val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } + HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav) + } + is BottomBarEntry.Favorite -> { + val fav = favoritesById[entry.favoriteId] ?: return@forEach + val destination = + when (fav) { + is FavoriteApp.WebApp -> Route.WebApp(fav.url) + is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) + } + // A web favorite uses its captured favicon; an nsite/napplet uses the verified + // icon blob bundled in its own content (the iframe sandbox rules out live capture). + val iconModel = + when (fav) { + is FavoriteApp.WebApp -> + remember(fav, iconKeys) { + OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + } + is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) + } + FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav) + } + } } } } } +@Composable +private fun RowScope.FavoriteNavItem( + selected: Boolean, + fav: FavoriteApp, + iconModel: Any?, + destination: Route, + nav: (Route) -> Unit, +) { + NavigationBarItem( + alwaysShowLabel = false, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + // A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else + // the grid glyph). + FavoriteAppIcon( + app = fav, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + modifier = Size25Modifier, + iconModel = iconModel, + ) + } + }, + // No label — favorite tabs match the built-in items, which show icon only. + selected = selected, + onClick = { nav(destination) }, + ) +} + @Composable private fun RowScope.HasNewItemsIcon( selected: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt new file mode 100644 index 0000000000..3911885096 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.bottombars + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One slot in the bottom navigation bar. A single ordered list of these (persisted in + * [com.vitorpamplona.amethyst.model.UiSettings.bottomBarItems]) holds **both** built-in destinations + * and favorite apps, so the user can pin and drag-reorder them together in one list. + * + * - [BuiltIn] resolves its [Route][com.vitorpamplona.amethyst.ui.navigation.routes.Route] (and its + * icon/label/notification badge) through [NavBarCatalog], like before. + * - [Favorite] points at a [FavoriteApp][com.vitorpamplona.amethyst.commons.favorites.FavoriteApp] by + * its stable id (which already encodes the route's parameters — the `url` or addressable + * `coordinate`); the bar resolves it to a live favorite for its icon/label and to + * `Route.WebApp` / `Route.NostrApp` for navigation. + */ +@Serializable +sealed interface BottomBarEntry { + // Stable discriminators so persisted bottom-bar configs survive class renames/moves (the default + // polymorphic discriminator is the fully-qualified class name, which is fragile across refactors). + @Serializable + @SerialName("builtIn") + data class BuiltIn( + val item: NavBarItem, + ) : BottomBarEntry + + @Serializable + @SerialName("favorite") + data class Favorite( + val favoriteId: String, + ) : BottomBarEntry +} + +/** The favorite-app ids in this bottom-bar config — the apps that should be kept warm as bottom-row tabs. */ +fun List.favoriteIds(): List = mapNotNull { (it as? BottomBarEntry.Favorite)?.favoriteId } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 01dc584878..aab1307013 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars +import android.os.Build import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -50,7 +51,13 @@ enum class NavBarItem { COMMUNITIES, ARTICLES, PICTURES, + WORKOUTS, + GIT_REPOSITORIES, SOFTWARE_APPS, + NAPPLETS, + NSITES, + BROWSER, + FAVORITE_APPS, CALENDARS, CALENDAR_COLLECTIONS, SHORTS, @@ -206,6 +213,20 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Photo, resolveRoute = { Route.Pictures }, ), + NavBarItem.WORKOUTS to + NavBarItemDef( + id = NavBarItem.WORKOUTS, + labelRes = R.string.workouts, + icon = MaterialSymbols.DirectionsRun, + resolveRoute = { Route.Workouts }, + ), + NavBarItem.GIT_REPOSITORIES to + NavBarItemDef( + id = NavBarItem.GIT_REPOSITORIES, + labelRes = R.string.git_repositories, + icon = MaterialSymbols.Code, + resolveRoute = { Route.GitRepositories }, + ), NavBarItem.SOFTWARE_APPS to NavBarItemDef( id = NavBarItem.SOFTWARE_APPS, @@ -213,6 +234,34 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Apps, resolveRoute = { Route.SoftwareApps }, ), + NavBarItem.NAPPLETS to + NavBarItemDef( + id = NavBarItem.NAPPLETS, + labelRes = R.string.napplets, + icon = MaterialSymbols.Apps, + resolveRoute = { Route.Napplets }, + ), + NavBarItem.NSITES to + NavBarItemDef( + id = NavBarItem.NSITES, + labelRes = R.string.nsites, + icon = MaterialSymbols.Language, + resolveRoute = { Route.Nsites }, + ), + NavBarItem.BROWSER to + NavBarItemDef( + id = NavBarItem.BROWSER, + labelRes = R.string.browser, + icon = MaterialSymbols.Language, + resolveRoute = { Route.Browser }, + ), + NavBarItem.FAVORITE_APPS to + NavBarItemDef( + id = NavBarItem.FAVORITE_APPS, + labelRes = R.string.favorite_apps, + icon = MaterialSymbols.Star, + resolveRoute = { Route.FavoriteApps }, + ), NavBarItem.CALENDARS to NavBarItemDef( id = NavBarItem.CALENDARS, @@ -338,12 +387,14 @@ val DefaultBottomBarItems: List = listOf( NavBarItem.HOME, NavBarItem.MESSAGES, - NavBarItem.VIDEO, - NavBarItem.DISCOVER, - NavBarItem.FAVORITE_ALGO_FEEDS, + NavBarItem.WALLET, + NavBarItem.BROWSER, NavBarItem.NOTIFICATIONS, ) +/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */ +val DefaultBottomBarEntries: List = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) } + // Ordered membership lists for each drawer section. The drawer renders these by looking up // each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the // matching section list below — not two separate files. @@ -352,6 +403,7 @@ val DrawerNavigateItems: List = NavBarItem.HOME, NavBarItem.MESSAGES, NavBarItem.VIDEO, + NavBarItem.BROWSER, NavBarItem.DISCOVER, NavBarItem.NOTIFICATIONS, ) @@ -371,24 +423,31 @@ val DrawerYouItems: List = val DrawerFeedsItems: List = listOfNotNull( - NavBarItem.COMMUNITIES, NavBarItem.ARTICLES, NavBarItem.PICTURES, - NavBarItem.SOFTWARE_APPS, - NavBarItem.CALENDARS, - NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SHORTS, - NavBarItem.MUSIC_TRACKS, - NavBarItem.MUSIC_PLAYLISTS, + NavBarItem.LONGS, NavBarItem.PODCAST_EPISODES, NavBarItem.PODCASTS, - NavBarItem.PUBLIC_CHATS, - NavBarItem.FOLLOW_PACKS, + NavBarItem.MUSIC_TRACKS, + NavBarItem.MUSIC_PLAYLISTS, + NavBarItem.POLLS, + NavBarItem.PRODUCTS, + NavBarItem.WORKOUTS, + NavBarItem.GIT_REPOSITORIES, NavBarItem.LIVE_STREAMS, NavBarItem.NESTS, - NavBarItem.LONGS, - NavBarItem.POLLS, + NavBarItem.COMMUNITIES, + NavBarItem.PUBLIC_CHATS, + NavBarItem.CALENDARS, + NavBarItem.CALENDAR_COLLECTIONS, + NavBarItem.SOFTWARE_APPS, + // Favorites can be pinned as inline tabs that render on a cross-process surface + // (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason. + NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R }, + NavBarItem.NAPPLETS, + NavBarItem.NSITES, + NavBarItem.FOLLOW_PACKS, NavBarItem.BADGES, - NavBarItem.PRODUCTS, NavBarItem.EMOJI_SETS, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt index 33df344796..68ad628ab8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import java.net.URLEncoder import kotlin.reflect.KClass private const val NOSTR_URI_PREFIX = "nostr:" @@ -144,6 +145,10 @@ class BouncingIntentNav( NOSTR_URI_PREFIX + "hashtag?id=" + route.hashtag } + is Route.Url -> { + NOSTR_URI_PREFIX + "url?id=" + URLEncoder.encode(route.url, Charsets.UTF_8.name()) + } + is Route.LiveActivityChannel -> { NOSTR_URI_PREFIX + NAddress.create(route.kind, route.pubKeyHex, route.dTag, null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 48ef26c9b4..48fb62e212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -100,7 +101,9 @@ fun routeForInner( if (noteEvent.includeKind(5300)) { Route.ContentDiscovery(noteEvent.id) } else { - Route.Note(noteEvent.id) + // By address, not version id: the per-id note may have been + // evicted and relays don't serve replaceables by old ids. + Route.Note(noteEvent.addressTag()) } } @@ -326,6 +329,25 @@ fun routeReplyTo( } } + is LnZapEvent -> { + // A public reply can't tag a private zapper without exposing them. + // When we hold the decrypted sender (we are the zap recipient), reply + // in their DM room instead of the public comment composer. + val request = noteEvent.zapRequest + val privateSender = + if (request?.isPrivateZap() == true) { + account.privateZapsDecryptionCache.cachedPrivateZap(request)?.pubKey + } else { + null + } + + if (privateSender != null) { + routeToMessage(ChatroomKey(setOf(privateSender)), null, account = account) + } else { + Route.GenericCommentPost(replyTo = note.idHex) + } + } + else -> { Route.GenericCommentPost(replyTo = note.idHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f0faa2f41e..42760f3437 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -67,6 +67,8 @@ sealed class Route { @Serializable object ProfileBadges : Route() + @Serializable object ProfileAppRecommendations : Route() + @Serializable data class AwardBadge( val kind: Int, val pubKeyHex: HexKey, @@ -81,8 +83,36 @@ sealed class Route { @Serializable object Pictures : Route() + @Serializable object Workouts : Route() + + @Serializable object GitRepositories : Route() + @Serializable object SoftwareApps : Route() + @Serializable object Napplets : Route() + + @Serializable object Nsites : Route() + + @Serializable object Browser : Route() + + @Serializable object FavoriteApps : Route() + + @Serializable data class WebApp( + val url: String, + ) : Route() + + @Serializable data class NostrApp( + val coordinate: String, + ) : Route() + + @Serializable object ConnectedApps : Route() + + @Serializable object RelayAuthSettings : Route() + + @Serializable data class ConnectedAppDetail( + val coordinate: String, + ) : Route() + @Serializable data class SoftwareAppDetail( val kind: Int, val pubKeyHex: HexKey, @@ -171,6 +201,11 @@ sealed class Route { val dTag: String? = null, ) : Route() + @Serializable + data class NewMusicPlaylist( + val dTag: String? = null, + ) : Route() + @Serializable data class AddToMusicPlaylist( val trackAddress: String, @@ -208,12 +243,41 @@ sealed class Route { val nip47: String? = null, ) : Route() - @Serializable object WalletAddCashu : Route() + @Serializable object CashuWalletMints : Route() + + @Serializable data class WalletAddClinkDebit( + val ndebit: String? = null, + ) : Route() @Serializable object CashuWallet : Route() + /** Find-or-create wizard, shown when no NIP-60 wallet is loaded. */ + @Serializable object CashuWalletWizard : Route() + + /** Celebratory interstitial after choosing to create a new wallet. */ + @Serializable object CashuWalletCreated : Route() + @Serializable object CashuWalletSettings : Route() + @Serializable object CashuMintRecommendations : Route() + + /** + * Unified profile payment screen: collects amount/message/zap type and pays + * through whichever rail (Lightning, CLINK offer, on-chain, Cashu) the + * profile supports. [method] preselects a rail (see ProfilePaymentMethod + * route keys); [lnAddressOverride] pays a specific lightning target instead + * of the profile's kind:0 lud16; [btcAddressOverride] makes the on-chain + * rail pay that announced address directly (plain send, no NIP-BC receipt) + * instead of the recipient's pubkey-derived Taproot address. + */ + @Serializable + data class SendPayment( + val userHex: String, + val method: String? = null, + val lnAddressOverride: String? = null, + val btcAddressOverride: String? = null, + ) : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() @@ -238,6 +302,8 @@ sealed class Route { @Serializable object PinnedNotes : Route() + @Serializable object BookmarkedRepositories : Route() + @Serializable object BookmarkGroups : Route() @Serializable object InterestSets : Route() @@ -325,6 +391,8 @@ sealed class Route { @Serializable object ReactionsSettings : Route() + @Serializable object AudioVisualizerSettings : Route() + @Serializable object BottomBarSettings : Route() @Serializable object HomeTabsSettings : Route() @@ -405,6 +473,14 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ShareNoteAsImage( + val id: String, + ) : Route() + + @Serializable data class ShareNoteAsImageFile( + val id: String, + ) : Route() + @Serializable data class ContactListUsers( val noteId: String, ) : Route() @@ -417,6 +493,10 @@ sealed class Route { val geohash: String, ) : Route() + @Serializable data class Url( + val url: String, + ) : Route() + @Serializable data class ChessGame( val gameId: String, ) : Route() @@ -445,6 +525,54 @@ sealed class Route { ) } + @Serializable data class GitRepositoryCode( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + + @Serializable data class GitRepositoryIssues( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + + @Serializable data class GitRepositoryPulls( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + + @Serializable data class GitRepositoryNewIssue( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable data class FollowPack( val kind: Int, val pubKeyHex: HexKey, @@ -585,6 +713,26 @@ sealed class Route { @Serializable data object NewGoal : Route() + /** + * Manual workout composer. All fields are optional pre-fill, used when a + * workout is detected from Health Connect and the user accepts the + * suggestion; a blank [NewWorkout] opens the empty manual form. + */ + @Serializable + data class NewWorkout( + val exercise: String? = null, + val title: String? = null, + val durationSeconds: Long = 0, + val distanceMeters: Double = 0.0, + val calories: Int = 0, + val avgHeartRate: Int = 0, + val maxHeartRate: Int = 0, + val steps: Int = 0, + val elevationGainMeters: Double = 0.0, + val startTime: Long = 0, + val source: String? = null, + ) : Route() + @Serializable data class NewLongFormPost( val draft: String? = null, @@ -611,6 +759,16 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable + data class UrlPost( + val url: String? = null, + val message: String? = null, + val attachment: String? = null, + val replyTo: String? = null, + val quote: String? = null, + val draft: String? = null, + ) : Route() + @Serializable data class GenericCommentPost( val message: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 1afd463fdb..39f5c8dd63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -68,10 +68,10 @@ import com.vitorpamplona.amethyst.Amethyst 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.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName @@ -367,6 +367,7 @@ private fun FeedDefinition.group(): FeedGroup = when (code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS + is TopFilter.Selected -> FeedGroup.RELAYS is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS else -> FeedGroup.FEEDS } @@ -507,6 +508,10 @@ private fun FeedIcon( MaterialSymbols.Public } + is TopFilter.Selected -> { + MaterialSymbols.FilterAlt + } + is TopFilter.AroundMe -> { MaterialSymbols.LocationOn } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt new file mode 100644 index 0000000000..85a205c84e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.topbars + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.MemorySnapshot +import com.vitorpamplona.amethyst.collectMemorySnapshot +import com.vitorpamplona.amethyst.isDebug +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +@Composable +fun MemoryUsageChip() { + if (!isDebug) return + + val context = LocalContext.current + var showDetail by remember { mutableStateOf(false) } + + val snapshot by produceState(null) { + while (true) { + // collectMemorySnapshot reads coil3.disk.DiskLruCache.size(), which is @Synchronized and + // contends with the disk cache's own journal I/O. On cold start that lock is held by a + // background worker for seconds (initial journal read + the burst of image writes), so + // running this on the produceState default (main) dispatcher froze the UI thread — + // the "Loading account" frame couldn't repaint until size() returned. Collect off-main. + value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) } + delay(2_000) + } + } + + val s = snapshot ?: return + + val chipColor = + when { + s.heapFraction > 0.80f -> Color(0xFFE53935) // red + s.heapFraction > 0.60f -> Color(0xFFFFA000) // amber + else -> Color(0xFF43A047) // green + } + + Text( + text = "${s.heapUsedMb}/${s.heapMaxMb}MB", + color = chipColor, + style = MaterialTheme.typography.labelSmall, + modifier = + Modifier + .padding(horizontal = 4.dp) + .clickable { showDetail = true }, + ) + + if (showDetail) { + MemoryDetailDialog(snapshot = s, onDismiss = { showDetail = false }) + } +} + +@Composable +private fun MemoryDetailDialog( + snapshot: MemorySnapshot, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Memory Usage") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Device class: ${snapshot.memoryClassMb} MB") + Text("JVM heap: ${snapshot.heapUsedMb} / ${snapshot.heapMaxMb} MB") + Text("Native heap: ${snapshot.nativeHeapUsedMb} MB") + Text("Coil memory: ${snapshot.imageCacheUsedMb} / ${snapshot.imageCacheMaxMb} MB") + Text("Coil disk: ${snapshot.imageDiskUsedMb} / ${snapshot.imageDiskMaxMb} MB") + Text("Notes: ${snapshot.noteCount}") + Text("Users: ${snapshot.userCount}") + Text("Addressables: ${snapshot.addressableCount}") + Text("Chatrooms: ${snapshot.chatroomCount}") + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("OK") } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index 570a4d2737..5ab67c459f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -75,6 +75,7 @@ fun UserDrawerSearchTopBar( } }, actions = { + MemoryUsageChip() IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) } @@ -83,7 +84,7 @@ fun UserDrawerSearchTopBar( } @Composable -private fun LoggedInUserPictureDrawer( +internal fun LoggedInUserPictureDrawer( accountViewModel: AccountViewModel, onClick: () -> Unit, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt new file mode 100644 index 0000000000..702d846009 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt @@ -0,0 +1,66 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * The notification galleries (reactions, zaps, nutzaps, boosts) render up to 30 + * author avatars each. A couple of the inputs to every avatar are *account-global* + * — the auto-play-gif setting and the logged-in user's follow set — yet the avatar + * code used to collect each of them **once per author**. With dozens of authors per + * card that produced dozens of redundant Flow collectors / coroutine launches every + * time a card scrolled into view, a measurable chunk of the per-card composition cost. + * + * Hoisting those reads to a single collection per gallery and handing the snapshot + * down through [LocalAuthorGalleryRenderContext] lets each avatar read plain values + * and stay skippable. When the local is absent the avatar falls back to its old + * per-author behaviour, so callers that don't provide a context keep working. + */ +@Immutable +class AuthorGalleryRenderContext( + val autoPlayGif: Boolean, + val follows: Set, +) + +val LocalAuthorGalleryRenderContext = compositionLocalOf { null } + +/** + * Collects the account-global render inputs a single time. Provide the result via + * [LocalAuthorGalleryRenderContext] around a gallery's author list. + */ +@Composable +fun rememberAuthorGalleryRenderContext(accountViewModel: AccountViewModel): AuthorGalleryRenderContext { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val follows by accountViewModel.account.allFollows.flow + .collectAsStateWithLifecycle() + + val followAuthors = follows.authors + return remember(autoPlayGif, followAuthors) { + AuthorGalleryRenderContext(autoPlayGif, followAuthors) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index a7e17d4536..365c13a9b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -35,11 +35,11 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.Address diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 374ff41b74..36ef874ded 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -68,19 +69,21 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture -import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.ExpandableRichTextViewer import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.authorRouteFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu @@ -88,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.MultiSetCard import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller @@ -128,6 +132,39 @@ fun MultiSetCompose( val scope = rememberCoroutineScope() + // A MultiSetCard that carries a single zap or nutzap — the common case for a + // freshly-arrived notification appended at the top of the feed — is rendered + // as a large activity card, the same big display used for onchain zaps and the + // thread view, instead of a one-icon gallery. Once a full rebuild groups + // several reactions/zaps on the same post into one card (size > 1), it falls + // back to the compact gallery below. This is purely a rendering decision: the + // card-building logic is unchanged, so the additive path naturally produces the + // single-item (large) cards and a rebuild naturally produces the grouped + // (gallery) ones. + val singleZap = + remember(multiSetCard) { + multiSetCard.zapEvents + .singleOrNull() + ?.takeIf { + multiSetCard.nutzapEvents.isEmpty() && + multiSetCard.likeEvents.isEmpty() && + multiSetCard.boostEvents.isEmpty() + } + } + + val singleNutzap = + remember(multiSetCard) { + multiSetCard.nutzapEvents + .singleOrNull() + ?.takeIf { + multiSetCard.zapEvents.isEmpty() && + multiSetCard.likeEvents.isEmpty() && + multiSetCard.boostEvents.isEmpty() + } + } + + val isLargeCard = singleZap != null || singleNutzap != null + val backgroundColor = calculateBackgroundColor( createdAt = multiSetCard.maxCreatedAt, @@ -136,7 +173,7 @@ fun MultiSetCompose( ) val columnModifier = - remember(backgroundColor.value) { + remember(backgroundColor.value, isLargeCard) { Modifier .fillMaxWidth() .background(backgroundColor.value) @@ -146,34 +183,67 @@ fun MultiSetCompose( }, onLongClick = { popupExpanded.value = true }, ).padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, + // The large-card branch renders through NoteCompose, which applies + // its own 12dp/10dp note padding; let it own the inset there so we + // don't double it up. The gallery branch keeps the card's padding. + start = if (isLargeCard) 0.dp else 12.dp, + end = if (isLargeCard) 0.dp else 12.dp, + top = if (isLargeCard) 0.dp else 10.dp, + bottom = 0.dp, ) } Column(modifier = columnModifier) { - Galeries(multiSetCard, backgroundColor, accountViewModel, nav) + when { + // Render the lone zap/nutzap as a full note (author picture on the left, + // the zap card as its body) by reusing NoteCompose on the receipt note, + // instead of dropping the bare activity card into the feed. + singleZap != null -> + NoteCompose( + baseNote = singleZap.response, + routeForLastRead = null, + isHiddenFeed = showHidden, + quotesLeft = 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) - Row(Modifier.fillMaxWidth()) { - Spacer(modifier = WidthAuthorPictureModifierWithPadding) + singleNutzap != null -> + NoteCompose( + baseNote = singleNutzap, + routeForLastRead = null, + isHiddenFeed = showHidden, + quotesLeft = 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) - NoteCompose( - baseNote = baseNote, - modifier = HalfTopPadding, - routeForLastRead = null, - isBoostedNote = true, - isHiddenFeed = showHidden, - quotesLeft = 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + else -> { + Galeries(multiSetCard, backgroundColor, accountViewModel, nav) - if (popupExpanded.value) { - NoteDropDownMenu(baseNote, { popupExpanded.value = false }, null, accountViewModel, nav) + Row(Modifier.fillMaxWidth()) { + Spacer(modifier = WidthAuthorPictureModifierWithPadding) + + NoteCompose( + baseNote = baseNote, + modifier = HalfTopPadding, + routeForLastRead = null, + isBoostedNote = true, + isHiddenFeed = showHidden, + quotesLeft = 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } + + if (popupExpanded.value) { + NoteDropDownMenu(baseNote, { popupExpanded.value = false }, null, accountViewModel, nav) + } } } @@ -256,7 +326,9 @@ fun RenderLikeGallery( } } - AuthorGallery(likeEvents, nav, accountViewModel) + // Opens the reaction's own thread (where it can be replied to, + // boosted, or zapped) instead of the reactor's profile. + AuthorGallery(likeEvents, nav, accountViewModel) { Route.Note(it.idHex) } } } } @@ -372,18 +444,25 @@ fun RenderNutzapGallery( user = note.author, comment = event?.content?.ifBlank { null }, amount = showAmount(java.math.BigDecimal(sats)), + zapNote = note, ) }.toImmutableList() } Row(Modifier.fillMaxWidth()) { Box( - modifier = WidthAuthorPictureModifier, + // Reuse the reaction galleries' icon column (55dp wide with a 5dp end + // inset) so the cashu glyph lines up with the like/boost icons above + // it, instead of sitting flush-right like the lightning ZappedIcon. + modifier = NotificationIconModifier, ) { Icon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.nutzap), modifier = Modifier.size(Size20dp).align(Alignment.TopEnd), + // Tint the monochrome cashu outline brand orange so the nutzap + // gallery matches the lightning ZappedIcon in RenderZapGallery. + tint = BitcoinOrange, ) } @@ -459,8 +538,12 @@ fun AuthorGalleryZaps( nav: INav, accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + } } } @@ -469,6 +552,9 @@ data class ZapAmountCommentNotification( val user: User?, val comment: String?, val amount: String?, + // The zap receipt (kind 9735) note, when available, so the chip can offer + // actions that target the zap itself (e.g. replying to it). + val zapNote: Note? = null, ) @Composable @@ -485,6 +571,7 @@ private fun ParseAuthorCommentAndAmount( user = zapRequest.author, comment = null, amount = null, + zapNote = zapEvent, ), ) } @@ -504,7 +591,14 @@ fun click( content: ZapAmountCommentNotification, nav: INav, ) { - content.user?.let { nav.nav(routeFor(it)) } + val zapNote = content.zapNote + if (zapNote != null) { + // Opens the zap's own thread, where anyone can reply, boost, + // zap, or share it. The sender's profile is one tap away there. + nav.nav(Route.Note(zapNote.idHex)) + } else { + content.user?.let { nav.nav(routeFor(it)) } + } } @Composable @@ -614,9 +708,14 @@ fun AuthorGallery( authorNotes: ImmutableList, nav: INav, accountViewModel: AccountViewModel, + clickRoute: (Note) -> Route? = ::authorRouteFor, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } } + } } } @@ -627,9 +726,13 @@ fun AuthorGallery( nav: INav, accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { - noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { + noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + } } } } @@ -639,8 +742,9 @@ private fun BoxedAuthor( note: Note, nav: INav, accountViewModel: AccountViewModel, + clickRoute: (Note) -> Route? = ::authorRouteFor, ) { - Box(modifier = Size35Modifier.clickable(onClick = { authorRouteFor(note)?.let { nav.nav(it) } })) { + Box(modifier = Size35Modifier.clickable(onClick = { clickRoute(note)?.let { nav.nav(it) } })) { WatchAuthorWithBlank(note, Size35Modifier, accountViewModel) { author -> WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( author, @@ -667,7 +771,27 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( author: User, accountViewModel: AccountViewModel, ) { - WatchUserMetadata(author, accountViewModel) { baseUserPicture -> + // When rendered inside a gallery, the account-global auto-play and follow-set + // reads are hoisted to a single collection for the whole gallery (see + // [rememberAuthorGalleryRenderContext]). Falls back to per-author collection + // for callers that don't provide a context. + val galleryContext = LocalAuthorGalleryRenderContext.current + + // One shared relay subscription per author. The profile picture and the + // contact-card score below both fetch the same kind-0 metadata, so we + // subscribe once here and let them skip their own (formerly duplicate) one. + UserFinderFilterAssemblerSubscription(author, accountViewModel) + + WatchUserMetadata(author, accountViewModel, subscribe = false) { baseUserPicture -> + val autoPlayGif = + if (galleryContext != null) { + galleryContext.autoPlayGif + } else { + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value + } + RobohashFallbackAsyncImage( robot = author.pubkeyHex, model = baseUserPicture, @@ -676,30 +800,39 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( contentScale = ContentScale.Crop, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = - accountViewModel.settings.autoPlayVideosFlow - .collectAsStateWithLifecycle() - .value, + autoPlayGif = autoPlayGif, ) } - WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + if (galleryContext != null) { + val isFollowing = + accountViewModel.isLoggedUser(author.pubkeyHex) || + author.pubkeyHex in galleryContext.follows if (isFollowing) { Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { FollowingIcon(Size10Modifier) } } + } else { + WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + if (isFollowing) { + Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { + FollowingIcon(Size10Modifier) + } + } + } } - ObserveAndRenderBoxedUserCards(author, accountViewModel) + ObserveAndRenderBoxedUserCards(author, accountViewModel, subscribe = false) } @Composable fun ObserveAndRenderBoxedUserCards( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ) { - val score by observeUserContactCardsScore(user, accountViewModel) + val score by observeUserContactCardsScore(user, accountViewModel, subscribe) score?.let { Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { @@ -712,9 +845,10 @@ fun ObserveAndRenderBoxedUserCards( private fun WatchUserMetadata( author: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, onNewMetadata: @Composable (String?) -> Unit, ) { - val userProfile by observeUserPicture(author, accountViewModel) + val userProfile by observeUserPicture(author, accountViewModel, subscribe) onNewMetadata(userProfile) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 9403b39435..6bcb102918 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05VerifState +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b33649a6a0..a9c17d31f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -60,6 +60,7 @@ 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.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteModifications import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout @@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderAppRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency @@ -137,6 +138,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.types.RenderFundraiser import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitPullRequestEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitPullRequestUpdateEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGoal import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight @@ -154,8 +157,10 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status +import com.vitorpamplona.amethyst.ui.note.types.RenderNamedNappletEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent +import com.vitorpamplona.amethyst.ui.note.types.RenderNutzap import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode @@ -172,6 +177,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember import com.vitorpamplona.amethyst.ui.note.types.RenderReport +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport +import com.vitorpamplona.amethyst.ui.note.types.RenderRootNappletEvent import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -186,9 +194,12 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay +import com.vitorpamplona.amethyst.ui.note.types.observeZapSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP @@ -220,6 +231,8 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent @@ -230,7 +243,10 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -250,6 +266,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent @@ -289,6 +307,9 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent @@ -311,6 +332,7 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @@ -341,6 +363,7 @@ fun NoteCompose( parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, nav: INav, + onClick: (() -> Unit)? = null, moreOptions: (@Composable () -> Unit)? = null, ) { WatchNoteEvent( @@ -371,6 +394,7 @@ fun NoteCompose( parentBackgroundColor = parentBackgroundColor, accountViewModel = accountViewModel, nav = nav, + onClick = onClick, moreOptions = moreOptions, ) } @@ -392,6 +416,7 @@ fun AcceptableNote( parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, nav: INav, + onClick: (() -> Unit)? = null, moreOptions: (@Composable () -> Unit)?, ) { if (isQuotedNote || isBoostedNote) { @@ -444,6 +469,7 @@ fun AcceptableNote( accountViewModel = accountViewModel, showPopup = showPopup, nav = nav, + onClick = onClick, moreOptions = moreOptions, ) } @@ -498,6 +524,7 @@ fun AcceptableNote( accountViewModel = accountViewModel, showPopup = showPopup, nav = nav, + onClick = onClick, moreOptions = moreOptions, ) } @@ -512,6 +539,7 @@ fun calculateBackgroundColor( routeForLastRead: String? = null, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, + dismissNotificationId: HexKey? = null, ): MutableState { val defaultBackgroundColor = MaterialTheme.colorScheme.background val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor @@ -524,7 +552,7 @@ fun calculateBackgroundColor( val isNew = remember(createdAt, routeForLastRead) { - routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt) + routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt, dismissNotificationId) } val bgColor = @@ -564,6 +592,7 @@ private fun CheckNewAndRenderNote( accountViewModel: AccountViewModel, showPopup: () -> Unit, nav: INav, + onClick: (() -> Unit)? = null, moreOptions: (@Composable () -> Unit)? = null, ) { val backgroundColor = @@ -572,12 +601,13 @@ private fun CheckNewAndRenderNote( routeForLastRead, parentBackgroundColor, accountViewModel, + dismissNotificationId = baseNote.idHex, ) InnerNoteWithReactions( baseNote = baseNote, backgroundColor = backgroundColor, - clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav), + clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav, onClick), isBoostedNote = isBoostedNote, isQuotedNote = isQuotedNote, unPackReply = unPackReply, @@ -607,25 +637,30 @@ fun clickableNoteModifier( accountViewModel: AccountViewModel, showPopup: () -> Unit, nav: INav, + onClick: (() -> Unit)? = null, ): Modifier = - remember(baseNote, modifier) { + remember(baseNote, modifier, onClick) { modifier .combinedClickable( onClick = { - val redirectToNote = - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull() ?: baseNote - } else { - baseNote - } - - nav.nav { - if (redirectToNote.event is DraftWrapEvent) { - withContext(Dispatchers.IO) { - routeEditDraftTo(redirectToNote, accountViewModel.account) + if (onClick != null) { + onClick() + } else { + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } + + nav.nav { + if (redirectToNote.event is DraftWrapEvent) { + withContext(Dispatchers.IO) { + routeEditDraftTo(redirectToNote, accountViewModel.account) + } + } else { + routeFor(redirectToNote, accountViewModel.account) } - } else { - routeFor(redirectToNote, accountViewModel.account) } } }, @@ -734,6 +769,7 @@ fun InnerNoteWithReactions( unPackReply = unPackReply, accountViewModel = accountViewModel, nav = nav, + isBoostedNote = isBoostedNote, ) if (!makeItShort) { @@ -882,12 +918,17 @@ private fun RenderNoteRow( editState: State>, accountViewModel: AccountViewModel, nav: INav, + isBoostedNote: Boolean = false, ) { when (val noteEvent = baseNote.event) { is AppDefinitionEvent -> { RenderAppDefinition(baseNote, accountViewModel, nav) } + is AppRecommendationEvent -> { + RenderAppRecommendation(baseNote, accountViewModel, nav) + } + is SoftwareApplicationEvent -> { RenderSoftwareApplication(baseNote, accountViewModel, nav) } @@ -1004,11 +1045,15 @@ private fun RenderNoteRow( } is LnZapEvent -> { - RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) + RenderLnZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + + is NutzapEvent -> { + RenderNutzap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } is OnchainZapEvent -> { - RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav) + RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } is LiveActivitiesClipEvent -> { @@ -1132,6 +1177,14 @@ private fun RenderNoteRow( RenderNamedSiteEvent(baseNote, accountViewModel, nav) } + is RootNappletEvent -> { + RenderRootNappletEvent(baseNote, accountViewModel, nav) + } + + is NamedNappletEvent -> { + RenderNamedNappletEvent(baseNote, accountViewModel, nav) + } + is GitPatchEvent -> { RenderGitPatchEvent( baseNote, @@ -1156,6 +1209,30 @@ private fun RenderNoteRow( ) } + is GitPullRequestEvent -> { + RenderGitPullRequestEvent( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + + is GitPullRequestUpdateEvent -> { + RenderGitPullRequestUpdateEvent( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + is PrivateDmEvent -> { RenderPrivateMessage( baseNote, @@ -1270,6 +1347,14 @@ private fun RenderNoteRow( RenderBirdex(baseNote) } + is RoadEventReportEvent -> { + RenderRoadEventReport(baseNote) + } + + is RoadEventConfirmationEvent -> { + RenderRoadEventConfirmation(baseNote) + } + is HighlightEvent -> { RenderHighlight( baseNote, @@ -1312,6 +1397,7 @@ private fun RenderNoteRow( backgroundColor, accountViewModel, nav, + isBoostedNote = isBoostedNote, ) } @@ -1324,6 +1410,7 @@ private fun RenderNoteRow( backgroundColor, accountViewModel, nav, + isBoostedNote = isBoostedNote, ) } @@ -1336,6 +1423,7 @@ private fun RenderNoteRow( backgroundColor, accountViewModel, nav, + isBoostedNote = isBoostedNote, ) } @@ -1363,6 +1451,14 @@ private fun RenderNoteRow( PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav) } + is WorkoutRecordEvent -> { + WorkoutDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav) + } + + is ExerciseTemplateEvent -> { + ExerciseTemplateDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav) + } + is BaseVoiceEvent -> { RenderVoiceTrack(baseNote, accountViewModel, nav) } @@ -1463,6 +1559,7 @@ private fun RenderNoteRow( backgroundColor, accountViewModel, nav, + isBoostedNote = isBoostedNote, ) } @@ -1477,6 +1574,7 @@ private fun RenderNoteRow( editState, accountViewModel, nav, + isBoostedNote = isBoostedNote, ) } } @@ -1708,7 +1806,22 @@ fun FirstUserInfoRow( val isDraft = baseNote.isDraft() val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified - if (showAuthorPicture) { + // Zap receipts are signed by the recipient's lightning provider; show the + // sender from the embedded zap request instead of the service key. + val zapSender = + if (baseNote.event is LnZapEvent) { + observeZapSender(baseNote, accountViewModel).value + } else { + null + } + + if (zapSender != null) { + if (showAuthorPicture) { + UserPicture(zapSender, Size25dp, accountViewModel = accountViewModel, nav = nav) + Spacer(HalfPadding) + } + UsernameDisplay(zapSender, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) + } else if (showAuthorPicture) { NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav) Spacer(HalfPadding) NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) @@ -1752,6 +1865,10 @@ fun FirstUserInfoRow( DisplayDraft() } + if (baseNote.isPrivateRumor()) { + PrivateRumorMark() + } + if (isPinned) { PinnedMark() } @@ -1780,6 +1897,16 @@ fun PinnedMark() { ) } +@Composable +fun PrivateRumorMark() { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = stringRes(R.string.private_rumor_mark), + modifier = Modifier.padding(start = 5.dp).size(16.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) +} + @Composable fun JumpToParentReplyButton( baseNote: Note, @@ -1918,6 +2045,16 @@ fun RenderAuthorImages( } else { NoteAuthorPicture(baseNote, Size55dp, accountViewModel = accountViewModel, nav = nav) } + } else if (noteEvent is LnZapEvent) { + // Zap receipts are signed by the recipient's lightning provider; show the + // sender from the embedded zap request instead of the service key, matching + // how the thread's master note resolves the author. + val zapSender = observeZapSender(baseNote, accountViewModel).value + if (zapSender != null) { + UserPicture(zapSender, Size55dp, accountViewModel = accountViewModel, nav = nav) + } else { + NoteAuthorPicture(baseNote, Size55dp, accountViewModel = accountViewModel, nav = nav) + } } else { NoteAuthorPicture(baseNote, Size55dp, accountViewModel = accountViewModel, nav = nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 7591b7d582..7d4d6ce281 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -385,14 +385,19 @@ fun CardBody( } } - VerticalDivider(color = primaryLight) - NoteQuickActionItem( - icon = MaterialSymbols.Dns, - label = stringRes(R.string.broadcast), - ) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() + // Rumors are rebroadcast as their delivering gift wrap; hidden + // when the wrap is unknown (the unsigned rumor must never be + // published). + if (accountViewModel.canBroadcast(note)) { + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + icon = MaterialSymbols.Dns, + label = stringRes(R.string.broadcast), + ) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } } VerticalDivider(color = primaryLight) if (isOwnNote && note.isDraft()) { @@ -402,6 +407,9 @@ fun CardBody( ) { onWantsToEditDraft() } + } else if (note.isPrivateRumor()) { + // No external share link for private rumors: nobody can + // resolve the id from relays and sharing it leaks the id. } else { NoteQuickActionItem( icon = MaterialSymbols.Share, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt index 4283371205..9430a87884 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.StdStartPadding @@ -94,6 +95,10 @@ private fun RenderNutzapGalleryRow( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.nutzap), modifier = Modifier.size(Size20dp).align(Alignment.TopEnd), + // Tint the monochrome cashu outline brand orange so it reads as + // a value transfer, matching the lightning ZappedIcon in the + // sibling zap gallery. + tint = BitcoinOrange, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt index 393c8e86ae..81e844a19d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NutzapUserSetCard import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @@ -84,6 +85,7 @@ fun NutzapUserSetCompose( user = note.author, comment = event?.content?.ifBlank { null }, amount = showAmount(java.math.BigDecimal(sats)), + zapNote = note, ) }.toImmutableList() } @@ -112,6 +114,9 @@ fun NutzapUserSetCompose( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.nutzap), modifier = Modifier.size(Size25dp).align(Alignment.TopEnd), + // Tint the monochrome cashu outline brand orange so it + // matches the lightning ZappedIcon in the zap set card. + tint = BitcoinOrange, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 2e352ab051..7a4067d6b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.note import android.content.Context -import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibility @@ -44,6 +43,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -59,8 +59,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard @@ -116,6 +114,8 @@ import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ReactionRowAction @@ -141,14 +141,13 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox -import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo +import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog @@ -259,6 +258,13 @@ private fun InnerReactionRow( }, reactions = reactionRowItems, renderReaction = { item -> + // Unsealed rumors (private replies/posts) must not receive public + // reposts or quotes: each would e-tag the private rumor id onto + // public relays. Replies, reactions, and zaps stay enabled because + // the composer locks private mode for rumor parents, ReactionAction + // gift-wraps reactions to empty-sig targets, and zaps are forced to + // the PRIVATE type with public rails suppressed. + val isPrivateRumor = baseNote.isPrivateRumor() when (item.action) { ReactionRowAction.Reply -> { ReplyReactionWithDialog( @@ -273,7 +279,7 @@ private fun InnerReactionRow( ReactionRowAction.Boost -> { val isDM = baseNote.event is ChatroomKeyable - if (!isDM) { + if (!isDM && !isPrivateRumor) { BoostWithDialog( baseNote, editState, @@ -296,6 +302,9 @@ private fun InnerReactionRow( } ReactionRowAction.Zap -> { + // Zaps stay enabled on private rumors: AccountViewModel.zap + // forces the PRIVATE zap type, and the public nutzap/onchain + // rails are suppressed for them. ZapReaction( baseNote, MaterialTheme.colorScheme.placeholderText, @@ -306,10 +315,13 @@ private fun InnerReactionRow( } ReactionRowAction.Share -> { - ShareReaction( - note = baseNote, - grayTint = MaterialTheme.colorScheme.placeholderText, - ) + if (!isPrivateRumor) { + ShareReaction( + note = baseNote, + nav = nav, + grayTint = MaterialTheme.colorScheme.placeholderText, + ) + } } ReactionRowAction.Pay -> { @@ -327,38 +339,26 @@ private fun InnerReactionRow( @Composable fun ShareReaction( note: Note, + nav: INav, grayTint: Color, barChartModifier: Modifier = Size19Modifier, ) { - val context = LocalContext.current + var showShareSheet by remember { mutableStateOf(false) } ClickableBox( modifier = barChartModifier, - onClick = { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), - ) - putExtra( - Intent.EXTRA_TITLE, - stringRes(context, R.string.quick_action_share_browser_link), - ) - } - - val shareIntent = - Intent.createChooser( - sendIntent, - stringRes(context, R.string.quick_action_share), - ) - context.startActivity(shareIntent) - }, + onClick = { showShareSheet = true }, ) { ShareIcon(barChartModifier, grayTint) } + + if (showShareSheet) { + ShareOptionsBottomSheet( + note = note, + nav = nav, + onDismiss = { showShareSheet = false }, + ) + } } @Composable @@ -1242,10 +1242,16 @@ fun ZapReaction( nav.nav(Route.UpdateZapAmount()) } }, - onOnchainAmount = { amount -> - wantsToZap = false - onchainZapRequest = OnchainZapRequest(amount) - }, + onOnchainAmount = + if (baseNote.isPrivateRumor()) { + // Onchain zap events are public and would e-tag the rumor id. + null + } else { + { amount -> + wantsToZap = false + onchainZapRequest = OnchainZapRequest(amount) + } + }, onError = { _, message, user -> scope.launch { zappingProgress = 0f @@ -1600,7 +1606,6 @@ private fun BoostTypeChoicePopup( ) } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun BoostTypeChoicePopup( baseNote: Note, @@ -1624,52 +1629,81 @@ private fun BoostTypeChoicePopup( enter = popupAnimationEnter, exit = popupAnimationExit, ) { - FlowRow { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - if (accountViewModel.isWriteable()) { - accountViewModel.boost(baseNote) - visibilityState.targetState = false - } else { - onRepost() - visibilityState.targetState = false - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringRes(R.string.boost), color = Color.White, textAlign = TextAlign.Center) - } - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onQuote, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringRes(R.string.quote), color = Color.White, textAlign = TextAlign.Center) - } - + BoostTypeChoicePopupContent( // removes the option to fork for now because we do not have screens for // LongForm, Wiki and NIP posting. - if (baseNote.event is TextNoteEvent) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), + showFork = baseNote.event is TextNoteEvent, + onBoost = { + if (accountViewModel.isWriteable()) { + accountViewModel.boost(baseNote) + } else { + onRepost() + } + visibilityState.targetState = false + }, + onQuote = { + onQuote() + visibilityState.targetState = false + }, + onFork = { + onFork() + visibilityState.targetState = false + }, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun BoostTypeChoicePopupContent( + showFork: Boolean, + onBoost: () -> Unit, + onQuote: () -> Unit, + onFork: () -> Unit, +) { + Box(HalfPadding, contentAlignment = Center) { + ElevatedCard( + shape = SmallBorder, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + FlowRow( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + itemVerticalAlignment = CenterVertically, + ) { + BoostActionChip( + label = stringRes(R.string.boost), + onClick = onBoost, + ) { tint -> + RepostedIcon(Size18Modifier, tint) + } + + BoostActionChip( + label = stringRes(R.string.quote), + onClick = onQuote, + ) { tint -> + Icon( + symbol = MaterialSymbols.FormatQuote, + contentDescription = null, + modifier = Size18Modifier, + tint = tint, + ) + } + + if (showFork) { + BoostActionChip( + label = stringRes(R.string.fork), onClick = onFork, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center) + ) { tint -> + Icon( + symbol = MaterialSymbols.EditNote, + contentDescription = null, + modifier = Size18Modifier, + tint = tint, + ) } } } @@ -1677,6 +1711,67 @@ private fun BoostTypeChoicePopup( } } +/** + * A single quote/repost action rendered as a pill: a coloured leading icon next + * to its label, wrapped in the same soft-outlined surface the zap rails use so + * the popups read as one family. + */ +@Composable +private fun BoostActionChip( + label: String, + onClick: () -> Unit, + icon: @Composable (Color) -> Unit, +) { + Surface( + shape = ButtonBorder, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + ) { + Row( + modifier = + Modifier + .clip(ButtonBorder) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = CenterVertically, + ) { + icon(MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(6.dp)) + Text( + text = label, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } + } +} + +@Preview +@Composable +fun BoostTypeChoicePopupPreview() { + ThemeComparisonColumn { + Column { + // With the fork action (e.g. a plain text note). + BoostTypeChoicePopupContent( + showFork = true, + onBoost = {}, + onQuote = {}, + onFork = {}, + ) + + // Without the fork action (e.g. long-form, wiki, …). + BoostTypeChoicePopupContent( + showFork = false, + onBoost = {}, + onQuote = {}, + onFork = {}, + ) + } + } +} + val popupAnimationEnter: EnterTransition = slideInVertically( animationSpec = defaultTweenIntOffsetSpec, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index fd71504495..9cec7d50ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -73,11 +73,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.commons.util.firstFullChar import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull -import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 5bd7d3624c..8c29e8e01c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -131,6 +131,10 @@ fun ZapCustomDialog( LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } + // Zaps on private rumors are forced private (AccountViewModel.zap), so + // only offer the choices that match what will actually be sent. + val isPrivateTarget = baseNote.isPrivateRumor() + val zapTypes = listOf( Triple( @@ -153,10 +157,21 @@ fun ZapCustomDialog( stringRes(id = R.string.zap_type_nonzap), stringRes(id = R.string.zap_type_nonzap_explainer), ), - ) + ).filter { + !isPrivateTarget || it.first == LnZapEvent.ZapType.PRIVATE || it.first == LnZapEvent.ZapType.NONZAP + } var selectedZapType by - remember(accountViewModel) { mutableStateOf(accountViewModel.defaultZapType()) } + remember(accountViewModel, baseNote) { + val default = accountViewModel.defaultZapType() + mutableStateOf( + if (isPrivateTarget && default != LnZapEvent.ZapType.NONZAP) { + LnZapEvent.ZapType.PRIVATE + } else { + default + }, + ) + } val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt index a16430e086..31e85dfb4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt @@ -104,7 +104,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent -import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers @@ -204,7 +203,6 @@ fun ZapZapPollNotePreview2() { arrayOf("poll_option", "1", "Pesquisa em ingles"), arrayOf("value_maximum", "2"), arrayOf("value_minimum", "2"), - AltTag.assemble("Poll event"), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt new file mode 100644 index 0000000000..fa577b9632 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -0,0 +1,268 @@ +/* + * 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.foundation.clickable +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.Icon +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.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.service.ClinkOfferPayer +import com.vitorpamplona.amethyst.ui.components.PaymentCard +import com.vitorpamplona.amethyst.ui.components.PaymentCardAmount +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +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 +import java.text.NumberFormat + +/** + * 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, + nav: INav, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var requesting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } + var amountInput by remember { mutableStateOf("") } + var needsAmount by remember { mutableStateOf(offer.priceType == OfferPriceType.SPONTANEOUS) } + var amountRange by remember { mutableStateOf(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 }, + ) + + PaymentCard( + title = stringRes(R.string.clink_lightning_offer), + icon = { + Icon( + imageVector = CustomHashTagIcons.Lightning, + contentDescription = null, + modifier = Size18Modifier, + tint = Color.Unspecified, + ) + }, + copyValue = activeOffer.encode(), + ) { + // Who gets paid: the offer pointer carries the recipient's pubkey. + OfferRecipientRow(activeOffer.pubKey, accountViewModel, nav) + + // 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"). + if (activeOffer.priceType == OfferPriceType.FIXED) { + activeOffer.price?.let { + PaymentCardAmount( + amount = NumberFormat.getIntegerInstance().format(it), + unit = stringRes(R.string.sats), + ) + } + } + + 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(top = 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(top = 12.dp), + enabled = !requesting && (!amountRequired || (amountInput.toLongOrNull() ?: 0L) > 0L), + onClick = { + requesting = true + scope.launch { runOfferRequest(activeOffer, followMoved = true) } + }, + shape = ButtonBorder, + ) { + Text(text = stringRes(if (requesting) R.string.clink_requesting_invoice else R.string.pay)) + } + } +} + +/** Avatar + name of the offer's payee, tappable to open their profile. */ +@Composable +private fun OfferRecipientRow( + pubKey: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadUser(baseUserHex = pubKey, accountViewModel) { user -> + if (user != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .clip(SmallBorder) + .clickable { nav.nav(routeFor(user)) } + .padding(4.dp), + ) { + Text( + text = stringRes(R.string.clink_offer_pay_to), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + + ClickableUserPicture( + baseUser = user, + size = Size25dp, + accountViewModel = accountViewModel, + onClick = { nav.nav(routeFor(it)) }, + ) + + Spacer(modifier = StdHorzSpacer) + + UsernameDisplay(user, accountViewModel = accountViewModel) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt new file mode 100644 index 0000000000..3487f7fd44 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt @@ -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)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt index ae6c23419f..ded4a9e79b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt @@ -20,14 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.invoice -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -38,29 +33,27 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection 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.service.lnurl.CachedLnInvoiceParser import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.PaymentCard +import com.vitorpamplona.amethyst.ui.components.PaymentCardAmount +import com.vitorpamplona.amethyst.ui.components.PaymentCardDescription 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 -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -89,7 +82,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, it.description, it.expiresAt, accountViewModel) } else { Text( text = lnbcWord, @@ -104,10 +97,14 @@ fun MayBeInvoicePreview( fun InvoicePreview( lnInvoice: String, amount: String?, + description: String?, + expiresAt: Long?, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current var showErrorMessageDialog by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } if (showErrorMessageDialog != null) { ErrorMessageDialog( @@ -117,70 +114,60 @@ fun InvoicePreview( ) } - 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), + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { showErrorMessageDialog = it }, + ) + + // Snapshot at composition: enough to flag clearly stale invoices without + // ticking a clock while the card is on screen. + val isExpired = remember(expiresAt) { expiresAt != null && expiresAt < TimeUtils.now() } + + PaymentCard( + title = stringRes(R.string.lightning_invoice), + icon = { + Icon( + imageVector = CustomHashTagIcons.Lightning, + contentDescription = null, + modifier = Size18Modifier, + tint = Color.Unspecified, + ) + }, + copyValue = lnInvoice, ) { - Column( + amount?.let { + PaymentCardAmount(amount = it, unit = stringRes(R.string.sats)) + } + + description?.let { + PaymentCardDescription(it) + } + + if (isExpired) { + Text( + text = stringRes(R.string.invoice_expired), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + ) + } + + Button( modifier = Modifier .fillMaxWidth() - .padding(20.dp), + .padding(top = 12.dp), + enabled = !isExpired, + onClick = { payingInvoice = lnInvoice }, + shape = ButtonBorder, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 10.dp), - ) { - Icon( - imageVector = CustomHashTagIcons.Lightning, - null, - modifier = Size20Modifier, - tint = Color.Unspecified, - ) - - Text( - text = stringRes(R.string.lightning_invoice), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) - } - - HorizontalDivider(thickness = DividerThickness) - - amount?.let { - Text( - text = "$it ${stringRes(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 10.dp), - ) - } - - Button( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 10.dp), - onClick = { payViaIntent(lnInvoice, context, { }) { showErrorMessageDialog = it } }, - shape = QuoteBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringRes(R.string.pay), color = Color.White, fontSize = 20.sp) - } + Text(text = stringRes(R.string.pay)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt index e3f1568b29..be8f95374c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.invoice -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -41,7 +40,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -59,38 +57,6 @@ 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.placeholderText -import com.vitorpamplona.amethyst.ui.theme.subtleBorder - -@Composable -fun InvoiceRequestCard( - lud16: String, - user: User, - accountViewModel: AccountViewModel, - titleText: String? = null, - buttonText: String? = null, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) - .clip(shape = QuoteBorder) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) - .padding(30.dp), - ) { - InvoiceRequest( - lud16, - user, - accountViewModel, - titleText, - buttonText, - onSuccess, - onError, - ) - } -} @Composable fun InvoiceRequest( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt index 86289842f5..b7abefd291 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt index 05cce5516b..2fd6d60d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt @@ -30,7 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations -import com.vitorpamplona.amethyst.service.location.toGeoHash +import com.vitorpamplona.amethyst.service.location.toLocation +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt new file mode 100644 index 0000000000..984c3fc985 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -0,0 +1,192 @@ +/* + * 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.location + +import android.graphics.ColorFilter +import android.graphics.ColorMatrixColorFilter +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.ui.theme.isLight +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +/** Default close-up zoom for a single pinned location (street-level). */ +private const val DEFAULT_ZOOM = 16.0 + +/** + * Night-mode filter for the (always-light) MAPNIK tiles so the map follows the + * app theme. This is a lightness invert composed with a 180° hue rotation: it + * darkens the map (light land → near-black) while *preserving* hue, so forests + * stay green and water stays blue and the surfaces remain distinguishable — + * unlike a plain colour invert (forests → magenta) or a desaturating invert + * (everything → black). + */ +private val NIGHT_TILE_FILTER: ColorFilter = + ColorMatrixColorFilter( + floatArrayOf( + 0.574f, + -1.430f, + -0.144f, + 0f, + 255f, + -0.426f, + -0.430f, + -0.144f, + 0f, + 255f, + -0.426f, + -1.430f, + 0.856f, + 0f, + 255f, + 0f, + 0f, + 0f, + 1f, + 0f, + ), + ) + +/** + * A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude] + * with a single pin at that point. + * + * Used by the Roadstr road event cards (kind 1315/1316) in place of a + * reverse-geocoded city name. Tiles are fetched from the public OSM tile + * servers, which requires the app's package name as the User-Agent (set on + * [Configuration] below) — without it OSM returns HTTP 403. + * + * When [pinColor] and [pinEmoji] are supplied the marker becomes a colored + * teardrop with the category emoji on it (see [roadEventPinBitmap]); otherwise + * osmdroid's default pin is used. [pinAlpha] fades the marker to signal + * freshness (e.g. an event close to its effective expiry). + * + * Pan/zoom stay enabled, but a touch listener asks the parent to stop + * intercepting gestures while the finger is on the map, so dragging the map + * pans it instead of scrolling the surrounding feed. + */ +@Composable +fun LocationPreviewMap( + latitude: Double, + longitude: Double, + modifier: Modifier = Modifier, + zoom: Double = DEFAULT_ZOOM, + pinColor: Color? = null, + pinEmoji: String? = null, + pinAlpha: Float = 1f, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val darkTheme = !MaterialTheme.colorScheme.isLight + + val markerIcon = + remember(pinColor, pinEmoji) { + if (pinColor != null && pinEmoji != null) { + val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) + BitmapDrawable(context.resources, bitmap) + } else { + null + } + } + + val mapView = + remember(context) { + // Must be set before the MapView is created so OSM tile requests + // carry a valid User-Agent (the default "osmdroid" is rejected). + Configuration.getInstance().userAgentValue = context.packageName + + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP -> view.parent?.requestDisallowInterceptTouchEvent(false) + } + // Returning false lets the MapView still handle the pan/zoom. + false + } + } + } + + // osmdroid's MapView is a plain Android View with its own lifecycle: it + // needs onResume/onPause to (re)start its tile threads, and onDetach to + // free the tile cache when the composable leaves the tree. + DisposableEffect(lifecycleOwner, mapView) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> mapView.onResume() + Lifecycle.Event.ON_PAUSE -> mapView.onPause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + mapView.onDetach() + } + } + + AndroidView( + modifier = modifier.fillMaxWidth().aspectRatio(1f), + factory = { mapView }, + update = { map -> + val point = GeoPoint(latitude, longitude) + map.controller.setZoom(zoom) + map.controller.setCenter(point) + + // Follow the app theme: dim the bright MAPNIK tiles in dark mode. + map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + + map.overlays.removeAll { it is Marker } + val marker = + Marker(map).apply { + position = point + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setInfoWindow(null) + markerIcon?.let { icon = it } + alpha = pinAlpha + } + map.overlays.add(marker) + map.invalidate() + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt new file mode 100644 index 0000000000..8e9f422ccc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt @@ -0,0 +1,102 @@ +/* + * 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.location + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import kotlin.math.roundToInt + +/** + * Draws a Roadstr-style map pin: a colored teardrop in [colorArgb] with [emoji] + * centered on its head, a white ring, and a soft drop shadow. + * + * The pin's tip sits at the bottom-center of the returned bitmap, so the + * osmdroid marker should be anchored at (0.5, 1.0) — the geographic point then + * lands exactly under the tip. + * + * Matches the visual language of the roadstr reference clients + * (): the same per-category color is used so + * a pin reads the same across implementations. + */ +fun roadEventPinBitmap( + emoji: String, + colorArgb: Int, + density: Float, +): Bitmap { + fun dp(value: Float) = value * density + + val head = dp(34f) // head diameter + val radius = head / 2f + val tail = dp(12f) // pointer height below the head + val pad = dp(5f) // room for the drop shadow + val ringWidth = dp(2.5f) + + val width = (head + pad * 2).roundToInt() + val height = (head + tail + pad * 2).roundToInt() + val cx = width / 2f + val cy = pad + radius + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Head circle + pointer drawn as a single path so they share one shadow + // instead of seaming where the triangle meets the circle. + val body = + Path().apply { + addCircle(cx, cy, radius, Path.Direction.CW) + val mouth = radius * 0.7f + moveTo(cx - mouth, cy + radius * 0.55f) + lineTo(cx + mouth, cy + radius * 0.55f) + lineTo(cx, pad + head + tail) + close() + } + + val fill = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = colorArgb + style = Paint.Style.FILL + setShadowLayer(dp(3f), 0f, dp(2f), 0x80000000.toInt()) + } + canvas.drawPath(body, fill) + + val ring = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = ringWidth + } + canvas.drawCircle(cx, cy, radius - ringWidth / 2f, ring) + + // Color emoji fonts ignore the paint color, so the glyph keeps its own hue. + val text = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + textSize = head * 0.58f + } + val metrics = text.fontMetrics + val baseline = cy - (metrics.ascent + metrics.descent) / 2f + canvas.drawText(emoji, cx, baseline, text) + + return bitmap +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt index 3f7bd02376..64f1d09337 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt @@ -23,27 +23,31 @@ package com.vitorpamplona.amethyst.ui.note.creators.notify import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp 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.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +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.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink +import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.ImmutableList @@ -52,29 +56,38 @@ import kotlinx.collections.immutable.ImmutableList fun Notifying( baseMentions: ImmutableList?, accountViewModel: AccountViewModel, + label: String? = null, + showWhenEmpty: Boolean = false, + onAddUser: (() -> Unit)? = null, onClick: (User) -> Unit, ) { val mentions = baseMentions?.toSet() - FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - if (!mentions.isNullOrEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (!mentions.isNullOrEmpty() || showWhenEmpty) { Text( - stringRes(R.string.reply_notify), + label ?: stringRes(R.string.reply_notify), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.align(CenterVertically), ) - mentions.forEachIndexed { _, user -> - Button( - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.mediumImportanceLink, - ), - onClick = { onClick(user) }, - ) { - DisplayUserNameWithDeleteMark(user, accountViewModel) + // The chips render through a selectable Surface that enforces a 48dp minimum + // touch target, inflating each chip's measured height well above its visible + // 32dp pill. That invisible padding would dominate the gap between wrapped + // rows and make verticalArrangement barely noticeable. Disabling the minimum + // interactive size lets the chips measure at their visible height so the row + // spacing matches the horizontal spacing between chips. + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + mentions?.forEach { user -> + NotifyUserChip(user, accountViewModel) { onClick(user) } + } + + if (onAddUser != null) { + AddUserChip(onAddUser) } } } @@ -82,26 +95,49 @@ fun Notifying( } @Composable -private fun DisplayUserNameWithDeleteMark( +private fun NotifyUserChip( user: User, accountViewModel: AccountViewModel, + onRemove: () -> Unit, ) { - val innerUserState by observeUserInfo(user, accountViewModel) - - val meta = innerUserState - - if (meta != null) { - CreateTextWithEmoji( - text = remember(meta) { "✖ ${meta.info.bestName() ?: user.pubkeyDisplayHex()}" }, - tags = meta.tags, - color = Color.White, - textAlign = TextAlign.Center, - ) - } else { - Text( - text = remember(meta) { "✖ ${user.pubkeyDisplayHex()}" }, - color = Color.White, - textAlign = TextAlign.Center, - ) - } + InputChip( + selected = false, + onClick = onRemove, + label = { + UsernameDisplay( + user, + weight = Modifier.widthIn(max = 180.dp), + fontWeight = FontWeight.SemiBold, + accountViewModel = accountViewModel, + ) + }, + // Use the leadingIcon slot instead of avatar: InputChip clips the avatar slot + // to a circle, which would cut off the following badge that BaseUserPicture + // intentionally draws slightly outside the picture's circle. + leadingIcon = { + BaseUserPicture(user, Size24dp, accountViewModel) + }, + trailingIcon = { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.notify_remove_user), + modifier = Modifier.size(InputChipDefaults.IconSize), + ) + }, + ) +} + +@Composable +private fun AddUserChip(onAddUser: () -> Unit) { + AssistChip( + onClick = onAddUser, + label = { Text(text = stringRes(R.string.notify_add_user)) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt index 918011fbcf..6839746d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.service.playback.composable.VideoView @@ -48,9 +49,9 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard -import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -84,7 +85,7 @@ fun PreviewUrl( } else -> { - MyLoadUrlPreviewDirect(myUrlPreview, myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirect(myUrlPreview, myUrlPreview, accountViewModel, nav) } } } @@ -104,7 +105,7 @@ fun PreviewUrl( } RichTextParser.isUrlWithoutScheme(myUrlPreview) -> { - MyLoadUrlPreviewDirect("https://$myUrlPreview", myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirect("https://$myUrlPreview", myUrlPreview, accountViewModel, nav) } } } @@ -138,7 +139,7 @@ fun PreviewUrlFillWidth( } else -> { - MyLoadUrlPreviewDirectFillWidth(myUrlPreview, myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirectFillWidth(myUrlPreview, myUrlPreview, accountViewModel, nav) } } } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) { @@ -154,7 +155,7 @@ fun PreviewUrlFillWidth( nav = nav, ) } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) { - MyLoadUrlPreviewDirectFillWidth("https://$myUrlPreview", myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirectFillWidth("https://$myUrlPreview", myUrlPreview, accountViewModel, nav) } } @@ -200,6 +201,7 @@ private fun MyLoadUrlPreviewDirect( url: String, urlText: String, accountViewModel: AccountViewModel, + nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") val urlPreviewState by @@ -268,6 +270,7 @@ private fun MyLoadUrlPreviewDirectFillWidth( url: String, urlText: String, accountViewModel: AccountViewModel, + nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") val urlPreviewState by @@ -304,7 +307,11 @@ private fun MyLoadUrlPreviewDirectFillWidth( accountViewModel = accountViewModel, ) } else { - UrlPreviewCard(url, previewInfo = state.previewInfo) + UrlPreviewCard( + url, + previewInfo = state.previewInfo, + onUrlComments = { nav.nav(Route.Url(url)) }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index cfa7304b62..dc8a448dce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +46,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User @@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize @@ -116,13 +121,23 @@ fun WatchResponses( val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) if (suggestions.isNotEmpty()) { + // Snapshot once per result list, not per row. + val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } + LazyColumn( contentPadding = PaddingValues(top = 10.dp), modifier = modifier, state = listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - UserLine(item, accountViewModel, trailingContent) { onSelect(item) } + val trailing = + trailingContent + ?: if (item.pubkeyHex in priority) { + { InThisChatChip() } + } else { + null + } + UserLine(item, accountViewModel, trailing) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, ) @@ -133,6 +148,22 @@ fun WatchResponses( } } +@Composable +private fun InThisChatChip() { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringRes(R.string.user_suggestion_in_this_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + @Composable fun UserLine( baseUser: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index f066303433..76858b0776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client @@ -58,10 +59,36 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** + * Moves users whose pubkey is in [priority] to the top of [found], + * preserving the relative order everywhere else (stable sort). Reorders + * only — it never adds or removes entries, so priority keys whose users + * didn't match the search have no effect. + */ +fun rankPriorityFirst( + found: List, + priority: Set, +): List = + if (priority.isEmpty()) { + found + } else { + found.sortedByDescending { it.pubkeyHex in priority } + } + +/** + * Drives the @-mention autocomplete dropdown: searches the local cache, + * relays, and NIP-05 identifiers for the word currently being typed. + * + * [priorityPubkeys] is a live supplier of pubkeys to rank first in the + * results — pass the current conversation's participants (NIP-17 room + * users, public-chat authors, MLS group members, …) so they beat + * network-wide matches. Ranking only; it never filters anyone out. + */ @Stable class UserSuggestionState( val account: Account, val nip05Client: INip05Client, + val priorityPubkeys: () -> Set = { emptySet() }, ) { val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") @@ -158,7 +185,10 @@ class UserSuggestionState( } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { - account.cache.findUsersStartingWith(prefix, account) + rankPriorityFirst( + account.cache.findUsersStartingWith(prefix, account), + priorityPubkeys(), + ) } } else { emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt index be91881bb0..791bbfc67a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollField.kt @@ -36,10 +36,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.utils.RandomInstance @Composable fun ZapPollField(postViewModel: ShortNotePostViewModel) { - val optionsList = postViewModel.zapPollOptions + // Shares the same `pollOptions` text fields as the regular poll so switching poll types keeps the text. + val optionsList = postViewModel.pollOptions Column( modifier = Modifier.fillMaxWidth(), ) { @@ -55,8 +58,7 @@ fun ZapPollField(postViewModel: ShortNotePostViewModel) { Button( onClick = { - // postViewModel.pollOptions[postViewModel.pollOptions.size] = "" - optionsList[optionsList.size] = "" + optionsList[optionsList.size] = OptionTag(RandomInstance.randomChars(6), "") }, border = BorderStroke( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt index ded27476c9..64a5e979cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollOption.kt @@ -47,7 +47,7 @@ fun ZapPollOption( val deleteIcon: @Composable (() -> Unit) = { IconButton( onClick = { - pollViewModel.removeZapPollOption(optionIndex) + pollViewModel.removePollOption(optionIndex) }, ) { Icon( @@ -59,9 +59,9 @@ fun ZapPollOption( OutlinedTextField( modifier = Modifier.weight(1F), - value = pollViewModel.zapPollOptions[optionIndex] ?: "", + value = pollViewModel.pollOptions[optionIndex]?.label ?: "", onValueChange = { - pollViewModel.updateZapPollOption(optionIndex, it) + pollViewModel.updatePollOption(optionIndex, it) }, label = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt index f40ab6d724..d40543e202 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 0ece66d124..3e32487fc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -27,10 +27,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index ed06b90142..cc4f929afb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -33,8 +33,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index 316ef9ee6f..ec5b9bb06b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -30,8 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.components.buildLinkString import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.buildLinkString import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index f13cb18fac..b392b6a50c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -55,10 +55,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies -import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapIcon diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index c63cd5a5cc..48e7fa591d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -28,8 +28,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.service.CachedRichTextParser -import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index fd4d2b5695..3956faf0d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.State @@ -30,15 +29,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection @@ -47,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog @@ -116,6 +113,23 @@ fun NoteDropDownMenu( ) { var reportDialogShowing by remember { mutableStateOf(false) } var addLabelDialogShowing by remember { mutableStateOf(false) } + var showShareSheet by remember { mutableStateOf(false) } + + // Tapping "Share" hands the note's share options off to the shared Share + // drawer (ShareOptionsBottomSheet). We render it INSTEAD of the menu dialog + // — not on top of it — so the menu doesn't sit dimmed behind the sheet; + // dismissing the drawer closes the whole menu. + if (showShareSheet) { + ShareOptionsBottomSheet( + note = note, + nav = nav, + onDismiss = { + showShareSheet = false + onDismiss() + }, + ) + return + } val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( DropDownParams( @@ -158,9 +172,14 @@ fun NoteDropDownMenu( onDismiss = onDismiss, ) { val clipboardManager = LocalClipboard.current - val actContext = LocalContext.current val scope = rememberCoroutineScope() + // Unsealed rumors (private replies/posts received in gift wraps) are + // unsigned and must never be referenced by a public event: hide every + // action that would publish an e-tag of this note (broadcast, edit, + // OTS timestamp, pin, label, public bookmark, deletion request). + val isPrivateRumor = note.isPrivateRumor() + // Follow section M3ActionSection { if (!state.isFollowingAuthor) { @@ -183,7 +202,8 @@ fun NoteDropDownMenu( } } - // Copy & Share section + // Copy & Share section. The copy-to-clipboard rows live here (and only + // here); the "Share" row hands off to the shared Share drawer. M3ActionSection { M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note @@ -220,17 +240,10 @@ fun NoteDropDownMenu( onDismiss() } } - M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) - putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) - } - val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - actContext.startActivity(shareIntent) - onDismiss() + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { + showShareSheet = true + } } } @@ -241,7 +254,7 @@ fun NoteDropDownMenu( nav.nav { routeEditDraftTo(note, accountViewModel.account) } } } - if (!note.isDraft()) { + if (!note.isDraft() && !isPrivateRumor) { if (note.event is TextNoteEvent) { if (state.isLoggedUser) { M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_post)) { @@ -258,23 +271,30 @@ fun NoteDropDownMenu( } } } - M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) { - accountViewModel.broadcast(note) - onDismiss() + // Rumors are rebroadcast as their delivering gift wrap; hidden + // when the wrap is unknown (the unsigned rumor must never be + // published). + if (accountViewModel.canBroadcast(note)) { + M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) { + accountViewModel.broadcast(note) + onDismiss() + } } } // Timestamp & Bookmarks section M3ActionSection { - if (accountViewModel.account.otsState.hasPendingAttestations(note)) { - M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } - } else { - M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) { - accountViewModel.timestamp(note) - onDismiss() + if (!isPrivateRumor) { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } + } else { + M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) { + accountViewModel.timestamp(note) + onDismiss() + } } } - if (state.isLoggedUser) { + if (state.isLoggedUser && !isPrivateRumor) { if (state.isPinnedNote) { M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.unpin_from_profile)) { accountViewModel.removePin(note) @@ -287,14 +307,22 @@ fun NoteDropDownMenu( } } } - M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) { - addLabelDialogShowing = true + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) { + addLabelDialogShowing = true + } } // Pick exactly one curation flow per kind: music tracks go to playlists, emoji // packs go to the emoji list, everything else gets the standard bookmark rows. // Showing both at once is noisy and makes "bookmark" feel like the catch-all when // it really isn't for these kinds. when { + isPrivateRumor -> { + // No bookmark/playlist/emoji-list rows for private rumors: + // those lists reference the note by id, which other devices + // can't resolve from relays and public lists would leak. + } + note.event is MusicTrackEvent && note is AddressableNote -> { // Music tracks (kind 36787) belong in playlists (kind 34139). The // sheet behind this nav lets the user toggle membership across all of @@ -372,7 +400,7 @@ fun NoteDropDownMenu( } onDismiss() } - if (state.isLoggedUser) { + if (state.isLoggedUser && !isPrivateRumor) { M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) onDismiss() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt new file mode 100644 index 0000000000..15693b2e7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt @@ -0,0 +1,79 @@ +/* + * 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.elements + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * The shared Share rows used by the [ShareOptionsBottomSheet] drawer (opened + * both from the reaction-row Share button and from the note's 3-dot menu). + * + * Only the true "send it somewhere" options live here — browser link, image + * file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so + * they are intentionally NOT part of this shared element. + * + * Callers only render these for non-private notes: every option exposes the + * note publicly (a shareable web link, or an image of it), which must never + * happen for a private gift-wrapped rumor. + */ +@Composable +fun ShareActionRows( + note: Note, + nav: INav, + onDismiss: () -> Unit, +) { + val actContext = LocalContext.current + // AddressableNotes are shared by their replaceable address; everything else + // by event id. The two image routes resolve the note from this same id. + val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex + + M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) + putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) + } + val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + actContext.startActivity(shareIntent) + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { + nav.nav(Route.ShareNoteAsImageFile(shareId)) + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image_url)) { + nav.nav(Route.ShareNoteAsImage(shareId)) + onDismiss() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt new file mode 100644 index 0000000000..f65c26bde4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Bottom drawer that offers the note's Share options ([ShareActionRows]). + * Opened from the reaction-row Share button and from the note's 3-dot menu, + * so both entry points share the exact same list. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareOptionsBottomSheet( + note: Note, + nav: INav, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) { + Text( + text = stringRes(R.string.quick_action_share), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) + M3ActionSection { + ShareActionRows( + note = note, + nav = nav, + onDismiss = onDismiss, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 70c44937c0..3c3973b09e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -80,6 +81,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris @@ -96,6 +98,7 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -134,11 +137,17 @@ open class CommentPostViewModel : IExpiration { val draftTag = DraftTagState() + // Strong reference to the live cache note for the current draft tag (derived from the + // versions flow below), so LocalCache cannot weakly collect it before a deletion needs it. + var draftNote: AddressableNote? = null + private set + init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { // don't save the first if (it > 0) { + draftNote = account.getOrCreateDraftNote(draftTag.current) sendDraftSync() } } @@ -259,6 +268,7 @@ open class CommentPostViewModel : val oldTag = (draft.event as? AddressableEvent)?.dTag() if (oldTag != null) { draftTag.set(oldTag) + draftNote = account.getOrCreateDraftNote(oldTag) } loadFromDraft(innerNote) } @@ -269,9 +279,27 @@ open class CommentPostViewModel : open fun reply(post: Note) { this.replyingTo = post this.externalIdentity = (post.event as? CommentEvent)?.scope() + (post.event as? LnZapEvent)?.let { zap -> + notifying = listOfNotNull(zapSenderToNotify(zap)) + } observeCommunityRules(post) } + /** + * Zap receipts (kind 9735) are signed by the recipient's lightning provider, so + * the reply tags built from the receipt alone would notify the custodian instead + * of the person who zapped. The actual sender is the author of the embedded zap + * request. Requests carrying an `anon` tag (anonymous or private zaps) are signed + * by an ephemeral key: skip those — tagging the throwaway key is useless, and + * tagging the decrypted sender would publicly expose a private zapper. + */ + private fun zapSenderToNotify(zapEvent: LnZapEvent): User? { + val request = zapEvent.zapRequest ?: return null + if (request.hasAnonTag()) return null + if (request.pubKey == account.signer.pubKey) return null + return LocalCache.checkGetOrCreateUser(request.pubKey) + } + /** * Subscribes to the latest [CommunityRulesEvent] when [target] is a NIP-72 * community, and clears the rules state otherwise. Re-evaluates the current @@ -433,6 +461,17 @@ open class CommentPostViewModel : notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + // Replies to zaps notify the zap sender through a plain p tag (the receipt's + // author keys above are the lightning provider). Restore the sender chip only + // if the draft still tags them — its absence means the user removed it. + (replyingTo?.event as? LnZapEvent)?.let { zap -> + zapSenderToNotify(zap)?.let { sender -> + if (draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) { + notifying = (notifying ?: emptyList()) + sender + } + } + } + if (forwardZapTo.value.items.isNotEmpty()) { wantsForwardZapTo = true } @@ -458,7 +497,7 @@ open class CommentPostViewModel : } } - val version = draftTag.current + val draftToDelete = draftNote val anonymous = wantsAnonymousPost cancel() @@ -469,14 +508,14 @@ open class CommentPostViewModel : } accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.account.deleteDraftIgnoreErrors(version) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } suspend fun sendDraftSync() { if (message.text.toString().isBlank()) { - accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) - } else { + accountViewModel.account.deleteDraftIgnoreErrors(draftNote) + } else if (accountViewModel.settings.automaticallyCreateDrafts()) { val attachments = mutableSetOf() nip95attachments.forEach { attachments.add(it.first) @@ -529,6 +568,15 @@ open class CommentPostViewModel : null } } + } else if (replyingToEvent is LnZapEvent) { + val sender = zapSenderToNotify(replyingToEvent) + // notifying starts with the sender; a missing entry means the + // user removed the chip, so respect that and don't tag them. + if (sender != null && notifying?.contains(sender) != false) { + listOf(sender.toPTag()) + } else { + emptyList() + } } else { emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt index 86565a8f56..90c3aad5d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.buildAnnotatedString @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.amethyst.ui.theme.StdHorzSpacer @@ -68,7 +68,7 @@ fun DisplayExternalId( } is UrlId -> { - DisplayUrlExternalId(externalId) + DisplayUrlExternalId(externalId, nav) } else -> { @@ -78,13 +78,15 @@ fun DisplayExternalId( } @Composable -fun DisplayUrlExternalId(externalId: UrlId) { - val uriHandler = LocalUriHandler.current +fun DisplayUrlExternalId( + externalId: UrlId, + nav: INav, +) { DisplayExternalIdChip( symbol = MaterialSymbols.Link, contentDescription = stringRes(id = R.string.external_url_scope), label = externalId.url, - linkInteractionListener = { runCatching { uriHandler.openUri(externalId.url) } }, + linkInteractionListener = { nav.nav(Route.Url(externalId.url)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt new file mode 100644 index 0000000000..9b6fc4a151 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt @@ -0,0 +1,558 @@ +/* + * 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.share + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +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.model.Note +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +/** + * Full-screen flow that turns [id]'s note into a shareable image: it renders the post into a + * framed card, captures it to a bitmap, uploads the PNG to one of the user's Blossom media + * servers, and hands the resulting URL to the Android share sheet. + */ +@Composable +fun ShareNoteAsImageScreen( + id: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadNote(id, accountViewModel) { note -> + if (note != null) { + ShareNoteAsImageScreen(note, accountViewModel, nav) + } + } +} + +/** + * Renders [id]'s note into the same framed card as [ShareNoteAsImageScreen], captures it to a + * bitmap, writes it to the cache as a PNG and hands the local file straight to the Android share + * sheet — no preview, no upload. The screen only shows a brief progress indicator while the card + * is being captured. + */ +@Composable +fun ShareNoteAsImageFileScreen( + id: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadNote(id, accountViewModel) { note -> + if (note != null) { + ShareNoteAsImageFileScreen(note, accountViewModel, nav) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareNoteAsImageFileScreen( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val graphicsLayer = rememberGraphicsLayer() + + // Guards against capturing/sharing more than once across recompositions. + var shared by remember { mutableStateOf(false) } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.share_as_image), nav) }, + ) { pad -> + Box( + modifier = + Modifier + .padding(pad) + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceVariant, + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + // Rendered (but unpainted) only to feed the snapshot; the user never sees this card. + CaptureSource( + note = note, + graphicsLayer = graphicsLayer, + accountViewModel = accountViewModel, + nav = nav, + ) + + GeneratingPreview() + + LaunchedEffect(note) { + // Wait a couple of frames so the card is measured and drawn, then let network + // media settle before the final snapshot. + withFrameNanos {} + withFrameNanos {} + graphicsLayer.toImageBitmap() + delay(IMAGE_SETTLE_MS) + val bitmap = graphicsLayer.toImageBitmap() + + if (!shared) { + shared = true + val uri = + withContext(Dispatchers.IO) { + saveBitmapToCache(context, bitmap.asAndroidBitmap()) + } + startShareImageFileIntent(context, uri) + nav.popBack() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareNoteAsImageScreen( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val graphicsLayer = rememberGraphicsLayer() + val orchestrator = remember { UploadOrchestrator() } + + // The captured bitmap that becomes the preview and, on share, the uploaded file. + var preview by remember { mutableStateOf(null) } + // Once true the off-screen capture source is disposed; [preview] holds the final bitmap. + var captureComplete by remember { mutableStateOf(false) } + var isProcessing by remember { mutableStateOf(false) } + + val fileServers by account.blossomServers.hostNameFlow.collectAsState() + + var selectedServer by remember(fileServers) { + mutableStateOf( + fileServers.firstOrNull { it == account.settings.defaultFileServer } + ?: fileServers.firstOrNull() + ?: DEFAULT_MEDIA_SERVERS[0], + ) + } + + val fileServerOptions = + remember(fileServers) { + fileServers.map { TitleExplainer(it.name, it.baseUrl) }.toImmutableList() + } + + fun shareImage() { + val image = preview ?: return + if (isProcessing) return + isProcessing = true + scope.launch { + val server = selectedServer + val finalState = + withContext(Dispatchers.IO) { + val uri = saveBitmapToCache(context, image.asAndroidBitmap()) + orchestrator.upload( + uri = uri, + mimeType = PNG_MIME, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = context, + stripMetadata = false, + ) + } + + isProcessing = false + + when (finalState) { + is UploadingState.Finished -> { + val result = finalState.result + if (result is UploadOrchestrator.OrchestratorResult.ServerResult) { + account.settings.changeDefaultFileServer(server) + startShareUrlIntent(context, result.url) + nav.popBack() + } else { + accountViewModel.toastManager.toast( + R.string.failed_to_upload_media_no_details, + R.string.server_did_not_provide_a_url_after_uploading, + ) + } + } + + is UploadingState.Error -> { + accountViewModel.toastManager.toast( + R.string.failed_to_upload_media_no_details, + finalState.errorResource, + *finalState.params, + ) + } + + else -> {} + } + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.share_as_image_url), nav) }, + bottomBar = { + ShareBottomBar( + serverName = selectedServer.name, + serverOptions = fileServerOptions, + onSelectServer = { selectedServer = fileServers[it] }, + shareEnabled = preview != null && !isProcessing, + onShare = ::shareImage, + ) + }, + ) { pad -> + Box( + modifier = + Modifier + .padding(pad) + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceVariant, + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + // Keep the note rendered (but unpainted) until the snapshot is final, so async + // images have a chance to load into the layer before the second capture. + if (!captureComplete) { + CaptureSource( + note = note, + graphicsLayer = graphicsLayer, + accountViewModel = accountViewModel, + nav = nav, + ) + LaunchedEffect(note) { + // Wait a couple of frames so the card is measured and drawn, snapshot a + // first preview, then refine once network media has had time to settle. + withFrameNanos {} + withFrameNanos {} + preview = graphicsLayer.toImageBitmap() + delay(IMAGE_SETTLE_MS) + preview = graphicsLayer.toImageBitmap() + captureComplete = true + } + } + + val captured = preview + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (captured != null) { + Image( + bitmap = captured, + contentDescription = stringRes(R.string.share_as_image), + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .widthIn(max = 460.dp) + .fillMaxWidth() + .shadow(18.dp, PreviewShape, clip = false) + .clip(PreviewShape), + ) + } else { + GeneratingPreview() + } + } + + if (isProcessing) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = PreviewShape, + tonalElevation = 6.dp, + ) { + UploadProgressIndicator( + orchestrator, + modifier = Modifier.widthIn(min = 160.dp).padding(horizontal = 24.dp), + ) + } + } + } + } + } +} + +@Composable +private fun GeneratingPreview() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + CircularProgressIndicator() + Text( + text = stringRes(R.string.share_as_image_generating), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareBottomBar( + serverName: String, + serverOptions: ImmutableList, + onSelectServer: (Int) -> Unit, + shareEnabled: Boolean, + onShare: () -> Unit, +) { + Surface(tonalElevation = 3.dp) { + Column( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextSpinner( + label = stringRes(R.string.file_server), + placeholder = serverName, + options = serverOptions, + onSelect = onSelectServer, + modifier = Modifier.fillMaxWidth(), + ) + + Button( + onClick = onShare, + enabled = shareEnabled, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + symbol = MaterialSymbols.Share, + contentDescription = null, + modifier = Size18Modifier, + ) + Spacer(Modifier.width(8.dp)) + Text(stringRes(R.string.quick_action_share)) + } + } + } +} + +/** + * The framed card that is recorded into [graphicsLayer] but not painted to the screen — the + * visible preview is the captured [ImageBitmap] instead. The capture modifier is first in the + * chain so the opaque background and border are part of the bitmap. + */ +@Composable +private fun CaptureSource( + note: Note, + graphicsLayer: GraphicsLayer, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = + Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .drawWithContent { + graphicsLayer.record { this@drawWithContent.drawContent() } + // Intentionally no drawLayer: this source stays invisible and only feeds the + // snapshot; the user sees the resulting Image. + }.clip(PreviewShape) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, PreviewShape) + .padding(14.dp), + ) { + NoteCompose( + baseNote = note, + isQuotedNote = true, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.amethyst), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringRes(R.string.share_as_image_watermark), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private const val PNG_MIME = "image/png" + +// Time given for async media to load into the off-screen card before the final snapshot. +private const val IMAGE_SETTLE_MS = 900L + +private val PreviewShape = RoundedCornerShape(18.dp) + +private fun saveBitmapToCache( + context: Context, + bitmap: Bitmap, +): Uri { + val file = File(context.cacheDir, "amethyst_share_${System.currentTimeMillis()}.png") + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + return FileProvider.getUriForFile(context, "${context.packageName}.provider", file) +} + +private fun startShareUrlIntent( + context: Context, + url: String, +) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.share_as_image_url)) + context.startActivity(shareIntent) +} + +private fun startShareImageFileIntent( + context: Context, + uri: Uri, +) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = PNG_MIME + putExtra(Intent.EXTRA_STREAM, uri) + // Attaching the URI as ClipData (not just EXTRA_STREAM) lets the system share sheet + // itself read the file so it can render the image thumbnail preview at the top. + clipData = ClipData.newUri(context.contentResolver, "Image", uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.share_as_image)) + context.startActivity(shareIntent) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt new file mode 100644 index 0000000000..08a6375273 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt @@ -0,0 +1,188 @@ +/* + * 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.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier + +/** + * Heart color of the Liked icon vector; tints reaction activity cards the way + * bitcoinColor tints the zap ones. + */ +val LikeTint = Color(0xFFCA395F) + +/** + * Gradient card frame shared by the reaction / zap / nutzap / onchain-zap + * renderings, mirroring the onchain card's look: rounded corners and a soft + * top-to-bottom wash of the kind's tint. + * + * The content lambda receives a stable [transparentCardBackground] state to hand + * down to the embedded note and comment: everything inside the card sits on the + * orange (or like-tinted) wash, so inner content must draw transparent and let + * that wash show through. Passing the parent feed / MultiSetCard background here + * instead would paint the inner note black (the app background) or flash it with + * the new-note highlight, breaking the card's solid tint. + */ +@Composable +fun ActivityCardFrame( + tint: Color, + content: @Composable ColumnScope.(transparentCardBackground: MutableState) -> Unit, +) { + val transparentCardBackground = remember { mutableStateOf(Color.Transparent) } + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background( + Brush.linearGradient( + colors = + listOf( + tint.copy(alpha = 0.16f), + tint.copy(alpha = 0.04f), + ), + ), + ).padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + content(transparentCardBackground) + } + } +} + +/** Circular kind badge: a small filled circle of the tint with a white glyph. */ +@Composable +fun ActivityBadge( + tint: Color, + icon: @Composable () -> Unit, +) { + Box( + modifier = + Modifier + .size(24.dp) + .clip(CircleShape) + .background(tint), + contentAlignment = Alignment.Center, + ) { + icon() + } +} + +/** Right-aligned kind label, the static cousin of the onchain card's dashed pill. */ +@Composable +fun ActivityPill( + label: String, + tint: Color, +) { + Box( + modifier = + Modifier + .border(1.4.dp, tint.copy(alpha = 0.7f), RoundedCornerShape(10.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = tint, + fontWeight = FontWeight.Bold, + ) + } +} + +/** First line of the card: badge, sender → recipient avatars, and the kind pill. */ +@Composable +fun ActivityHeaderRow( + tint: Color, + pillLabel: String?, + badge: @Composable () -> Unit, + senderAvatar: @Composable () -> Unit, + recipientAvatar: (@Composable () -> Unit)?, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + badge() + senderAvatar() + recipientAvatar?.let { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos, + contentDescription = null, + tint = tint, + modifier = Size16Modifier, + ) + it() + } + + Spacer(Modifier.weight(1f)) + + pillLabel?.let { ActivityPill(it, tint) } + } +} + +/** Big amount line, matching the onchain card's typography. */ +@Composable +fun ActivityAmountRow( + amount: String, + tint: Color, +) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = amount, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + color = tint, + ) + Text( + text = "sats", + style = MaterialTheme.typography.titleSmall, + color = tint, + modifier = Modifier.padding(bottom = 6.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt new file mode 100644 index 0000000000..8271e033b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt @@ -0,0 +1,225 @@ +/* + * 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.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent + +/** + * Design-time fixtures for the activity cards (reaction / lightning zap / + * cashu nutzap / onchain zap). The notes are built by hand and never consumed + * into [LocalCache], except for the users behind the avatars. + */ +private object ActivityCardPreviewData { + val senderHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val authorHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" + const val SIG = "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + const val TIME = 1_700_000_000L + + val targetId = "a".repeat(64) + + fun targetNote(): Note = + Note(targetId).apply { + event = + TextNoteEvent( + targetId, + authorHex, + TIME, + emptyArray(), + "Just finished the new release — zaps, nests, and a faster feed. What should we build next?", + SIG, + ) + author = LocalCache.getOrCreateUser(authorHex) + replyTo = emptyList() + } + + fun reactionNote(reaction: String): Note { + val id = "b".repeat(64) + return Note(id).apply { + event = + ReactionEvent( + id, + senderHex, + TIME, + arrayOf(arrayOf("e", targetId), arrayOf("p", authorHex)), + reaction, + SIG, + ) + author = LocalCache.getOrCreateUser(senderHex) + replyTo = listOf(targetNote()) + } + } + + fun nutzapNote(): Note { + val id = "c".repeat(64) + return Note(id).apply { + event = + NutzapEvent( + id, + senderHex, + TIME, + arrayOf( + arrayOf("proof", """{"amount":2100,"id":"00ad268c4d1f5826","secret":"s","C":"c"}"""), + arrayOf("u", "https://mint.example.com"), + arrayOf("e", targetId), + arrayOf("p", authorHex), + ), + "Have some cashu, great post!", + SIG, + ) + author = LocalCache.getOrCreateUser(senderHex) + replyTo = listOf(targetNote()) + } + } + + fun lnZapNote(): Note { + val id = "d".repeat(64) + // Receipts are signed by the lightning provider; the preview skips the + // embedded request and feeds the decrypted card to RenderLnZapCard directly. + return Note(id).apply { + event = null + author = null + replyTo = listOf(targetNote()) + } + } + + fun onchainZapNote(): Note { + val id = "e".repeat(64) + return Note(id).apply { + event = + OnchainZapEvent( + id, + senderHex, + TIME, + arrayOf( + arrayOf("i", "bitcoin:tx:" + "ab".repeat(32)), + arrayOf("amount", "150000"), + arrayOf("e", targetId), + arrayOf("p", authorHex), + ), + "Welcome to the timechain!", + SIG, + ) + author = LocalCache.getOrCreateUser(senderHex) + replyTo = listOf(targetNote()) + } + } +} + +@Preview +@Composable +fun LikeActivityCardPreview() { + val accountViewModel = mockAccountViewModel() + + ThemeComparisonColumn { + RenderReaction( + note = ActivityCardPreviewData.reactionNote("+"), + quotesLeft = 1, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} + +@Preview +@Composable +fun EmojiReactionActivityCardPreview() { + val accountViewModel = mockAccountViewModel() + + ThemeComparisonColumn { + RenderReaction( + note = ActivityCardPreviewData.reactionNote("🤙"), + quotesLeft = 1, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} + +@Preview +@Composable +fun LnZapActivityCardPreview() { + val accountViewModel = mockAccountViewModel() + + ThemeComparisonColumn { + RenderLnZapCard( + note = ActivityCardPreviewData.lnZapNote(), + card = + ZapAmountCommentNotification( + user = LocalCache.getOrCreateUser(ActivityCardPreviewData.senderHex), + comment = "Great post! ⚡", + amount = "100k", + ), + recipientKey = ActivityCardPreviewData.authorHex, + quotesLeft = 1, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} + +@Preview +@Composable +fun NutzapActivityCardPreview() { + val accountViewModel = mockAccountViewModel() + + ThemeComparisonColumn { + RenderNutzap( + note = ActivityCardPreviewData.nutzapNote(), + quotesLeft = 1, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} + +@Preview +@Composable +fun OnchainZapActivityCardPreview() { + val accountViewModel = mockAccountViewModel() + + ThemeComparisonColumn { + RenderOnchainZap( + note = ActivityCardPreviewData.onchainZapNote(), + quotesLeft = 1, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 469f6fa9d6..27fa51d3e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -24,25 +24,42 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,35 +70,55 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.LinkIcon +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.kinds.KindNames +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.SupportedNipTag import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun RenderAppDefinition( note: Note, @@ -200,11 +237,14 @@ fun RenderAppDefinition( Spacer(Modifier.weight(1f)) Row( - modifier = - Modifier - .height(Size35dp) - .padding(bottom = 3.dp), - ) {} + // Cancels the surrounding Column's horizontal padding so + // the button's right edge lines up with the banner's. + modifier = Modifier.padding(bottom = 3.dp).offset(x = 10.dp), + ) { + if (accountViewModel.account.isWriteable()) { + RecommendAppButton(noteEvent, note, accountViewModel) + } + } } val name = remember(theAppMetadata) { theAppMetadata.anyName() } @@ -225,6 +265,23 @@ fun RenderAppDefinition( } } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 4.dp), + ) { + ByAuthorChip(noteEvent.pubKey, accountViewModel, nav) + + val client = remember(noteEvent) { noteEvent.client()?.name } + if (!client.isNullOrBlank()) { + Text( + text = stringRes(R.string.app_definition_via, client), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + val website = remember(theAppMetadata) { theAppMetadata.website } if (!website.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -261,7 +318,464 @@ fun RenderAppDefinition( ) } } + + val categories = remember(noteEvent) { noteEvent.categories().distinct() } + if (categories.isNotEmpty()) { + SectionLabel(stringRes(R.string.app_definition_categories)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + categories.forEach { CategoryChip(it) } + } + } + + val relatedAddresses = remember(noteEvent) { noteEvent.relatedAddresses() } + if (relatedAddresses.isNotEmpty()) { + SectionLabel(stringRes(R.string.app_definition_related)) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + relatedAddresses.forEach { aTag -> + RelatedEventRow(aTag, accountViewModel, nav) + } + } + } + + val platforms = remember(noteEvent) { noteEvent.platformLinks().map { it.platform }.distinct() } + if (platforms.isNotEmpty()) { + SectionLabel(stringRes(R.string.app_definition_available_on)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + platforms.forEach { PlatformChip(it) } + } + } + + val supportedKinds = remember(noteEvent) { noteEvent.supportedKinds() } + if (supportedKinds.isNotEmpty()) { + SupportedKindsSection(supportedKinds) + } + + val supportedNips = remember(noteEvent) { noteEvent.supportedNips() } + if (supportedNips.isNotEmpty()) { + SupportedNipsSection(supportedNips) + } } } } } + +private const val VISIBLE_SUPPORTED_NIP_LIMIT = 8 + +/** A section header matching the metrics used by the platforms/handles rows. */ +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) +} + +/** + * NIPs the app declares it supports (NostrHub-style `i` tags). Shows a handful of + * chips inline with a "+N" overflow that opens a bottom sheet listing every NIP, + * each one opening its spec. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SupportedNipsSection(nips: List) { + var showAllSheet by rememberSaveable { mutableStateOf(false) } + val visible = remember(nips) { nips.take(VISIBLE_SUPPORTED_NIP_LIMIT) } + val overflow = (nips.size - VISIBLE_SUPPORTED_NIP_LIMIT).coerceAtLeast(0) + val uri = LocalUriHandler.current + + SectionLabel(stringRes(R.string.app_definition_implements)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp, bottom = 5.dp), + ) { + visible.forEach { nip -> + NipChip(nip.nip) { runCatching { uri.openUri(nip.url) } } + } + if (overflow > 0) { + OverflowChip(overflow) { showAllSheet = true } + } + } + + if (showAllSheet) { + AllNipsSheet(nips = nips, onDismiss = { showAllSheet = false }) + } +} + +/** + * Kinds the app handles. Mirrors [SupportedNipsSection]: a few chips inline with a + * "+N" overflow that opens a bottom sheet listing every handled kind. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SupportedKindsSection(kinds: List) { + var showAllSheet by rememberSaveable { mutableStateOf(false) } + val visible = remember(kinds) { kinds.take(VISIBLE_SUPPORTED_KIND_LIMIT) } + val overflow = (kinds.size - VISIBLE_SUPPORTED_KIND_LIMIT).coerceAtLeast(0) + + SectionLabel(stringRes(R.string.app_definition_handles)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp, bottom = 5.dp), + ) { + visible.forEach { KindChip(it) } + if (overflow > 0) { + OverflowChip(overflow) { showAllSheet = true } + } + } + + if (showAllSheet) { + AllKindsSheet(kinds = kinds, onDismiss = { showAllSheet = false }) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun AllKindsSheet( + kinds: List, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Text( + text = stringRes(R.string.app_definition_handles), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + kinds.forEach { KindChip(it) } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun AllNipsSheet( + nips: List, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val uri = LocalUriHandler.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Text( + text = stringRes(R.string.app_definition_supported_nips), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + nips.forEach { nip -> + NipChip(nip.nip) { + val target = nip.url + scope.launch { + sheetState.hide() + onDismiss() + runCatching { uri.openUri(target) } + } + } + } + } + } +} + +@Composable +private fun NipChip( + nip: String, + onClick: (() -> Unit)? = null, +) { + val shape = RoundedCornerShape(50) + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier, + ) { + Text( + text = stringRes(R.string.app_definition_nip, nip), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun CategoryChip(category: String) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = category, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +/** + * Tappable row for an `a`-tag reference (source repo, store listing, NIP, ...). + * These are vouched for by the handler's author, so we show the referenced event's + * author avatar plus its real name (the app/repo/NIP title rather than the raw + * d-tag), falling back to the d-tag only when the event hasn't loaded yet. + */ +@Composable +private fun RelatedEventRow( + aTag: ATag, + accountViewModel: AccountViewModel, + nav: INav, +) { + val address = remember(aTag) { Address(aTag.kind, aTag.pubKeyHex, aTag.dTag) } + val shape = RoundedCornerShape(8.dp) + + LoadAddressableNote(address, accountViewModel) { note -> + if (note == null) return@LoadAddressableNote + + val noteState by observeNote(note, accountViewModel) + val name = + remember(noteState) { + relatedEventName(noteState.note.event)?.ifBlank { null } ?: aTag.dTag + } + + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .clickable { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + ) { + noteState.note.author?.let { author -> + BaseUserPicture(author, 26.dp, accountViewModel) + Spacer(modifier = Modifier.size(10.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = kindLabel(aTag.kind), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** The human title for a related addressable event, by kind, or null if unknown. */ +private fun relatedEventName(event: Event?): String? = + when (event) { + is SoftwareApplicationEvent -> event.name() + is GitRepositoryEvent -> event.name() + is NipTextEvent -> event.title() + else -> null + } + +/** Short kind label for a related reference (e.g. "App" for a software application). */ +@Composable +private fun kindLabel(kind: Int): String = + if (kind == SoftwareApplicationEvent.KIND) { + stringRes(R.string.app_definition_kind_app) + } else { + val resId = kindDisplayName(kind) + if (resId != -1) stringRes(resId) else (KindNames.nameFor(kind) ?: "k$kind") + } + +private const val VISIBLE_SUPPORTED_KIND_LIMIT = 12 + +/** Same shape and metrics as [KindChip] so it lines up with the kind chips. */ +@Composable +private fun OverflowChip( + count: Int, + onClick: (() -> Unit)? = null, +) { + val shape = RoundedCornerShape(50) + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier, + ) { + Text( + text = "+$count", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun PlatformChip(platform: String) { + val name = + when (platform) { + PlatformType.WEB.code -> stringRes(R.string.platform_web) + PlatformType.ANDROID.code -> stringRes(R.string.platform_android) + PlatformType.IOS.code -> stringRes(R.string.platform_ios) + else -> platform + } + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +/** + * "by " pill identifying the pubkey that published the app + * definition, so users can tell an app from a friend apart from a spammer + * impersonating the real one. The picture carries the following-checkmark + * overlay for people the account follows. Tapping opens the author's profile. + */ +@Composable +fun ByAuthorChip( + authorHex: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { author -> + if (author == null) return@LoadUser + + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .clickable { nav.nav(routeFor(author)) }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp, end = 10.dp, top = 3.dp, bottom = 3.dp), + ) { + Text( + text = stringRes(R.string.app_definition_by), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.size(5.dp)) + BaseUserPicture(author, 18.dp, accountViewModel) + Spacer(modifier = Modifier.size(5.dp)) + ProvideTextStyle(MaterialTheme.typography.labelMedium) { + UsernameDisplay( + baseUser = author, + fontWeight = FontWeight.SemiBold, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + accountViewModel = accountViewModel, + ) + } + } + } + } +} + +/** + * Shows whether the logged-in user publicly recommends this app (NIP-89, + * kind 31989) and toggles the recommendation on tap. + */ +@Composable +private fun RecommendAppButton( + noteEvent: AppDefinitionEvent, + note: Note, + accountViewModel: AccountViewModel, +) { + val myRecommendations by accountViewModel.account.appRecommendations.flow + .collectAsStateWithLifecycle() + + val isRecommended = + remember(myRecommendations, noteEvent) { + val address = noteEvent.addressTag() + myRecommendations.any { event -> + event.recommendationAddresses().any { it == address } + } + } + + val compactHeight = Modifier.height(32.dp) + val compactPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp) + + if (isRecommended) { + OutlinedButton( + onClick = { + accountViewModel.launchSigner { + val account = accountViewModel.account + account.appRecommendations.unrecommendApp(noteEvent.address(), account) + } + }, + modifier = compactHeight, + contentPadding = compactPadding, + ) { + Text(stringRes(R.string.app_definition_recommended), style = MaterialTheme.typography.labelMedium) + } + } else { + Button( + enabled = noteEvent.supportedKinds().isNotEmpty(), + onClick = { + accountViewModel.launchSigner { + val account = accountViewModel.account + account.appRecommendations.recommendApp(noteEvent, note.relayHintUrl(), account) + } + }, + modifier = compactHeight, + contentPadding = compactPadding, + ) { + Text(stringRes(R.string.app_definition_recommend), style = MaterialTheme.typography.labelMedium) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppRecommendation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppRecommendation.kt new file mode 100644 index 0000000000..e819c1b631 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppRecommendation.kt @@ -0,0 +1,98 @@ +/* + * 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.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.AppRecommendationChip +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent + +/** + * Renders a kind 31989 NIP-89 recommendation: the list of apps the author + * recommends for one event kind, as tappable logo+name chips. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderAppRecommendation( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? AppRecommendationEvent ?: return + + val recommendations = remember(noteEvent) { noteEvent.recommendations() } + val targetKind = remember(noteEvent) { noteEvent.dTag().toIntOrNull() } + + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(R.string.profile_app_recommendations_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + targetKind?.let { + Spacer(modifier = Modifier.size(6.dp)) + KindChip(it) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + recommendations.forEach { recommendation -> + LoadAddressableNote(recommendation.address, accountViewModel) { appNote -> + appNote?.let { + if (it.event == null) { + EventFinderFilterAssemblerSubscription(it, accountViewModel) + } + AppRecommendationChip(it, accountViewModel, nav) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt index b1925b38fe..9939227380 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.State import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt index a499e28509..e51612e3a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt @@ -49,11 +49,15 @@ fun RenderChat( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, + isBoostedNote: Boolean = false, ) { val noteEvent = note.event ?: return val eventContent = remember(noteEvent) { noteEvent.content } - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + // A boosted note inside a zap/nutzap/onchain activity card is always shown as a + // compact 2-line preview, even when the logged-in user is only a zap-split + // beneficiary (and thus not the author) of the post being zapped. + if (makeItShort && (isBoostedNote || accountViewModel.isLoggedUser(note.author))) { Text( text = eventContent, color = MaterialTheme.colorScheme.placeholderText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt index 6318b62135..07af5228e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt @@ -31,8 +31,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt index 142dd73f39..c5279ec8b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt @@ -32,8 +32,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 43b310e905..99fb0abf00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,28 +34,36 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepoSnapshotCache +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.ClickableUrl @@ -66,6 +75,13 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryBrowserViewModelFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoExternalNotice +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLanguageBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLastCommit +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoStatTiles +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.computeLanguageBreakdown +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.repoHasFetchableClone import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer @@ -79,15 +95,16 @@ import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.patch.UnifiedDiffParser +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent private val CardShape = QuoteBorder private val ChipShape = RoundedCornerShape(8.dp) -private val CardPadding = PaddingValues(Size10dp) +private val CardPadding = PaddingValues(start = Size10dp, top = Size10dp, end = Size10dp, bottom = Size5dp) private val HeaderSpacing = Arrangement.spacedBy(Size8dp) private val LinkRowSpacing = Arrangement.spacedBy(Size8dp) @@ -141,19 +158,33 @@ private fun TypeChip( text: String, background: Color, contentColor: Color, + symbol: MaterialSymbol? = null, ) { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = contentColor, - maxLines = 1, + Row( modifier = Modifier .clip(ChipShape) .background(background) .padding(horizontal = Size8dp, vertical = 2.dp), - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + symbol?.let { + Icon( + symbol = it, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = contentColor, + ) + } + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = contentColor, + maxLines = 1, + ) + } } @Composable @@ -161,6 +192,7 @@ private fun LinkRow( symbol: MaterialSymbol, contentDescription: String?, url: String, + style: TextStyle = LocalTextStyle.current, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -176,10 +208,124 @@ private fun LinkRow( ClickableUrl( url = url, urlText = url.removePrefix("https://").removePrefix("http://"), + style = style, ) } } +@Composable +private fun GitSubjectTitle(text: String) { + Spacer(modifier = StdVertSpacer) + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + maxLines = 3, + ) +} + +/** + * A small icon + value line for a piece of git metadata such as a branch name, + * commit hash or merge base. [value] is shown verbatim; commit-like hashes are + * expected to be shortened by the caller via [shortCommit]. + */ +@Composable +private fun GitMetaRow( + symbol: MaterialSymbol, + contentDescription: String?, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = LinkRowSpacing, + ) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + modifier = Modifier.size(Size16dp), + tint = MaterialTheme.colorScheme.grayText, + ) + Text( + text = value, + style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP), + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Shortens a 40-char git object id to its 7-char prefix for display. */ +private fun String.shortCommit(): String = if (length > 7) take(7) else this + +/** + * The shared markdown body used by patches, issues and pull requests: honors + * the collapsed [makeItShort] preview for the logged-in user's own posts and + * otherwise renders the full content with sensitivity warnings and uncited + * hashtags. The subject, when present, is rendered separately as a title by the + * caller, so it is not inlined here. + */ +@Composable +private fun GitMarkdownBody( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadDecryptedContent(note, accountViewModel) { body -> + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = body, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = body, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + val event = note.event + if (event?.hasHashtags() == true) { + DisplayUncitedHashtags( + event = event, + content = body, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + @Composable fun RenderGitPatchEvent( baseNote: Note, @@ -278,16 +424,6 @@ private fun RenderGitPatchEvent( ) GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) - - val commit = remember(noteEvent) { noteEvent.commit() } - commit?.takeIf { it.isNotBlank() }?.let { hash -> - Text( - text = hash.take(7), - style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP), - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - ) - } } val repository = remember(noteEvent) { noteEvent.repositoryAddress() } @@ -300,65 +436,37 @@ private fun RenderGitPatchEvent( } } + val commit = remember(noteEvent) { noteEvent.commit()?.takeIf { it.isNotBlank() } } + if (commit != null) { + Spacer(modifier = StdVertSpacer) + GitMetaRow( + symbol = MaterialSymbols.Commit, + contentDescription = stringRes(id = R.string.git_commit), + value = commit.shortCommit(), + ) + } + Spacer(modifier = HalfDoubleVertSpacer) - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } - } - } - - val isAuthorTheLoggedUser = - remember(note.event) { accountViewModel.isLoggedUser(note.author) } - - if (makeItShort && isAuthorTheLoggedUser) { + // In a collapsed feed preview keep the lightweight markdown body; in the + // full (thread) view parse the patch into a proper file-by-file diff. + val parsed = if (makeItShort) null else remember(noteEvent) { UnifiedDiffParser.parse(noteEvent.content) } + if (parsed != null && parsed.hasDiff) { + if (parsed.message.isNotBlank()) { Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + text = parsed.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) - } else { - val callbackUri = remember(note) { note.toNostrUri() } - - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - quotesLeft = quotesLeft, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - id = note.idHex, - callbackUri = callbackUri, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (note.event?.hasHashtags() == true) { - DisplayUncitedHashtags( - event = noteEvent, - content = eventContent, - callbackUri = callbackUri, - accountViewModel = accountViewModel, - nav = nav, - ) - } + Spacer(modifier = HalfDoubleVertSpacer) } + GitDiffView(parsed, Modifier.fillMaxWidth()) + } else { + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + } + + if (!makeItShort) { + GitStatusActions(note, accountViewModel) } } } @@ -412,6 +520,11 @@ private fun RenderGitIssueEvent( GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) } + val subject = remember(noteEvent) { noteEvent.subject()?.takeIf { it.isNotBlank() } } + if (subject != null) { + GitSubjectTitle(subject) + } + val repository = remember(noteEvent) { noteEvent.repositoryAddress() } if (repository != null) { Spacer(modifier = StdVertSpacer) @@ -424,56 +537,238 @@ private fun RenderGitIssueEvent( Spacer(modifier = HalfDoubleVertSpacer) - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } - } + if (!makeItShort) { + GitStatusActions(note, accountViewModel) + } + } +} + +@Composable +fun RenderGitPullRequestEvent( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? GitPullRequestEvent ?: return + + RenderGitPullRequestEvent( + event, + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +private fun RenderGitPullRequestEvent( + noteEvent: GitPullRequestEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + // A later pull-request update (kind 1619) revises this PR with a newer commit / + // merge base. Fold the most recent one in so the card reflects the current state. + val updateIndex by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle() + val update = updateIndex?.get(note.idHex) + + GitCardContainer { + val repository = remember(noteEvent) { noteEvent.repositoryAddress() } + if (repository != null) { + LoadAddressableNote(repository, accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) } + } + Spacer(modifier = StdVertSpacer) + } - val isAuthorTheLoggedUser = - remember(note.event) { accountViewModel.isLoggedUser(note.author) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = HeaderSpacing, + ) { + TypeChip( + text = stringRes(id = R.string.kind_git_pr), + background = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), + contentColor = MaterialTheme.colorScheme.secondary, + symbol = MaterialSymbols.CallMerge, + ) - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + + if (update != null) { + TypeChip( + text = stringRes(id = R.string.git_pr_revised), + background = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + symbol = MaterialSymbols.Sync, ) - } else { - val callbackUri = remember(note) { note.toNostrUri() } + } + } - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val tags = - remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + val subject = remember(noteEvent) { noteEvent.subject()?.takeIf { it.isNotBlank() } } + if (subject != null) { + GitSubjectTitle(subject) + } - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - quotesLeft = quotesLeft, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - id = note.idHex, - callbackUri = callbackUri, - accountViewModel = accountViewModel, - nav = nav, + val branch = remember(noteEvent) { noteEvent.branchName()?.takeIf { it.isNotBlank() } } + val currentCommit = + remember(noteEvent, update) { + (update?.currentCommit() ?: noteEvent.currentCommit())?.takeIf { it.isNotBlank() } + } + val mergeBase = + remember(noteEvent, update) { + (update?.mergeBase() ?: noteEvent.mergeBase())?.takeIf { it.isNotBlank() } + } + val cloneUrls = + remember(noteEvent, update) { + (update?.cloneUrls()?.filter { it.isNotBlank() }?.ifEmpty { null } ?: noteEvent.cloneUrls().filter { it.isNotBlank() }) + } + + if (branch != null || currentCommit != null || mergeBase != null) { + Spacer(modifier = StdVertSpacer) + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + branch?.let { + GitMetaRow(MaterialSymbols.AltRoute, stringRes(id = R.string.git_branch), it) + } + currentCommit?.let { + GitMetaRow(MaterialSymbols.Commit, stringRes(id = R.string.git_commit), it.shortCommit()) + } + mergeBase?.let { + GitMetaRow(MaterialSymbols.CallMerge, stringRes(id = R.string.git_merge_base), it.shortCommit()) + } + } + } + + if (cloneUrls.isNotEmpty()) { + Spacer(modifier = StdVertSpacer) + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + cloneUrls.forEach { url -> + LinkRow( + symbol = MaterialSymbols.CloudDownload, + contentDescription = stringRes(id = R.string.git_clone_address), + url = url, + style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP), ) } + } + } - if (note.event?.hasHashtags() == true) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + Spacer(modifier = HalfDoubleVertSpacer) + + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + + if (!makeItShort) { + GitPullRequestChanges(cloneUrls, currentCommit, mergeBase, accountViewModel) + GitStatusActions(note, accountViewModel) + } + } +} + +@Composable +fun RenderGitPullRequestUpdateEvent( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? GitPullRequestUpdateEvent ?: return + + RenderGitPullRequestUpdateEvent( + event, + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +private fun RenderGitPullRequestUpdateEvent( + noteEvent: GitPullRequestUpdateEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + GitCardContainer { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = HeaderSpacing, + ) { + TypeChip( + text = stringRes(id = R.string.kind_git_pr_update), + background = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + symbol = MaterialSymbols.Sync, + ) + } + + val repository = remember(noteEvent) { noteEvent.repositoryAddress() } + if (repository != null) { + Spacer(modifier = StdVertSpacer) + LoadAddressableNote(repository, accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) + } + } + } + + Spacer(modifier = HalfDoubleVertSpacer) + Text( + text = stringRes(id = R.string.git_pr_update_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + + val currentCommit = remember(noteEvent) { noteEvent.currentCommit()?.takeIf { it.isNotBlank() } } + val mergeBase = remember(noteEvent) { noteEvent.mergeBase()?.takeIf { it.isNotBlank() } } + val cloneUrls = remember(noteEvent) { noteEvent.cloneUrls().filter { it.isNotBlank() } } + + if (currentCommit != null || mergeBase != null) { + Spacer(modifier = StdVertSpacer) + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + currentCommit?.let { + GitMetaRow(MaterialSymbols.Commit, stringRes(id = R.string.git_commit), it.shortCommit()) + } + mergeBase?.let { + GitMetaRow(MaterialSymbols.CallMerge, stringRes(id = R.string.git_merge_base), it.shortCommit()) + } + } + } + + if (cloneUrls.isNotEmpty()) { + Spacer(modifier = StdVertSpacer) + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + cloneUrls.forEach { url -> + LinkRow( + symbol = MaterialSymbols.CloudDownload, + contentDescription = stringRes(id = R.string.git_clone_address), + url = url, + style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP), + ) } } } @@ -500,8 +795,8 @@ private fun RenderGitRepositoryEvent( ) { val title = noteEvent.name() ?: noteEvent.dTag() val summary = noteEvent.description() - val web = noteEvent.web() - val clone = noteEvent.clone() + val topics = remember(noteEvent) { noteEvent.hashtags().filter { it.isNotBlank() } } + val isPersonalFork = remember(noteEvent) { noteEvent.isPersonalFork() } GitCardContainer { Row( @@ -531,6 +826,15 @@ private fun RenderGitRepositoryEvent( overflow = TextOverflow.Ellipsis, ) } + + if (isPersonalFork) { + TypeChip( + text = stringRes(id = R.string.git_repo_personal_fork), + background = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + symbol = MaterialSymbols.AltRoute, + ) + } } summary?.let { @@ -544,24 +848,76 @@ private fun RenderGitRepositoryEvent( ) } - if (web != null || clone != null) { + RepoSnapshotDashboard(noteEvent, note, accountViewModel, nav) + + if (topics.isNotEmpty()) { Spacer(modifier = HalfDoubleVertSpacer) - Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { - web?.let { - LinkRow( - symbol = MaterialSymbols.Public, - contentDescription = stringRes(id = R.string.git_web_address), - url = it, - ) - } - clone?.let { - LinkRow( - symbol = MaterialSymbols.AutoMirrored.OpenInNew, - contentDescription = stringRes(id = R.string.git_clone_address), - url = it, + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + topics.forEach { topic -> + TypeChip( + text = "#$topic", + background = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + contentColor = MaterialTheme.colorScheme.grayText, ) } } } } } + +/** + * Loads a shallow snapshot of the repository over smart-HTTP and renders the same + * stat tiles / language bar / last-commit strip used on the project home, in place of + * the old web/clone links. Fetches lazily (once) when the card is composed. + */ +@Composable +private fun RepoSnapshotDashboard( + noteEvent: GitRepositoryEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val cacheKey = remember(noteEvent) { noteEvent.address().toValue() } + val browser: GitRepositoryBrowserViewModel = + viewModel( + key = note.idHex + "GitRepoCardBrowser", + factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview), + ) + LaunchedEffect(noteEvent) { browser.loadOnce(noteEvent.clones(), cacheKey) } + val browserState by browser.state.collectAsStateWithLifecycle() + // Prefer the live state, but fall back to a cached snapshot so an already-fetched repo + // renders synchronously — including in the share-to-image capture, which won't wait for a + // fresh clone. + val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: remember(cacheKey) { GitRepoSnapshotCache.get(cacheKey) } + if (snapshot == null) { + // Repos we can't clone over http(s) (e.g. Iris's htree://) get an open-in-browser notice + // instead of an indefinitely-empty dashboard. + if (!repoHasFetchableClone(noteEvent)) { + Spacer(modifier = HalfDoubleVertSpacer) + RepoExternalNotice(noteEvent) + } + return + } + + val fileNames = remember(snapshot) { snapshot.walkFileNames() } + val slices = remember(fileNames) { computeLanguageBreakdown(fileNames) } + + Spacer(modifier = HalfDoubleVertSpacer) + RepoStatTiles( + branches = snapshot.branches.size, + tags = snapshot.tags.size, + files = fileNames.size, + updatedEpochSec = snapshot.tipCommit?.authorTimeSec, + ) + if (slices.isNotEmpty()) { + Spacer(modifier = HalfDoubleVertSpacer) + RepoLanguageBar(slices) + } + snapshot.tipCommit?.let { commit -> + Spacer(modifier = HalfDoubleVertSpacer) + RepoLastCommit(commit) { nav.nav(Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt new file mode 100644 index 0000000000..e612e7bbef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt @@ -0,0 +1,330 @@ +/* + * 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.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.saveable.rememberSaveable +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.res.pluralStringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip34Git.ui.CodeHighlighter +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.patch.CharSpan +import com.vitorpamplona.quartz.nip34Git.patch.GitDiffFile +import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLine +import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLineType +import com.vitorpamplona.quartz.nip34Git.patch.GitFileChange +import com.vitorpamplona.quartz.nip34Git.patch.IntralineDiff +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch +import dev.snipme.highlights.model.SyntaxLanguage + +private val AddColor = Color(0xFF1F883D) +private val DeleteColor = Color(0xFFCF222E) +private val DiffFontSize = 12.sp +private val DiffLineHeight = 17.sp + +// Above this many diff lines we skip per-line syntax highlighting to stay snappy. +private const val HIGHLIGHT_LINE_BUDGET = 600 + +/** + * Renders a parsed [ParsedPatch] as a GitHub-style file-by-file diff: a stat + * summary, then one collapsible card per file with +/- line coloring, old/new + * line numbers, and (for reasonably sized diffs) syntax highlighting reused from + * the repository code browser. + */ +@Composable +fun GitDiffView( + parsed: ParsedPatch, + modifier: Modifier = Modifier, +) { + if (!parsed.hasDiff) return + val highlightEnabled = + remember(parsed) { parsed.files.sumOf { f -> f.hunks.sumOf { it.lines.size } } <= HIGHLIGHT_LINE_BUDGET } + + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + DiffStatSummary(parsed) + parsed.files.forEach { file -> + DiffFileCard(file, highlightEnabled) + } + } +} + +@Composable +private fun DiffStatSummary(parsed: ParsedPatch) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + val fileCount = parsed.files.size + Text( + text = pluralStringResource(R.plurals.git_diff_files_changed, fileCount, fileCount), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text("+${parsed.totalAdditions}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = AddColor) + Text("-${parsed.totalDeletions}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = DeleteColor) + } +} + +@Composable +private fun DiffFileCard( + file: GitDiffFile, + highlightEnabled: Boolean, +) { + var expanded by rememberSaveable(file.displayPath) { mutableStateOf(true) } + val language = remember(file.displayPath) { CodeHighlighter.languageForFile(file.displayPath) } + val darkMode = isSystemInDarkTheme() + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface), + ) { + // File header + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val (symbol, tint) = changeBadge(file.change) + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint) + Text( + text = file.displayPath, + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (file.additions > 0) Text("+${file.additions}", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = AddColor) + if (file.deletions > 0) Text("-${file.deletions}", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = DeleteColor) + Icon( + symbol = if (expanded) MaterialSymbols.KeyboardArrowUp else MaterialSymbols.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (expanded) { + if (file.isBinary) { + Text( + text = stringRes(R.string.git_diff_binary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } else { + val gutterWidth = remember(file) { gutterWidthFor(file) } + Column(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + file.hunks.forEach { hunk -> + HunkHeaderRow(hunk.header) + val emphasis = remember(hunk) { IntralineDiff.emphasis(hunk.lines) } + hunk.lines.forEachIndexed { index, line -> + DiffLineRow(line, gutterWidth, language.takeIf { highlightEnabled }, darkMode, emphasis[index]) + } + } + } + } + } + } +} + +@Composable +private fun HunkHeaderRow(header: String) { + Text( + text = header, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.85f), + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +@Composable +private fun DiffLineRow( + line: GitDiffLine, + gutterWidth: Dp, + language: SyntaxLanguage?, + darkMode: Boolean, + emphasis: CharSpan?, +) { + val background = + when (line.type) { + GitDiffLineType.ADD -> AddColor.copy(alpha = 0.12f) + GitDiffLineType.DELETE -> DeleteColor.copy(alpha = 0.12f) + GitDiffLineType.CONTEXT -> Color.Transparent + } + val emphasisColor = + when (line.type) { + GitDiffLineType.ADD -> AddColor.copy(alpha = 0.30f) + GitDiffLineType.DELETE -> DeleteColor.copy(alpha = 0.30f) + GitDiffLineType.CONTEXT -> Color.Transparent + } + val marker = + when (line.type) { + GitDiffLineType.ADD -> "+" + GitDiffLineType.DELETE -> "-" + GitDiffLineType.CONTEXT -> " " + } + val markerColor = + when (line.type) { + GitDiffLineType.ADD -> AddColor + GitDiffLineType.DELETE -> DeleteColor + GitDiffLineType.CONTEXT -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + } + val numberColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) + + val content = + remember(line.content, language, darkMode, emphasis) { + val base = + if (language != null && line.content.isNotEmpty()) { + CodeHighlighter.highlight(line.content, language, darkMode) + } else { + AnnotatedString(line.content) + } + if (emphasis != null && !emphasis.isEmpty) { + buildAnnotatedString { + append(base) + addStyle( + SpanStyle(background = emphasisColor), + emphasis.start.coerceIn(0, line.content.length), + emphasis.end.coerceIn(0, line.content.length), + ) + } + } else { + base + } + } + + Row( + modifier = Modifier.background(background), + verticalAlignment = Alignment.Top, + ) { + LineNumber(line.oldNumber, gutterWidth, numberColor) + LineNumber(line.newNumber, gutterWidth, numberColor) + Text( + text = marker, + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + color = markerColor, + modifier = Modifier.padding(start = 4.dp), + ) + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + softWrap = false, + maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp, end = 12.dp), + ) + } +} + +@Composable +private fun LineNumber( + number: Int?, + width: Dp, + color: Color, +) { + Text( + text = number?.toString() ?: "", + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + color = color, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(width).padding(horizontal = 4.dp), + ) +} + +@Composable +private fun changeBadge(change: GitFileChange): Pair = + when (change) { + GitFileChange.ADD -> MaterialSymbols.AddCircle to AddColor + GitFileChange.DELETE -> MaterialSymbols.Cancel to DeleteColor + GitFileChange.RENAME -> MaterialSymbols.AltRoute to MaterialTheme.colorScheme.primary + GitFileChange.MODIFY -> MaterialSymbols.Edit to MaterialTheme.colorScheme.primary + } + +private fun gutterWidthFor(file: GitDiffFile): Dp { + val maxLine = + file.hunks.maxOfOrNull { hunk -> + hunk.lines.maxOfOrNull { maxOf(it.oldNumber ?: 0, it.newNumber ?: 0) } ?: 0 + } ?: 0 + val digits = maxLine.toString().length.coerceAtLeast(2) + return (digits * 8 + 8).dp +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt new file mode 100644 index 0000000000..6146391da6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -0,0 +1,169 @@ +/* + * 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.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +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.unit.dp +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +private sealed interface ChangesState { + object Idle : ChangesState + + object Loading : ChangesState + + class Loaded( + val patch: ParsedPatch, + ) : ChangesState + + object Failed : ChangesState +} + +/** + * "View changes" section of a pull-request card. A NIP-34 PR references a clone + * URL + commit instead of embedding a patch, so the actual diff is computed on + * demand over the git smart-HTTP client (base = merge base, head = current + * commit) and rendered with the shared [GitDiffView]. + */ +@Composable +fun GitPullRequestChanges( + cloneUrls: List, + headCommit: String?, + mergeBase: String?, + accountViewModel: AccountViewModel, +) { + val candidates = remember(cloneUrls) { candidateUrls(cloneUrls) } + if (candidates.isEmpty() || headCommit.isNullOrBlank()) return + + var state by remember(headCommit, mergeBase) { mutableStateOf(ChangesState.Idle) } + val scope = rememberCoroutineScope() + + fun load() { + state = ChangesState.Loading + scope.launch { + state = + try { + val patch = loadDiff(accountViewModel, candidates, headCommit, mergeBase) + ChangesState.Loaded(patch) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + ChangesState.Failed + } + } + } + + when (val s = state) { + ChangesState.Idle -> + FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { + Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_view_changes), modifier = Modifier.padding(start = 6.dp)) + } + + ChangesState.Loading -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(top = 12.dp), + ) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_loading_changes), style = MaterialTheme.typography.bodySmall) + } + + is ChangesState.Loaded -> + if (s.patch.hasDiff) { + Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + GitDiffView(s.patch, Modifier.fillMaxWidth()) + } + } else { + Text( + text = stringRes(R.string.git_pr_no_changes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + + ChangesState.Failed -> + FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_changes_retry), modifier = Modifier.padding(start = 6.dp)) + } + } +} + +private suspend fun loadDiff( + accountViewModel: AccountViewModel, + candidates: List, + headCommit: String, + mergeBase: String?, +): ParsedPatch = + withContext(Dispatchers.IO) { + val client = GitHttpClient(accountViewModel.httpClientBuilder::okHttpClientForPreview) + var lastError: Exception? = null + for (url in candidates) { + try { + return@withContext client.computeDiff(url, headCommit, mergeBase?.takeIf { it.isNotBlank() }) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + lastError = e + } + } + throw lastError ?: IllegalStateException("no usable clone URL") + } + +private fun candidateUrls(cloneUrls: List): List { + val out = LinkedHashSet() + for (raw in cloneUrls) { + val url = raw.trim() + if (!url.startsWith("http://") && !url.startsWith("https://")) continue + out.add(url) + if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git") + } + return out.toList() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt new file mode 100644 index 0000000000..ea8cc7075a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -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.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +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.model.GitStatusIndex +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent + +private enum class StatusTarget { OPEN, CLOSED, APPLIED } + +/** + * NIP-34 status controls for an issue, patch or pull request. Visible only to the + * people allowed to moderate the thread — the item's author and the repository + * owner / maintainers. Patches and PRs additionally offer "Mark merged" + * ([GitStatusAppliedEvent]); everything can be closed/reopened. [GitStatusIndex] + * reflects the published status everywhere. + */ +@Composable +fun GitStatusActions( + note: Note, + accountViewModel: AccountViewModel, +) { + val event = note.event ?: return + val repoAddress = repositoryAddressOf(event) ?: return + val isPatchOrPr = event is GitPatchEvent || event is GitPullRequestEvent + + val canModerate = remember(event, note) { canModerate(repoAddress, note, accountViewModel) } + if (!canModerate) return + + val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() + if (index == null) return + val current = index?.get(note.idHex) + val closedOrApplied = current is GitStatusClosedEvent || current is GitStatusAppliedEvent + + FlowRow( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (closedOrApplied) { + FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }) { + Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_status_reopen), modifier = Modifier.padding(start = 6.dp)) + } + } else { + if (isPatchOrPr) { + FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }) { + Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_status_mark_merged), modifier = Modifier.padding(start = 6.dp)) + } + } + OutlinedButton( + onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_status_close), modifier = Modifier.padding(start = 6.dp)) + } + } + } +} + +private fun repositoryAddressOf(event: Event): Address? = + when (event) { + is GitIssueEvent -> event.repositoryAddress() + is GitPatchEvent -> event.repositoryAddress() + is GitPullRequestEvent -> event.repositoryAddress() + else -> null + } + +private fun canModerate( + repoAddress: Address, + note: Note, + accountViewModel: AccountViewModel, +): Boolean { + val myHex = accountViewModel.account.signer.pubKey + if (accountViewModel.isLoggedUser(note.author)) return true + if (myHex == repoAddress.pubKeyHex) return true + val repoEvent = LocalCache.getAddressableNoteIfExists(repoAddress)?.event as? GitRepositoryEvent + return repoEvent?.maintainers()?.contains(myHex) == true +} + +private fun sendStatus( + accountViewModel: AccountViewModel, + note: Note, + target: StatusTarget, +) { + val hint = note.toEventHint() ?: return + accountViewModel.launchSigner { + val template = + when (target) { + StatusTarget.OPEN -> GitStatusOpenEvent.build("", hint) + StatusTarget.CLOSED -> GitStatusClosedEvent.build("", hint) + StatusTarget.APPLIED -> GitStatusAppliedEvent.build("", hint) + } + val signed = accountViewModel.account.signer.sign(template) + accountViewModel.account.sendAutomatic(signed) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt index 2425dfd6fc..443ca59ad1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -74,7 +73,6 @@ fun GitStatusPill( modifier: Modifier = Modifier, defaultIfMissing: StatusKind? = null, ) { - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index ef181c81ae..dedef008de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -39,11 +39,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.DisplayEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt index 44104f1ec8..c4d3bbea77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.State import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index 1cea089e40..29ccadab7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -146,6 +146,9 @@ fun MusicPlaylistHeader( val trackAddresses = remember(noteEvent) { noteEvent.trackAddresses() } val isCollaborative = remember(noteEvent) { noteEvent.isCollaborative() } val isPrivate = remember(noteEvent) { noteEvent.isPrivate() } + // The composer can only resolve and edit a playlist the logged-in user authored (it loads the + // addressable from their own pubkey), so only surface the edit affordance for owned playlists. + val isOwnPlaylist = remember(note) { accountViewModel.isLoggedUser(note.author) } Column(MaterialTheme.colorScheme.replyModifier) { MusicPlaylistCover(image, note, trackAddresses.size, accountViewModel) @@ -168,7 +171,10 @@ fun MusicPlaylistHeader( ) } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { Icon( symbol = MaterialSymbols.AutoMirrored.PlaylistAdd, contentDescription = null, @@ -188,6 +194,20 @@ fun MusicPlaylistHeader( if (isPrivate) { PlaylistTag(text = stringRes(R.string.music_playlist_private)) } + if (isOwnPlaylist) { + Spacer(Modifier.weight(1f)) + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = stringRes(R.string.music_playlist_edit_action), + tint = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clip(CircleShape) + .clickable { nav.nav(Route.NewMusicPlaylist(dTag = noteEvent.dTag())) } + .padding(4.dp) + .size(20.dp), + ) + } } shortDescription?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt new file mode 100644 index 0000000000..1fcf052518 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.hashtags.Cashu +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.claimedSatsTotal +import java.math.BigDecimal + +/** + * Renders a NIP-61 nutzap (kind 9321) as an activity card, like lightning zaps. + * Unlike kind 9735 receipts, the nutzap is signed by the sender, so [Note.author] + * is already the right person to attribute. + */ +@Composable +fun RenderNutzap( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val nutzapEvent = note.event as? NutzapEvent ?: return + + val recipientKey = nutzapEvent.linkedPubKeys().firstOrNull() + val orange = MaterialTheme.colorScheme.bitcoinColor + + ActivityCardFrame(orange) { cardBackground -> + ActivityHeaderRow( + tint = orange, + pillLabel = "CASHU", + badge = { + ActivityBadge(orange) { + Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + } + }, + senderAvatar = { + val sender = note.author + if (sender != null) { + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + } else { + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + recipientKey?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, + ) + + RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + + ActivityAmountRow(showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), orange) + + nutzapEvent.content.ifBlank { null }?.let { + CrossfadeToDisplayComment(it, cardBackground, nav, accountViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt index a4b6075330..7cc8a1a22c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt @@ -90,6 +90,7 @@ private const val MEMPOOL_TX_URL = "https://mempool.space/tx/" @Composable fun RenderOnchainZap( note: Note, + quotesLeft: Int, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, @@ -97,12 +98,19 @@ fun RenderOnchainZap( val event = note.event as? OnchainZapEvent ?: return val sender = note.author?.pubkeyHex ?: event.pubKey val recipient = event.recipient() ?: return + val sats = event.claimedAmountInSats() ?: 0L val txid = event.txid() val message = event.content.takeIf { it.isNotBlank() } val orange = MaterialTheme.colorScheme.bitcoinColor + // The embedded note sits on the orange wash, so it must draw transparent and + // let that show through. Handing it the parent feed / MultiSetCard background + // instead would paint it black (the app background) or flash it with the + // new-note highlight, breaking the card's solid tint. + val cardBackground = remember { mutableStateOf(Color.Transparent) } + // Async chain lookup so we can show a real "Confirmed at block N" or // "In mempool…" pill rather than a sender-claimed status. Null = backend // not configured, or fetch failed — we just show the sender-claimed sats @@ -141,6 +149,8 @@ fun RenderOnchainZap( nav = nav, ) + RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + AmountRow(sats = sats, orange = orange) ConfirmationPill(tx = tx, hasTxid = txid != null, orange = orange) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 6b7d0896af..0bcc0df398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -116,10 +116,14 @@ fun RenderPoll( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, + isBoostedNote: Boolean = false, ) { val noteEvent = note.event as? PollEvent ?: return - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + // A boosted note inside a zap/nutzap/onchain activity card is always shown as a + // compact 2-line preview, even when the logged-in user is only a zap-split + // beneficiary (and thus not the author) of the post being zapped. + if (makeItShort && (isBoostedNote || accountViewModel.isLoggedUser(note.author))) { Text( text = noteEvent.content, color = MaterialTheme.colorScheme.placeholderText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt index 73b05b19c8..4fa6b422fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt @@ -55,11 +55,15 @@ fun RenderPublicMessage( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, + isBoostedNote: Boolean = false, ) { val noteEvent = note.event as? PublicMessageEvent ?: return val content = remember(noteEvent) { noteEvent.peopleAndContent() } - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + // A boosted note inside a zap/nutzap/onchain activity card is always shown as a + // compact 2-line preview, even when the logged-in user is only a zap-split + // beneficiary (and thus not the author) of the post being zapped. + if (makeItShort && (isBoostedNote || accountViewModel.isLoggedUser(note.author))) { Text( text = content, color = MaterialTheme.colorScheme.placeholderText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt index f29e023151..6772cc4130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -20,16 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.LikedIcon +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.note.RenderReaction as RenderReactionEmoji +/** + * Renders a kind 7 reaction as an activity card, like the zap kinds, tinted + * with the Liked heart color: badge + reactor → author on the first line and + * the reacted-to post quoted inside the card. + */ @Composable fun RenderReaction( note: Note, @@ -38,24 +48,35 @@ fun RenderReaction( accountViewModel: AccountViewModel, nav: INav, ) { - note.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = ReplyRenderType.NONE, - quotesLeft = quotesLeft - 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, + val reactionType = note.event?.content ?: "" + + ActivityCardFrame(LikeTint) { cardBackground -> + ActivityHeaderRow( + tint = LikeTint, + pillLabel = "REACTION", + badge = { + if (reactionType == "+" || reactionType.isBlank()) { + ActivityBadge(LikeTint) { + LikedIcon(Modifier.size(16.dp), Color.White) + } + } else { + RenderReactionEmoji(reactionType) + } + }, + senderAvatar = { + val sender = note.author + if (sender != null) { + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + } else { + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + note.replyTo?.lastOrNull()?.author?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, ) + + RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) } - - // Reposts have trash in their contents. - val refactorReactionText = if (note.event?.content == "+") "❤" else note.event?.content ?: "" - - Text( - text = refactorReactionText, - maxLines = 1, - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt new file mode 100644 index 0000000000..3594b98919 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -0,0 +1,301 @@ +/* + * 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.types + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Emoji marker for a road event category. Mirrors the icon set of the roadstr + * reference clients () so a report reads the + * same across implementations; keeps the card icon-light and locale-independent. + */ +private fun RoadEventType.emoji(): String = + when (this) { + RoadEventType.POLICE -> "👮" + RoadEventType.SPEED_CAMERA -> "📷" + RoadEventType.TRAFFIC_JAM -> "🚗" + RoadEventType.ACCIDENT -> "💥" + RoadEventType.ROAD_CLOSURE -> "🚫" + RoadEventType.CONSTRUCTION -> "🚧" + RoadEventType.HAZARD -> "⚠️" + RoadEventType.ROAD_CONDITION -> "🛣️" + RoadEventType.POTHOLE -> "🕳️" + RoadEventType.FOG -> "🌫️" + RoadEventType.ICE -> "🧊" + RoadEventType.ANIMAL -> "🦌" + RoadEventType.OTHER -> "ℹ️" + } + +/** + * Per-category pin color. Uses the exact palette from the roadstr reference + * clients () so a pin's color carries the + * same meaning across the network. + */ +private fun RoadEventType.color(): Color = + when (this) { + RoadEventType.POLICE -> Color(0xFF0000FF) + RoadEventType.SPEED_CAMERA -> Color(0xFF800080) + RoadEventType.TRAFFIC_JAM -> Color(0xFFFF8C00) + RoadEventType.ACCIDENT -> Color(0xFFFF0000) + RoadEventType.ROAD_CLOSURE -> Color(0xFF8B0000) + RoadEventType.CONSTRUCTION -> Color(0xFFFFD700) + RoadEventType.HAZARD -> Color(0xFFFF4500) + RoadEventType.ROAD_CONDITION -> Color(0xFF4682B4) + RoadEventType.POTHOLE -> Color(0xFF795548) + RoadEventType.FOG -> Color(0xFF9E9E9E) + RoadEventType.ICE -> Color(0xFF00CED1) + RoadEventType.ANIMAL -> Color(0xFF4CAF50) + RoadEventType.OTHER -> Color(0xFF808080) + } + +private fun RoadEventType.labelRes(): Int = + when (this) { + RoadEventType.POLICE -> R.string.road_event_police + RoadEventType.SPEED_CAMERA -> R.string.road_event_speed_camera + RoadEventType.TRAFFIC_JAM -> R.string.road_event_traffic_jam + RoadEventType.ACCIDENT -> R.string.road_event_accident + RoadEventType.ROAD_CLOSURE -> R.string.road_event_road_closure + RoadEventType.CONSTRUCTION -> R.string.road_event_construction + RoadEventType.HAZARD -> R.string.road_event_hazard + RoadEventType.ROAD_CONDITION -> R.string.road_event_road_condition + RoadEventType.POTHOLE -> R.string.road_event_pothole + RoadEventType.FOG -> R.string.road_event_fog + RoadEventType.ICE -> R.string.road_event_ice + RoadEventType.ANIMAL -> R.string.road_event_animal + RoadEventType.OTHER -> R.string.road_event_other + } + +/** + * Self-contained card for a Roadstr road event report (kind 1315). + * + * A map hero pinned at the event's coordinates, with a floating category pill + * (colored per [RoadEventType.color]) and the optional free-text comment below. + * Identical in the feed and the opened thread view, so it takes no `makeItShort` + * flag — mirroring [RenderBirdex]. + */ +@Composable +fun RenderRoadEventReport(baseNote: Note) { + val noteEvent = baseNote.event as? RoadEventReportEvent ?: return + + val type = remember(noteEvent) { noteEvent.roadEventType() } + val comment = remember(noteEvent) { noteEvent.content.trim() } + val point = remember(noteEvent) { noteEvent.roadEventPoint() } + val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() } + + val color = type?.color() ?: MaterialTheme.colorScheme.outline + val emoji = type?.emoji() ?: "📍" + val label = if (type != null) stringResource(type.labelRes()) else stringResource(R.string.road_event_unknown) + + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = label, + point = point, + pinAlpha = freshness, + comment = comment, + ) +} + +/** + * Self-contained card for a Roadstr confirmation/denial (kind 1316). + * + * Same hero/pill layout as a report, with a green ✅ "Still there" or red ❌ + * "No longer there" pill and a one-line description of the action. + */ +@Composable +fun RenderRoadEventConfirmation(baseNote: Note) { + val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return + + val status = remember(noteEvent) { noteEvent.status() } + val point = remember(noteEvent) { noteEvent.roadEventPoint() } + + val denied = status == RoadEventStatus.NO_LONGER_THERE + val emoji = if (denied) "❌" else "✅" + val color = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50) + + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = stringResource(if (denied) R.string.road_event_denied else R.string.road_event_confirmed), + point = point, + subtitle = stringResource(if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report), + ) +} + +/** + * Shared road event card chrome: a full-bleed [LocationPreviewMap] hero with a + * floating [CategoryPill] overlaid on its top-start, and an optional + * [subtitle] + [comment] block below. When [point] is null the pill stands on + * its own so the card still reads. + */ +@Composable +private fun RoadEventCard( + chipColor: Color, + chipEmoji: String, + chipLabel: String, + point: Pair?, + pinAlpha: Float = 1f, + comment: String = "", + subtitle: String? = null, +) { + Column(MaterialTheme.colorScheme.replyModifier) { + if (point != null) { + Box(Modifier.fillMaxWidth()) { + LocationPreviewMap( + latitude = point.first, + longitude = point.second, + pinColor = chipColor, + pinEmoji = chipEmoji, + pinAlpha = pinAlpha, + ) + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.align(Alignment.TopStart).padding(10.dp), + ) + } + } else { + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.padding(12.dp), + ) + } + + if (subtitle != null || comment.isNotEmpty()) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + if (comment.isNotEmpty()) { + if (subtitle != null) Spacer(Modifier.height(4.dp)) + Text( + text = comment, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** A floating rounded pill: [emoji] + [label] on a [color] background, text auto-contrasted. */ +@Composable +private fun CategoryPill( + color: Color, + emoji: String, + label: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = color, + contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White, + shape = RoundedCornerShape(50), + shadowElevation = 3.dp, + ) { + Text( + text = "$emoji $label", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } +} + +/** + * Best available coordinate for the card map: the event's explicit `lat`/`lon` + * tags when present, otherwise the center of the finest published geohash. + * Returns null when the event carries no location at all. + */ +private fun RoadEventReportEvent.roadEventPoint(): Pair? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun RoadEventConfirmationEvent.roadEventPoint(): Pair? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun resolveRoadEventPoint( + latitude: Double?, + longitude: Double?, + geohashes: List, +): Pair? { + if (latitude != null && longitude != null) return latitude to longitude + + val finest = geohashes.maxByOrNull { it.length } ?: return null + val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null + return decoded.centerLat to decoded.centerLon +} + +/** + * Marker opacity by freshness, matching the roadstr clients: full while the + * report has plenty of life left, dimmed to 0.6 once under 25% of its effective + * TTL remains, and faded to 0.4 once effectively expired. Returns 1f when the + * type (and therefore the TTL) is unknown. + */ +private fun RoadEventReportEvent.freshnessAlpha(now: Long = TimeUtils.now()): Float { + val expiryAt = effectiveExpirationAt() ?: return 1f + val total = expiryAt - createdAt + if (total <= 0L) return 1f + + val fraction = (expiryAt - now).toFloat() / total + return when { + fraction <= 0f -> 0.4f + fraction < 0.25f -> 0.6f + else -> 1f + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 2dd2480dfc..059fa68747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -28,8 +28,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -41,6 +43,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.produceState @@ -54,25 +57,35 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent -import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.LinkIcon +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent @@ -80,9 +93,14 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.AppIdTag +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map /** * NIP-82 kind 32267 — compact feed card. Renders icon, name, latest version @@ -103,8 +121,7 @@ fun RenderSoftwareApplication( val name = remember(event) { event.name() ?: event.appId().orEmpty() } val summary = remember(event) { event.summary() } val description = remember(event) { event.content.trim() } - val platforms = remember(event) { event.platforms() } - val license = remember(event) { event.license() } + val images = remember(event) { event.images() } val latestVersion by produceLatestReleaseVersion(event) @@ -133,6 +150,7 @@ fun RenderSoftwareApplication( overflow = TextOverflow.Ellipsis, ) } + AppAuthorLine(note, accountViewModel, nav) summary?.takeIf { it.isNotBlank() }?.let { Text( text = it, @@ -148,6 +166,14 @@ fun RenderSoftwareApplication( Spacer(Modifier.width(8.dp)) VersionChip(version) } + + Spacer(Modifier.width(4.dp)) + MoreOptionsButton( + baseNote = note, + editState = null, + accountViewModel = accountViewModel, + nav = nav, + ) } if (description.isNotBlank()) { @@ -160,9 +186,9 @@ fun RenderSoftwareApplication( ) } - if (platforms.isNotEmpty() || license != null) { + if (images.isNotEmpty()) { Spacer(StdVertSpacer) - PlatformLicenseRow(platforms = platforms, license = license) + ScreenshotsStrip(images, accountViewModel, imageHeight = 200.dp) } } @@ -177,52 +203,79 @@ fun RenderSoftwareApplication( } /** - * Looks up the latest NIP-82 [SoftwareReleaseEvent] for [app] from - * [LocalCache] and exposes the version string. Recomputes on event identity - * change; relay-driven recompositions of the surrounding feed will pick up - * newer releases via re-keying. + * Latest NIP-82 [SoftwareReleaseEvent] version for [app], kept live. + * + * Releases (kind 30063) are separate events that point back to the app via an + * `i` tag rather than an `a` tag, so they are never indexed as replies to the + * app note and never ping its flows. Instead we register an index-driven + * [LocalCache.observeNotes] observer, so a newer release arriving while the + * card is visible updates the version chip without a manual refresh. + * + * The filter narrows on the release's `i` tag (the app id), not just the + * author, so the observer only ever loads *this* app's releases — an author + * with many apps would otherwise pull every release they ever published into + * the observer's working set. A blind `limit` is deliberately avoided: + * [LocalCache.filter] applies `take(limit)` before sorting by `created_at`, + * so it could drop the very release we are looking for. */ @Composable -fun produceLatestReleaseVersion(app: SoftwareApplicationEvent) = - produceState(initialValue = null, key1 = app.id) { - value = - withContext(Dispatchers.Default) { - findLatestNip82Release(app)?.version() - } - } - -fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? { - val prefix = "${app.dTag()}@" - val notes = - LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr -> - val ev = addr.event ?: return@filterIntoSet false - ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix) +fun produceLatestReleaseVersion(app: SoftwareApplicationEvent): State { + val flow = + remember(app.pubKey, app.id) { + val filter = + Filter( + kinds = listOf(SoftwareReleaseEvent.KIND), + authors = listOf(app.pubKey), + tags = mapOf(AppIdTag.TAG_NAME to listOf(app.appId())), + ) + LocalCache + .observeNotes(filter) + .map { notes -> latestNip82Release(notes, app)?.version() } + .distinctUntilChanged() + .flowOn(Dispatchers.Default) } - return notes - .mapNotNull { - when (val ev = it.event) { - is SoftwareReleaseEvent -> ev - null -> null - else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null - } - }.maxByOrNull { it.createdAt } + return flow.collectAsStateWithLifecycle(initialValue = null) } +fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? = latestNip82Release(nip82ReleaseNotesFor(app), app) + +/** Picks the newest NIP-82 release for [app] out of an already-narrowed [notes] collection. */ +private fun latestNip82Release( + notes: Collection, + app: SoftwareApplicationEvent, +): SoftwareReleaseEvent? { + val prefix = "${app.dTag()}@" + return notes + .mapNotNull { it.asNip82ReleaseFor(prefix) } + .maxByOrNull { it.createdAt } +} + +/** kind-30063 addressables authored by [app] whose `d` tag is `@`. */ +private fun nip82ReleaseNotesFor(app: SoftwareApplicationEvent): Set { + val prefix = "${app.dTag()}@" + return LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr -> + val ev = addr.event ?: return@filterIntoSet false + ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix) + } +} + +/** + * Re-reads this note as the NIP-82 release for [prefix] (`@`). kind 30063 + * collides with NIP-51 `ReleaseArtifactSetEvent`, which is what `EventFactory` + * builds, so we re-parse matching tag arrays as the NIP-82 form. + */ +private fun Note.asNip82ReleaseFor(prefix: String): SoftwareReleaseEvent? = + when (val ev = event) { + null -> null + is SoftwareReleaseEvent -> ev.takeIf { it.dTag().startsWith(prefix) } + else -> if (ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)) ev.asSoftwareRelease() else null + } + fun findAllNip82Releases(app: SoftwareApplicationEvent): List { val prefix = "${app.dTag()}@" - val notes = - LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr -> - val ev = addr.event ?: return@filterIntoSet false - ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix) - } - return notes - .mapNotNull { - when (val ev = it.event) { - is SoftwareReleaseEvent -> ev - null -> null - else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null - } - }.sortedByDescending { it.createdAt } + return nip82ReleaseNotesFor(app) + .mapNotNull { it.asNip82ReleaseFor(prefix) } + .sortedByDescending { it.createdAt } } @Composable @@ -231,12 +284,24 @@ fun AppIcon( name: String, sizeDp: Int = 56, ) { + val shape = RoundedCornerShape((sizeDp / 4).dp) Box( Modifier .size(sizeDp.dp) - .clip(RoundedCornerShape((sizeDp / 4).dp)) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape((sizeDp / 4).dp)), + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, shape), + contentAlignment = Alignment.Center, ) { + // Fallback underneath the image: visible when there is no icon url, + // while the icon downloads, and when the download fails (AsyncImage + // draws nothing in those states). + Text( + text = (name.firstOrNull() ?: '?').uppercase(), + fontSize = (sizeDp / 2).sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.grayText, + ) icon?.let { AsyncImage( model = it, @@ -248,6 +313,29 @@ fun AppIcon( } } +/** + * "by " line with a small clickable profile picture. Shared between + * the app feed card and the app detail screen header. + */ +@Composable +fun AppAuthorLine( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(R.string.nip82_by_author), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + Spacer(Modifier.width(4.dp)) + NoteAuthorPicture(note, Size20dp, accountViewModel = accountViewModel, nav = nav) + Spacer(Modifier.width(4.dp)) + NoteUsernameDisplay(note, Modifier.weight(1f, fill = false), accountViewModel = accountViewModel) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun PlatformLicenseRow( @@ -284,23 +372,44 @@ fun TopicChipFlow( } @Composable -fun ScreenshotsStrip(images: List) { +fun ScreenshotsStrip( + images: List, + accountViewModel: AccountViewModel, + contentPadding: PaddingValues = PaddingValues(0.dp), + imageHeight: Dp = 180.dp, +) { if (images.isEmpty()) return + + val mediaContents = + remember(images) { + images.map { MediaUrlImage(url = it) }.toImmutableList() + } + LazyRow( horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.height(180.dp), + contentPadding = contentPadding, + modifier = Modifier.height(imageHeight), ) { - items(images) { imageUrl -> - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .height(180.dp) - .clip(RoundedCornerShape(8.dp)) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)), - ) + items(mediaContents) { content -> + // Fixed-height tile whose width follows the image's aspect ratio + // once it is known; assumes a portrait phone screenshot before the + // first load fills the ratio cache. + val ratio = MediaAspectRatioCache.get(content.url) ?: (9f / 16f) + Box( + Modifier + .height(imageHeight) + .aspectRatio(ratio) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)), + ) { + ZoomableContentView( + content = content, + images = mediaContents, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt index c3f120226c..027fdd9247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -20,34 +20,87 @@ */ package com.vitorpamplona.amethyst.ui.note.types -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.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.ui.note.StaticWebsiteCard +import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.napplet.NappletLauncher +import com.vitorpamplona.amethyst.napplethost.HostProfile +import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteToggleButton import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun RenderRootNappletEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? RootNappletEvent ?: return + val context = LocalContext.current + + PrefetchManifestBlobs(event.paths(), event.servers()) + + StaticWebsiteCard( + title = event.title(), + description = event.description(), + source = event.source(), + servers = event.servers(), + identifier = null, + isNapplet = true, + requires = event.requires(), + icon = event.icon(), + // Tapping Open hands off to the sandboxed :napplet process; the card itself never executes code. + onOpen = + if (event.paths().isNotEmpty()) { + { NappletLauncher.launch(context = context, manifest = event, authorPubKey = event.pubKey, identifier = "") } + } else { + null + }, + headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: "Napplet", event.icon()) }, + ) +} + +@Composable +fun RenderNamedNappletEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? NamedNappletEvent ?: return + val context = LocalContext.current + + PrefetchManifestBlobs(event.paths(), event.servers()) + + StaticWebsiteCard( + title = event.title(), + description = event.description(), + source = event.source(), + servers = event.servers(), + identifier = event.identifier(), + isNapplet = true, + requires = event.requires(), + icon = event.icon(), + onOpen = + if (event.paths().isNotEmpty()) { + { NappletLauncher.launch(context = context, manifest = event, authorPubKey = event.pubKey, identifier = event.identifier()) } + } else { + null + }, + headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: event.identifier(), event.icon()) }, + ) +} @Composable fun RenderRootSiteEvent( @@ -56,13 +109,37 @@ fun RenderRootSiteEvent( nav: INav, ) { val event = baseNote.event as? RootSiteEvent ?: return + val context = LocalContext.current - RenderStaticWebsite( + PrefetchManifestBlobs(event.paths(), event.servers()) + + StaticWebsiteCard( title = event.title(), description = event.description(), source = event.source(), servers = event.servers(), identifier = null, + isNapplet = false, + icon = event.icon(), + onOpen = + if (event.paths().isNotEmpty()) { + { + NappletLauncher.launch( + context = context, + paths = event.paths(), + servers = event.servers(), + authorPubKey = event.pubKey, + identifier = "", + aggregateHash = null, + title = event.title() ?: "nsite", + requires = emptyList(), + profile = HostProfile.WEBSITE, + ) + } + } else { + null + }, + headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: "nsite", event.icon()) }, ) } @@ -73,91 +150,58 @@ fun RenderNamedSiteEvent( nav: INav, ) { val event = baseNote.event as? NamedSiteEvent ?: return + val context = LocalContext.current - RenderStaticWebsite( + PrefetchManifestBlobs(event.paths(), event.servers()) + + StaticWebsiteCard( title = event.title(), description = event.description(), source = event.source(), servers = event.servers(), identifier = event.identifier(), + isNapplet = false, + icon = event.icon(), + onOpen = + if (event.paths().isNotEmpty()) { + { + NappletLauncher.launch( + context = context, + paths = event.paths(), + servers = event.servers(), + authorPubKey = event.pubKey, + identifier = event.identifier(), + aggregateHash = null, + title = event.title() ?: event.identifier(), + requires = emptyList(), + profile = HostProfile.WEBSITE, + ) + } + } else { + null + }, + headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: event.identifier(), event.icon()) }, ) } +/** + * While a static-site / napplet card is on screen, eagerly download + verify all of its blobs into the + * shared content-addressed cache (Tor-routed), so tapping Open launches instantly. De-duplicated and + * cancellation-aware — it stops when the card scrolls out of composition. + */ @Composable -private fun RenderStaticWebsite( - title: String?, - description: String?, - source: String?, +private fun PrefetchManifestBlobs( + paths: List, servers: List, - identifier: String?, ) { - Row( - modifier = - Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ).padding(Size10dp), - ) { - Column { - val displayTitle = - title - ?: identifier - ?: stringRes(id = R.string.nsite_root_site) - - Text( - text = stringRes(id = R.string.nsite_title, displayTitle), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - description?.let { - Text( - text = it, - modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - HorizontalDivider(thickness = DividerThickness) - - source?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.nsite_source), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( - url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), - ) - } - } - - if (servers.isNotEmpty()) { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.nsite_servers), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - Column { - servers.forEach { server -> - ClickableUrl( - url = server, - urlText = server.removePrefix("https://").removePrefix("http://"), - ) - } - } - } + val context = LocalContext.current + LaunchedEffect(paths, servers) { + // Off the main thread: context.cacheDir touches disk (ensurePrivateCacheDirExists), so reading + // it on the composition dispatcher trips StrictMode. The prefetch itself is already IO-bound. + withContext(Dispatchers.IO) { + runCatching { + val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + NappletBlobPrefetcher.prefetch(paths, servers, context.cacheDir, torPort) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 4ca9f66c3e..2ec8bc7109 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -35,10 +35,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -57,6 +58,7 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip73ExternalIds.scope @@ -77,10 +79,15 @@ fun RenderTextEvent( editState: State>, accountViewModel: AccountViewModel, nav: INav, + isBoostedNote: Boolean = false, ) { val noteEvent = note.event ?: return if (unPackReply != ReplyRenderType.NONE) { + // Eagerly pull the rest of this reply's thread while it's on screen, so opening + // the conversation finds it already loaded. No-op when the note is a root itself. + PreloadThreadForReply(note, accountViewModel) + val canShowReply by remember(note) { derivedStateOf { @@ -115,7 +122,15 @@ fun RenderTextEvent( } ReplyRenderType.LINE -> { - val parentAuthor = replyingDirectlyTo.author + // Zap receipts are signed by the recipient's lightning provider; + // label the reply with the zap sender instead of the service key. + val zapSender = + if (replyingDirectlyTo.event is LnZapEvent) { + observeZapSender(replyingDirectlyTo, accountViewModel).value + } else { + null + } + val parentAuthor = zapSender ?: replyingDirectlyTo.author if (parentAuthor != null) { ReplyToLabel( parentAuthorDisplay = parentAuthor.toBestDisplayName(), @@ -169,7 +184,10 @@ fun RenderTextEvent( } } - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + // A boosted note inside a zap/nutzap/onchain activity card is always shown as a + // compact 2-line preview, even when the logged-in user is only a zap-split + // beneficiary (and thus not the author) of the post being zapped. + if (makeItShort && (isBoostedNote || accountViewModel.isLoggedUser(note.author))) { Text( text = eventContent, color = MaterialTheme.colorScheme.placeholderText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt index e7772e9c02..7e2cfb5c0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -45,10 +45,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.EditPostView -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt index 59dc332ae1..3253c87f31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index 55bcc1968e..cf61583c5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -45,11 +45,11 @@ import androidx.compose.ui.unit.dp 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.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt index 004c007e58..9c7ce8efe5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt @@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -35,6 +37,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache @@ -43,6 +46,8 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapIcon @@ -60,9 +65,37 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +/** + * Shows the post a zap targets above the transfer card, mirroring how + * reactions and reposts embed their target. + */ +@Composable +fun RenderZappedPost( + zapNote: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + zapNote.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + makeItShort = true, + unPackReply = ReplyRenderType.NONE, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + @Composable fun RenderLnZap( note: Note, + quotesLeft: Int, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, @@ -71,18 +104,76 @@ fun RenderLnZap( val card by parseAuthorCommentAndAmount(note, accountViewModel) - val destinationKey = zapEvent.zappedAuthor().firstOrNull() ?: return - - TransferCard( - card, - destinationKey, - backgroundColor, - Modifier, - accountViewModel, - nav, + RenderLnZapCard( + note = note, + card = card, + recipientKey = zapEvent.zappedAuthor().firstOrNull(), + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) } +@Composable +fun RenderLnZapCard( + note: Note, + card: ZapAmountCommentNotification, + recipientKey: String?, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val orange = MaterialTheme.colorScheme.bitcoinColor + + ActivityCardFrame(orange) { cardBackground -> + ActivityHeaderRow( + tint = orange, + pillLabel = "LIGHTNING", + badge = { + ActivityBadge(orange) { + ZapIcon(Modifier.size(18.dp), Color.White) + } + }, + senderAvatar = { + val sender = card.user + if (sender != null) { + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + } else { + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + recipientKey?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, + ) + + RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + + card.amount?.let { ActivityAmountRow(it, orange) } + + card.comment?.let { + CrossfadeToDisplayComment(it, cardBackground, nav, accountViewModel) + } + } +} + +/** + * Resolves the sender of a zap receipt — the author of the embedded kind 9734 + * request, decrypted when it is a private zap — since the receipt itself is + * signed by the recipient's lightning provider, not by the sender. + */ +@Composable +fun observeZapSender( + zapNote: Note, + accountViewModel: AccountViewModel, +): State = + produceState(initialValue = null, key1 = zapNote) { + value = accountViewModel.innerDecryptAmountMessage(zapNote)?.user + } + @Composable private fun parseAuthorCommentAndAmount( zapEventNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt index 48847aa61b..d99aa11976 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt @@ -60,6 +60,7 @@ fun RenderZapPoll( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, + isBoostedNote: Boolean = false, ) { val noteEvent = note.event as? ZapPollEvent ?: return val eventContent = noteEvent.content @@ -92,7 +93,10 @@ fun RenderZapPoll( } } - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + // A boosted note inside a zap/nutzap/onchain activity card is always shown as a + // compact 2-line preview, even when the logged-in user is only a zap-split + // beneficiary (and thus not the author) of the post being zapped. + if (makeItShort && (isBoostedNote || accountViewModel.isLoggedUser(note.author))) { Text( text = eventContent, color = MaterialTheme.colorScheme.placeholderText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index e4db4c00a6..bf63ace422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -175,9 +175,11 @@ class AccountSessionManager( } } - localPreferences.setDefaultAccount(accountSettings) + // setDefaultAccount may keep an existing signing account instead of this one when a + // read-only npub is added for a pubkey we already sign for — show whichever became current. + val current = localPreferences.setDefaultAccount(accountSettings) - startUI(accountSettings) + startUI(current) } fun startUI( @@ -386,12 +388,14 @@ class AccountSessionManager( // log off and relogin with the 0 account localPreferences.deleteAccount(accountInfo) accountsCache.removeAccount(hex) + accountsCache.deleteAccountFiles(hex) Amethyst.instance.scheduledPostStore.removeForAccount(hex) loginWithDefaultAccount() } else { // delete without switching logins localPreferences.deleteAccount(accountInfo) accountsCache.removeAccount(hex) + accountsCache.deleteAccountFiles(hex) Amethyst.instance.scheduledPostStore.removeForAccount(hex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 0b764e9e89..27f15114a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -20,112 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen -import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner +import androidx.compose.runtime.key import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.ui.components.getActivity +import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreOwner /** - * Creates a new scope for the given ViewModel type. + * Provides a [androidx.lifecycle.ViewModelStoreOwner] scoped to the currently logged-in account so + * that every ViewModel created under it (the [com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel] + * and all of its children) lives and dies with that account. + * + * The owner is keyed by the account's public key via [key]. While the same account stays logged in, + * the call site is stable, so the owner — and therefore the ViewModels — survives recompositions and + * configuration changes (it is parented to the Activity's [LocalViewModelStoreOwner]). When the user + * switches accounts the public key changes, the previous owner leaves the composition, and Lifecycle + * 2.11's [rememberViewModelStoreOwner] clears its [androidx.lifecycle.ViewModelStore] immediately — + * tearing down the old account's ViewModels instead of leaking them. + * + * This replaces a hand-rolled per-account ViewModelStore registry that could not clear stores around + * configuration changes (see git history). */ @Composable fun SetAccountCentricViewModelStore( state: AccountState.LoggedIn, content: @Composable () -> Unit, ) { - val activity = LocalContext.current.getActivity() - val vmStore: StoreOwnerRegistry = viewModel(viewModelStoreOwner = activity) - vmStore.checkAttached(activity) - - val owner = vmStore.getOwner(state) - - val observer = remember { CompositionObserver(vmStore, state) } - - CompositionLocalProvider( - LocalViewModelStoreOwner provides owner, - content = content, - ) -} - -/** - * This class is responsible for notifying the [StoreOwnerRegistry] when a composable is detached so - * that the viewmodel can be cleared. - */ -class CompositionObserver( - private val vmStore: StoreOwnerRegistry, - private val key: Any, -) : RememberObserver { - override fun onRemembered() { - // No action needed when remembered — registration happens at construction time. - } - - override fun onForgotten() = vmStore.composableDetached(key) - - override fun onAbandoned() = vmStore.composableDetached(key) -} - -/** - * Registry for [ViewModelStoreOwner]s that are scoped to a particular composition. - * This ViewModel is registered with the Activity's lifecycle and will clear the viewmodels. - */ -class StoreOwnerRegistry : ViewModel() { - private var isActivityRegistered: Boolean = false - private var isChangingConfigurations: Boolean = false - private val map = mutableMapOf() - - override fun onCleared() { - map.values.forEach { it.viewModelStore.clear() } - super.onCleared() - } - - fun getOwner(key: Any): ViewModelStoreOwner = map[key] ?: ScopedViewModelStoreOwner().also { map[key] = it } - - fun composableDetached(key: Any) { - // TODO: This prevents the viewmodel from being cleared when the Composable is detached due - // to a configuration change. We need to make sure that the viewmodel is cleared when the - // Composition is recreated without the Composable. E.g. by observing the Composition - if (isChangingConfigurations) return - map.remove(key)?.also { owner -> owner.viewModelStore.clear() } - } - - fun checkAttached(activity: ComponentActivity) { - if (!isActivityRegistered) { - isActivityRegistered = true - activity.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - isChangingConfigurations = false - } - - override fun onStop(owner: LifecycleOwner) { - if (activity.isChangingConfigurations) { - isChangingConfigurations = true - } - } - - override fun onDestroy(owner: LifecycleOwner) { - isActivityRegistered = false - owner.lifecycle.removeObserver(this) - } - }, - ) - } + key(state.account.signer.pubKey) { + val owner = rememberViewModelStoreOwner() + CompositionLocalProvider( + LocalViewModelStoreOwner provides owner, + content = content, + ) } } - -/** - * Simple ViewModelStoreOwner that can be used to create a new scope. - */ -class ScopedViewModelStoreOwner : ViewModelStoreOwner { - override val viewModelStore: ViewModelStore = ViewModelStore() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 3089f9ad8f..29bfa64725 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop @@ -133,7 +134,10 @@ fun RenderFeedState( when (state) { is FeedState.Empty -> onEmpty() is FeedState.FeedError -> onError(state.errorMessage) - is FeedState.Loaded -> onLoaded(state) + is FeedState.Loaded -> { + PrefetchLoadedFeedMedia(state, listState, accountViewModel) + onLoaded(state) + } is FeedState.Loading -> onLoading() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 666c1e153b..9e05c1d6a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -82,6 +82,14 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_global), ) + // Notifications-only curated mode; in Notifications, Global itself shows + // every event that p-tags the user. + val selectedFollow = + FeedDefinition( + code = TopFilter.Selected, + name = ResourceName(R.string.follow_list_curated), + ) + val aroundMe = FeedDefinition( code = TopFilter.AroundMe, @@ -108,6 +116,8 @@ class TopNavFilterState( val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) + val defaultNotificationLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow, muteListFollow) + fun mergePeopleLists( peopleLists: List, followLists: List, @@ -282,6 +292,42 @@ class TopNavFilterState( ) } + private val _musicRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the + // music + playlists screens can show only the user's own published items. + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + + private val _gitRepositoryRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Git repository announcements can be narrowed by author, hashtag and geohash, + // so this mirrors the kind3 catalog plus "Mine" — the user's own repositories. + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -294,6 +340,34 @@ class TopNavFilterState( ) } + // Author-only catalog for browse screens whose events carry no topical tags (no `t`/`g`/`a`), + // e.g. the nApplet (NIP-5D) and nSite (NIP-5A) manifests. These can only be narrowed by author, + // so the interest entries (hashtags, geohashes, communities, relays, favorites, interest sets) + // and AroundMe — none of which can match a tag-less manifest — are deliberately left out. + private val _authorOnlyRoutes = + livePeopleListsFlow.transform { peopleLists -> + checkNotInMainThread() + emit( + listOf( + listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow), + peopleLists, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + + private val _notificationLists = + livePeopleListsFlow.transform { peopleLists -> + checkNotInMainThread() + emit( + listOf( + listOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow), + peopleLists, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes .flowOn(Dispatchers.IO) @@ -304,6 +378,16 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, defaultLists) + val authorOnlyRoutes = + _authorOnlyRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow)) + + val notificationLists = + _notificationLists + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, defaultNotificationLists) + val badgeRoutes = _badgeRoutes .flowOn(Dispatchers.IO) @@ -314,6 +398,16 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow)) + val musicRoutes = + _musicRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + + val gitRepositoryRoutes = + _gitRepositoryRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 1d0edc75ea..29bf1319b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -30,13 +30,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index b72c382605..50cdfd204e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import android.content.ComponentCallbacks2 +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -45,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.D import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.dal.BrowseEmojiSetsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.dal.FollowPacksFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal.GitRepositoriesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeEverythingFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter @@ -70,8 +73,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal.SoftwareAppsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch class AccountFeedContentStates( @@ -106,6 +111,8 @@ class AccountFeedContentStates( val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache) val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) + val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache) + val gitRepositoriesFeed = FeedContentState(GitRepositoriesFeedFilter(account), scope, LocalCache) val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache) val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) @@ -136,6 +143,16 @@ class AccountFeedContentStates( val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache) init { + // Under critical memory pressure, trim every feed down to 50 items to release + // the strong Note references that would otherwise keep pruned cache objects alive. + scope.launch(Dispatchers.IO) { + Amethyst.instance.trimLevelEvents.collect { level -> + if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + trimFeedsToSize(200) + } + } + } + // Marmot group list changes (new group, group marked known, group // metadata synced) don't flow through LocalCache.newEventBundles, so // the additive update path can't see them. Force a full feed rebuild @@ -148,6 +165,17 @@ class AccountFeedContentStates( } } + // Pinning/unpinning a room only changes sort order, not membership, so no + // chat event flows through LocalCache. Force a rebuild to re-sort. This + // also fires when pins arrive via the synced AppSpecificData event. + scope.launch(Dispatchers.IO) { + account.settings.syncedSettings.chats.pinnedChatrooms + .drop(1) + .collect { + dmKnown.invalidateData() + } + } + scope.launch(Dispatchers.IO) { account.hiddenUsers.flow.collect { dmKnown.invalidateData() @@ -201,6 +229,8 @@ class AccountFeedContentStates( communitiesList.updateFeedWith(newNotes) picturesFeed.updateFeedWith(newNotes) + workoutsFeed.updateFeedWith(newNotes) + gitRepositoriesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) publicChatsFeed.updateFeedWith(newNotes) @@ -261,6 +291,8 @@ class AccountFeedContentStates( communitiesList.deleteFromFeed(newNotes) picturesFeed.deleteFromFeed(newNotes) + workoutsFeed.deleteFromFeed(newNotes) + gitRepositoriesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) shortsFeed.deleteFromFeed(newNotes) publicChatsFeed.deleteFromFeed(newNotes) @@ -290,6 +322,59 @@ class AccountFeedContentStates( webBookmarks.deleteFromFeed(newNotes) } + fun trimFeedsToSize(maxItems: Int) { + homeNewThreads.trimToSize(maxItems) + homeReplies.trimToSize(maxItems) + homeEverything.trimToSize(maxItems) + + dmKnown.trimToSize(maxItems) + dmNew.trimToSize(maxItems) + + videoFeed.trimToSize(maxItems) + + discoverFollowSets.trimToSize(maxItems) + discoverReads.trimToSize(maxItems) + discoverMarketplace.trimToSize(maxItems) + discoverDVMs.trimToSize(maxItems) + discoverLive.trimToSize(maxItems) + discoverCommunities.trimToSize(maxItems) + discoverPublicChats.trimToSize(maxItems) + + pollsFeed.trimToSize(maxItems) + openPollsFeed.trimToSize(maxItems) + closedPollsFeed.trimToSize(maxItems) + + badgesFeed.trimToSize(maxItems) + browseEmojiSetsFeed.trimToSize(maxItems) + communitiesList.trimToSize(maxItems) + + picturesFeed.trimToSize(maxItems) + workoutsFeed.trimToSize(maxItems) + gitRepositoriesFeed.trimToSize(maxItems) + calendarAppointmentsFeed.trimToSize(maxItems) + calendarCollectionsFeed.trimToSize(maxItems) + productsFeed.trimToSize(maxItems) + shortsFeed.trimToSize(maxItems) + publicChatsFeed.trimToSize(maxItems) + followPacksFeed.trimToSize(maxItems) + liveStreamsFeed.trimToSize(maxItems) + nestsFeed.trimToSize(maxItems) + longsFeed.trimToSize(maxItems) + articlesFeed.trimToSize(maxItems) + musicTracksFeed.trimToSize(maxItems) + musicPlaylistsFeed.trimToSize(maxItems) + podcastEpisodesFeed.trimToSize(maxItems) + podcastsFeed.trimToSize(maxItems) + softwareAppsFeed.trimToSize(maxItems) + + notifications.trimToSize(maxItems) + notificationsFollowing.trimToSize(maxItems) + notificationsEveryone.trimToSize(maxItems) + + drafts.trimToSize(maxItems) + webBookmarks.trimToSize(maxItems) + } + fun destroy() { notifications.destroy() notificationsFollowing.destroy() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 246beb2fc0..d4c7090b08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSelectionDrag import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -90,9 +91,14 @@ fun AccountSwitcherAndLeftDrawerLayout( dest.hasRoute() || dest.hasRoute() } ?: false val drawerGesturesEnabled = - !isTabPagerRoute || - nav.drawerState.isOpen || - nav.drawerState.targetValue != nav.drawerState.currentValue + ( + !isTabPagerRoute || + nav.drawerState.isOpen || + nav.drawerState.targetValue != nav.drawerState.currentValue + ) && + // Suspend the left-edge swipe while a selection/caret handle is dragged over an embedded surface, + // so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer. + !EmbeddedSelectionDrag.dragging ModalNavigationDrawer( drawerState = nav.drawerState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 956d8e8f97..c4efef0e6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.annotation.SuppressLint +import android.app.NotificationManager import android.content.Context import android.os.Handler import android.os.Looper @@ -30,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.rememberCoroutineScope +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -37,15 +39,17 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache @@ -62,17 +66,19 @@ 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 import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator 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.components.UrlPreviewState +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification @@ -86,6 +92,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 @@ -97,6 +105,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus @@ -130,7 +139,6 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel -import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response @@ -146,6 +154,7 @@ import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip92IMeta.imeta import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -259,63 +268,75 @@ class AccountViewModel( .set(callManager, this) } + /** + * Every known relay worth crawling for a full-history sweep (Event Sync, + * Cashu wallet discovery): relays that have connected at least once or were + * never tried, ordered busiest-first so the most fruitful are queried first. + */ + fun crawlRelayDb(): List { + val stats = Amethyst.instance.relayStats.snapshot() + + val relays = + account.cache.relayHints.relayDB + .keys() + .filter { url -> + val relayStat = stats[url] + // has connected at least once OR never tried. + if (relayStat != null) { + relayStat.connectionCompleted > 0 || relayStat.connectionTentatives == 0 + } else { + true + } + } + + val sortMap = relays.associateWith { stats.get(it)?.receivedBytes } + + return relays.sortedByDescending { sortMap[it] } + } + + /** + * A fresh, relay-authenticated [INostrClient] whose received events do NOT + * land in the production [com.vitorpamplona.amethyst.model.LocalCache] — for + * crawls (Event Sync, Cashu wallet discovery). The caller must `close()` it. + */ + fun buildCrawlClient(): INostrClient { + // Create a new scope that inherits the ViewModel's lifecycle + // but uses a SupervisorJob so child failures are independent. + val customScope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob()) + + // Provides a relay pool + val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope) + + // Authenticates with relays (registers itself with the client). + RelayAuthenticator( + newClient, + customScope, + signWithAllLoggedInUsers = { _, authTemplate -> + if (account.signer.isWriteable()) { + try { + listOf(account.signer.sign(authTemplate)) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + emptyList() + } + } else { + emptyList() + } + }, + ) + + return newClient + } + val eventSync = EventSync( accountPubKey = account.signer.pubKey, - relayDb = { - val stats = Amethyst.instance.relayStats.snapshot() - - val relays = - account.cache.relayHints.relayDB - .keys() - .filter { url -> - val relayStat = stats[url] - // has connected at least once OR never tried. - if (relayStat != null) { - relayStat.connectionCompleted > 0 || relayStat.connectionTentatives == 0 - } else { - true - } - } - - val sortMap = relays.associateWith { stats.get(it)?.receivedBytes } - - relays.sortedByDescending { sortMap[it] } - }, + relayDb = { crawlRelayDb() }, outboxTargets = { account.nip65RelayList.outboxFlow.value }, inboxTargets = { account.nip65RelayList.inboxFlow.value }, dmTargets = { account.dmRelayList.flow.value }, - clientBuilder = { - // creates a new client to make sure these events don't end up polluting the local cache. - - // Create a new scope that inherits the ViewModel's lifecycle - // but uses a SupervisorJob so child failures are independent. - val customScope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob()) - - // Provides a relay pool - val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope) - - // Authenticates with relays. - val auth = - RelayAuthenticator( - newClient, - customScope, - signWithAllLoggedInUsers = { authTemplate -> - if (account.signer.isWriteable()) { - try { - listOf(account.signer.sign(authTemplate)) - } catch (e: Exception) { - Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) - emptyList() - } - } else { - emptyList() - } - }, - ) - - newClient - }, + // creates a new client to make sure these events don't end up polluting the local cache. + clientBuilder = { buildCrawlClient() }, scope = viewModelScope, ) @@ -486,9 +507,18 @@ class AccountViewModel( launchSigner { val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { - account.delete(currentReactions) + // Gift-wrapped reactions are retracted with a gift-wrapped + // deletion to the same participants — a public NIP-09 would + // e-tag the private rumor id onto public relays. + val (privateRumors, publicReactions) = currentReactions.partition { it.isPrivateRumor() } + if (publicReactions.isNotEmpty()) { + account.delete(publicReactions) + } + if (privateRumors.isNotEmpty()) { + account.deletePrivately(privateRumors, note) + } } else { - if (settings.useTrackedBroadcasts() && note.event !is NIP17Group) { + if (settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> broadcastTracker.trackBroadcast( @@ -704,6 +734,7 @@ class AccountViewModel( ?.content ?.ifBlank { null }, showAmountInteger((it.response.event as? LnZapEvent)?.amount), + it.response, ) }.toMutableMap() @@ -719,7 +750,7 @@ class AccountViewModel( } } - results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info.copy(zapNote = decrypted.zapEvent) } onNewState(initialResults.values.toImmutableList()) } @@ -736,6 +767,7 @@ class AccountViewModel( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, cachedPrivateRequest.content.ifBlank { null }, showAmountInteger((it.response.event as? LnZapEvent)?.amount), + it.response, ) } else { ZapAmountCommentNotification( @@ -744,6 +776,7 @@ class AccountViewModel( ?.content ?.ifBlank { null }, showAmountInteger((it.response.event as? LnZapEvent)?.amount), + it.response, ) } } else { @@ -753,6 +786,7 @@ class AccountViewModel( ?.content ?.ifBlank { null }, showAmountInteger((it.response.event as? LnZapEvent)?.amount), + it.response, ) } }.toImmutableList() @@ -770,6 +804,7 @@ class AccountViewModel( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, cachedPrivateRequest.content.ifBlank { null }, showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + it.second, ) } else { ZapAmountCommentNotification( @@ -778,6 +813,7 @@ class AccountViewModel( ?.content ?.ifBlank { null }, showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + it.second, ) } } else { @@ -787,6 +823,7 @@ class AccountViewModel( ?.content ?.ifBlank { null }, showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + it.second, ) } }.toImmutableList() @@ -809,6 +846,7 @@ class AccountViewModel( ?.content ?.ifBlank { null }, showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + it.second, ) }.toMutableMap() @@ -827,7 +865,7 @@ class AccountViewModel( } } - decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info.copy(zapNote = decrypted.zapEvent) } onNewState(initialResults.values.toImmutableList()) } @@ -839,7 +877,7 @@ class AccountViewModel( onNewState: (ZapAmountCommentNotification?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)) + onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)?.copy(zapNote = zapEvent)) } } @@ -847,7 +885,7 @@ class AccountViewModel( val zapEvent = zapNote.event as? LnZapEvent ?: return null val zapRequest = zapEvent.zapRequest ?: return null - return innerDecryptAmountMessage(zapRequest, zapEvent) + return innerDecryptAmountMessage(zapRequest, zapEvent)?.copy(zapNote = zapNote) } suspend fun innerDecryptAmountMessage( @@ -900,6 +938,18 @@ class AccountViewModel( onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, ) = launchSigner { + val requestedType = zapType ?: defaultZapType() + + // Zaps on private rumors are forced to PRIVATE so the sender and + // comment stay encrypted. NONZAP is kept: paying without a zap + // request produces no receipt at all, which is even more private. + val effectiveType = + if (note.isPrivateRumor() && requestedType != LnZapEvent.ZapType.NONZAP) { + LnZapEvent.ZapType.PRIVATE + } else { + requestedType + } + ZapPaymentHandler(account).zap( note = note, amountMilliSats = amountInMillisats, @@ -911,7 +961,7 @@ class AccountViewModel( onError = onError, onProgress = onProgress, onPayViaIntent = onPayViaIntent, - zapType = zapType ?: defaultZapType(), + zapType = effectiveType, ) } @@ -929,6 +979,16 @@ class AccountViewModel( onError: (String, String, User?) -> Unit, onProgress: (Float) -> Unit = {}, ) = launchSigner { + // Nutzap events (kind 9321) are public and e-tag the zapped note — + // on a private rumor that would leak the rumor id to public relays. + if (baseNote.isPrivateRumor()) { + onError( + stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), + stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_private_note), + baseNote.author, + ) + return@launchSigner + } val recipient = baseNote.author?.pubkeyHex if (recipient == null) { onError( @@ -963,13 +1023,44 @@ class AccountViewModel( } catch (e: Exception) { onError( stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), - com.vitorpamplona.amethyst.model.nip60Cashu - .describeMintError(e), + describeMintError(e), baseNote.author, ) } } + /** + * NIP-61 nutzap aimed at a profile rather than an event: the kind:9321 + * carries only the `p` tag. Used by the profile Send Payment screen, which + * needs explicit success/error callbacks to drive its in-screen feedback. + */ + fun sendNutzapToUser( + recipientPubKey: HexKey, + amountSats: Long, + message: String, + onError: (String, String, User?) -> Unit, + onProgress: (Float) -> Unit = {}, + onSuccess: () -> Unit = {}, + ) = launchSigner { + try { + account.cashuWalletState.sendNutzap( + amountSats = amountSats, + recipientPubKey = recipientPubKey, + zappedEvent = null, + message = message, + onProgress = onProgress, + ) + onSuccess() + } catch (e: Exception) { + if (e is CancellationException) throw e + onError( + stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), + describeMintError(e), + getUserIfExists(recipientPubKey), + ) + } + } + fun report( note: Note, type: ReportType, @@ -1068,6 +1159,18 @@ class AccountViewModel( direct = { account.removeBookmark(note, false) }, ) + /** Stars/unstars a git repository in the user's NIP-51 kind 10018 list. */ + fun toggleRepositoryBookmark( + note: AddressableNote, + isBookmarked: Boolean, + ) = launchSigner { + if (isBookmarked) { + account.removeGitRepositoryBookmark(note) + } else { + account.addGitRepositoryBookmark(note) + } + } + /** NIP-32: tags [note] with [hashtag] by publishing a kind 1985 label event. */ fun labelWithHashtag( note: Note, @@ -1094,6 +1197,17 @@ class AccountViewModel( fun broadcast(note: Note) = launchSigner { account.broadcast(note) } + /** + * Broadcast republishes public events directly and rumors as their + * delivering kind-1059 wrap. A rumor whose wrap is unknown can't be + * broadcast at all — publishing the unsigned event would disclose the + * private content. + */ + fun canBroadcast(note: Note): Boolean { + val event = note.event ?: return false + return event.sig.isNotEmpty() || note.rumorHost != null + } + fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } fun delete(notes: List) = launchSigner { account.delete(notes) } @@ -1309,6 +1423,20 @@ class AccountViewModel( account.changeVideoPlayerButtonItems(items) } + fun audioVisualizerFlow(): StateFlow = account.settings.syncedSettings.media.audioVisualizer + + fun changeAudioVisualizer(style: VisualizerStyle) = + launchSigner { + account.changeAudioVisualizer(style) + } + + fun pinnedChatroomsFlow(): StateFlow> = account.settings.syncedSettings.chats.pinnedChatrooms + + fun toggleChatroomPin(room: ChatroomKey) = + launchSigner { + account.toggleChatroomPin(room) + } + fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, @@ -1507,6 +1635,7 @@ class AccountViewModel( fun loadAndMarkAsRead( routeForLastRead: String, createdAt: Long?, + dismissNotificationId: HexKey? = null, ): Boolean { if (createdAt == null) return false @@ -1517,12 +1646,21 @@ class AccountViewModel( if (onIsNew) { viewModelScope.launch(Dispatchers.IO) { account.markAsRead(routeForLastRead, createdAt) + // The user is now looking at this event in-app, so clear any tray + // notification that was posted for it while the app was backgrounded. + dismissNotificationId?.let { dismissTrayNotificationFor(it) } } } return onIsNew } + private fun dismissTrayNotificationFor(eventId: HexKey) { + ContextCompat + .getSystemService(Amethyst.instance.appContext, NotificationManager::class.java) + ?.dismissNotificationForEvent(eventId) + } + fun markAllChatNotesAsRead(notes: List) { viewModelScope.launch(Dispatchers.IO) { for (note in notes) { @@ -1619,6 +1757,11 @@ class AccountViewModel( replyToInnerEventId: HexKey? = null, replyToInnerAuthorPubKey: HexKey? = null, ) { + // Rewrites @npub…/@nprofile… mentions into nostr: URIs and collects + // the referenced users as p-tags. Lives here (not in the composer) so + // every send path gets mention handling. + val tagger = NewMessageTagger(text, null, null, this) + tagger.run() // Inner event construction lives on MarmotManager so CLI and UI don't drift. // persistOwn=false because Account.sendMarmotGroupMessage routes the outer // event through LocalCache which already handles own-message display. @@ -1626,10 +1769,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) @@ -1640,7 +1784,6 @@ class AccountViewModel( nostrGroupId: String, url: String, imeta: com.vitorpamplona.quartz.nip92IMeta.IMetaTag, - caption: String? = null, ) { val template = eventTemplate( @@ -1648,9 +1791,6 @@ class AccountViewModel( description = url, ) { imeta(imeta) - if (!caption.isNullOrEmpty()) { - alt(caption) - } } // MIP-03: inner events MUST remain unsigned (no `sig`) so a leaked // plaintext can't be replayed as a valid public kind:9. Authorship @@ -2019,6 +2159,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( @@ -2046,17 +2202,19 @@ class AccountViewModel( onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, + zapType: LnZapEvent.ZapType? = null, ) { viewModelScope.launch(Dispatchers.IO) { try { + val effectiveZapType = zapType ?: defaultZapType() val zapRequest = - if (defaultZapType() != LnZapEvent.ZapType.NONZAP) { + if (effectiveZapType != LnZapEvent.ZapType.NONZAP) { // NIP-57 Appendix F: include amount + lnurl so the receipt can be validated. val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32) account.createZapRequestFor( user = user, message = message, - zapType = defaultZapType(), + zapType = effectiveZapType, amountMillisats = milliSats, lnurl = splitLnurl, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 02063940a6..8052f08ab3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription @@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription @@ -47,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.Publ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssemblerSubscription /** * Activates the relay subscription for each feed the user has pinned to the bottom @@ -63,7 +66,9 @@ fun BottomBarFeedPreloaders(accountViewModel: AccountViewModel) { val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems .collectAsStateWithLifecycle() - items.forEach { item -> + // Only built-in destinations have feeds to preload; favorite-app entries embed their own content. + items.forEach { entry -> + val item = (entry as? BottomBarEntry.BuiltIn)?.item ?: return@forEach key(item) { PreloadFor(item, accountViewModel) } @@ -90,8 +95,24 @@ private fun PreloadFor( NavBarItem.PICTURES -> PicturesFilterAssemblerSubscription(accountViewModel) + NavBarItem.WORKOUTS -> WorkoutsFilterAssemblerSubscription(accountViewModel) + + NavBarItem.GIT_REPOSITORIES -> GitRepositoriesFilterAssemblerSubscription(accountViewModel) + NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel) + // Napplets & nSites read directly from the local cache; their screens open the discovery + // subscription themselves, so there's nothing to preload here. + NavBarItem.NAPPLETS -> {} + + NavBarItem.NSITES -> {} + + // The browser is a "new tab" launcher with no feed to preload. + NavBarItem.BROWSER -> {} + + // Favorite apps is a device-local launcher grid — nothing to preload from relays. + NavBarItem.FAVORITE_APPS -> {} + NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, -> CalendarsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a1e77258de..e05a9627a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -412,6 +412,11 @@ class SealedRumorEventHandler( if (rumorId == null) { processNewSealedRumor(event, eventNote, publicNote) } else { + // Replayed seal: re-link the rumor to its delivering envelope so + // broadcast can republish the wrap after a cache rebuild. + // publicNote is the outermost event of this unwrap chain — the + // kind-1059 wrap normally, the seal itself when it arrived bare. + publicNote.event?.let { envelope -> cache.getOrCreateNote(rumorId).recordRumorHost(envelope) } processExistingSealedRumor(rumorId, publicNote) } } @@ -442,6 +447,12 @@ class SealedRumorEventHandler( val innerRumorNote = cache.getOrCreateNote(innerRumor.id) + // Remember which envelope delivered this rumor (publicNote is the + // kind-1059 wrap normally, the seal itself when it arrived bare). + // Consumers cite/broadcast/prune/evict through this stub — the + // unsigned rumor itself must never be referenced publicly. + publicNote.event?.let { envelope -> innerRumorNote.recordRumorHost(envelope) } + // Marmot Welcome: GiftWrap → Seal → WelcomeEvent. The Seal handler // is the actual point at which we see the kind:444 inner. Route it // to the MLS flow for group joining in addition to caching — there's diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt new file mode 100644 index 0000000000..a3fd3f8bfe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -0,0 +1,458 @@ +/* + * 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.apps.recommendations + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.note.SearchIcon +import com.vitorpamplona.amethyst.ui.note.types.ByAuthorChip +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datasource.ProfileAppRecommendationsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.kinds.KindNames +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@Composable +fun ProfileAppRecommendationsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + // Pull my kind 31989 events plus recent kind 31990 app definitions from + // relays so the list below has candidates while this screen is open. + ProfileAppRecommendationsFilterAssemblerSubscription(accountViewModel) + + // Kind 31990 app definitions, kept live. observeNotes seeds with the notes + // already cached and re-emits as new definitions are inserted, so the + // candidate list below derives straight from these notes — no manual tick or + // full-cache rescan. + // + // Caveat: observeNotes does NOT re-emit when an already-listed addressable is + // replaced in place, so the list isn't re-filtered/re-sorted on a kind-31990 + // update (a blank app that later gains a name, or a newer createdAt changing + // the tier order). That's acceptable here: membership rarely flips on an + // update, each row's own content stays live via AppRow's observeNoteEvent, and + // the list recomputes anyway as the recommendation/follow lists stream in. + // + // The initial value is the current cache snapshot so the first frame matches + // the seeded emission instead of flashing empty. + val cachedAppDefinitions = + remember { + LocalCache.addressables + .filterIntoSet(AppDefinitionEvent.KIND) { _, _ -> true } + .toList() + } + val appDefinitionNotes by remember { + LocalCache + .observeNotes(Filter(kinds = listOf(AppDefinitionEvent.KIND))) + .flowOn(Dispatchers.IO) + }.collectAsStateWithLifecycle(initialValue = cachedAppDefinitions) + + val myRecommendationEvents by accountViewModel.account.appRecommendations.flow + .collectAsStateWithLifecycle() + + val recommendedAddresses = + remember(myRecommendationEvents) { + myRecommendationEvents.flatMapTo(mutableSetOf()) { event -> event.recommendations().map { it.address } } + } + + // The recommended set used for ORDERING only. It tracks recommendedAddresses + // while my 31989s stream in from relays, but freezes at the first toggle so + // rows don't jump around mid-edit. The next visit re-sorts with fresh data. + // Both pins start from the data already in cache (not empty) so the first + // frame after returning to this screen sorts the same as the last one; + // otherwise the restored LazyListState anchors to a key that then jumps + // down the list when the tiers kick in, dragging the viewport with it. + var userHasEdited by remember { mutableStateOf(false) } + var pinnedRecommended by remember { mutableStateOf(recommendedAddresses) } + LaunchedEffect(recommendedAddresses) { + if (!userHasEdited) pinnedRecommended = recommendedAddresses + } + + // Tracks the follow list as it loads (it may not be ready when the screen + // opens), then freezes with the first toggle like pinnedRecommended. + val followsState by accountViewModel.account.kind3FollowList.flow + .collectAsStateWithLifecycle() + var pinnedFollows by remember { mutableStateOf(accountViewModel.account.kind3FollowList.flow.value.authors) } + LaunchedEffect(followsState) { + if (!userHasEdited) pinnedFollows = followsState.authors + } + + val apps = + remember(appDefinitionNotes, pinnedRecommended, pinnedFollows) { + appDefinitionNotes + .filterIsInstance() + .filter { note -> + val event = note.event as? AppDefinitionEvent ?: return@filter false + // Unnamed apps are poor recommendation candidates; keep them + // only when already recommended, so they can be turned off. + note.address in pinnedRecommended || + event + .appMetaData() + ?.anyName() + ?.isNotBlank() == true + }.sortedWith( + // Apps I recommend on top, then apps authored by people I + // follow, then the rest; most recent first within each tier. + compareByDescending { it.address in pinnedRecommended } + .thenByDescending { it.address.pubKeyHex in pinnedFollows } + .thenByDescending { it.event?.createdAt ?: 0 }, + ) + } + + // Apps I recommend whose kind 31990 definition hasn't arrived yet: still + // listed so they can be turned off, while EventFinder fetches the details. + // Derived from the pinned set so a deselected row stays visible (switched + // off) instead of vanishing mid-edit. + val missingRecommended = + remember(pinnedRecommended, apps) { + val known = apps.mapTo(mutableSetOf()) { it.address } + pinnedRecommended + .filterNot { it in known } + .map { LocalCache.getOrCreateAddressableNote(it) } + } + + // The shared top-nav feed filter, resolved to an author/tag matcher. Only the + // author dimension is meaningful for app definitions, so the matchAuthor side + // does the work (Follows lists narrow to apps by those authors); the + // hashtag/relay/community variants leave matchAuthor == true and act as no-ops. + // Applied to discovered apps only — apps I already recommend stay listed so I + // can always turn them off. + val navFilter by accountViewModel.account.liveAppRecommendationsFollowLists + .collectAsStateWithLifecycle() + val authorFilteredApps = + remember(apps, navFilter) { + apps.filter { navFilter.matchAuthor(it.address.pubKeyHex) } + } + + // Full candidate list in display order; the search box filters this view. + val allApps = + remember(missingRecommended, authorFilteredApps) { + missingRecommended + authorFilteredApps + } + + var searchQuery by remember { mutableStateOf("") } + + // Matches on the app's name; rows whose definition hasn't arrived yet have no + // name to match, so they only show when the search box is empty. + val visibleApps = + remember(allApps, searchQuery) { + val query = searchQuery.trim() + if (query.isEmpty()) { + allApps + } else { + allApps.filter { note -> + val event = note.event as? AppDefinitionEvent + event?.appMetaData()?.anyName()?.contains(query, ignoreCase = true) == true + } + } + } + + Scaffold( + topBar = { + AppRecommendationsTopBar(accountViewModel, nav) + }, + ) { pad -> + Column(Modifier.padding(pad).fillMaxSize()) { + Text( + text = stringRes(R.string.profile_app_recommendations_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + + if (allApps.isNotEmpty()) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + placeholder = { + Text( + text = stringRes(R.string.profile_app_recommendations_search), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + ClearTextIcon() + } + } + }, + singleLine = true, + ) + } + + HorizontalDivider() + + if (allApps.isEmpty()) { + // Apps exist in cache but the author filter hid them all, vs. + // nothing discovered yet. + val emptyMessage = + if (apps.isNotEmpty()) { + stringRes(R.string.profile_app_recommendations_filter_empty) + } else { + stringRes(R.string.profile_app_recommendations_empty) + } + Text( + text = emptyMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(20.dp), + ) + } else if (visibleApps.isEmpty()) { + Text( + text = stringRes(R.string.profile_app_recommendations_search_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(20.dp), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + items = visibleApps, + key = { it.idHex }, + ) { appNote -> + AppRow( + appNote = appNote, + isRecommended = appNote.address in recommendedAddresses, + onUserEdited = { userHasEdited = true }, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider() + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppRecommendationsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + ShorterTopAppBar( + navigationIcon = { + if (nav.canPop()) { + IconButton(nav::popBack) { + ArrowBackIcon() + } + } + }, + title = { + val listName by accountViewModel.account.settings.defaultAppRecommendationsFollowList + .collectAsStateWithLifecycle() + val options by accountViewModel.feedStates.feedListOptions.kind3GlobalPeople + .collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = options, + onSelect = accountViewModel.account.settings::changeDefaultAppRecommendationsFollowList, + accountViewModel = accountViewModel, + ) + }, + ) +} + +@Composable +private fun AppRow( + appNote: AddressableNote, + isRecommended: Boolean, + onUserEdited: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Ask relays for the definition only if we don't have it yet, then watch. + // Subscribing for every visible row floods relays for nothing. + if (appNote.event == null) { + EventFinderFilterAssemblerSubscription(appNote, accountViewModel) + } + val definition by observeNoteEvent(appNote, accountViewModel) + + val metadata = definition?.appMetaData() + val supportedKinds = definition?.supportedKinds() ?: emptyList() + val canToggle = definition != null && (isRecommended || supportedKinds.isNotEmpty()) + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + routeFor(appNote, accountViewModel.account)?.let { nav.nav(it) } + }.padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppLogo(definition) + + Spacer(modifier = Modifier.size(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = metadata?.anyName()?.ifBlank { null } ?: stringRes(R.string.app_definition_untitled), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(modifier = Modifier.padding(top = 2.dp, bottom = 2.dp)) { + ByAuthorChip(appNote.address.pubKeyHex, accountViewModel, nav) + } + metadata?.about?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (definition != null && supportedKinds.isEmpty()) { + Text( + text = stringRes(R.string.app_definition_no_supported_kinds), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (supportedKinds.isNotEmpty()) { + Text( + text = supportedKindsLabel(supportedKinds), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.size(12.dp)) + + Switch( + checked = isRecommended, + enabled = canToggle, + onCheckedChange = { checked -> + onUserEdited() + accountViewModel.launchSigner { + val account = accountViewModel.account + if (checked) { + val event = definition ?: return@launchSigner + account.appRecommendations.recommendApp(event, appNote.relayHintUrl(), account) + } else { + account.appRecommendations.unrecommendApp(appNote.address, account) + } + } + }, + ) + } +} + +private const val VISIBLE_KIND_NAMES = 3 + +@Composable +private fun supportedKindsLabel(kinds: List): String { + val names = + kinds.take(VISIBLE_KIND_NAMES).map { kind -> + val nameRes = kindDisplayName(kind) + if (nameRes != -1) stringRes(nameRes) else (KindNames.nameFor(kind) ?: "k$kind") + } + val overflow = kinds.size - VISIBLE_KIND_NAMES + val suffix = if (overflow > 0) " +$overflow" else "" + return stringRes(R.string.app_definition_handles) + ": " + names.joinToString(" · ") + suffix +} + +@Composable +private fun AppLogo(definition: AppDefinitionEvent?) { + val imageUrl = definition?.appMetaData()?.profilePicture()?.ifBlank { null } + val logoModifier = Modifier.size(48.dp).clip(CircleShape) + + if (imageUrl.isNullOrBlank()) { + RobohashAsyncImage( + robot = definition?.id ?: "appnotfound", + contentDescription = null, + modifier = logoModifier, + loadRobohash = true, + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = logoModifier, + contentScale = ContentScale.Crop, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt new file mode 100644 index 0000000000..ab1c3b4def --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt @@ -0,0 +1,70 @@ +/* + * 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.apps.recommendations.datasource + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent + +/** + * Pulls this user's own kind 31989 recommendation events from their outbox + * relays so the "Recommended apps" management screen starts from the current + * published state even when the profile screen hasn't been visited yet. + */ +fun filterMyAppRecommendations( + pubkey: HexKey, + relays: Set, +): List { + if (pubkey.isEmpty() || relays.isEmpty()) return emptyList() + val authors = listOf(pubkey) + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(AppRecommendationEvent.KIND), + authors = authors, + limit = 100, + ), + ) + } +} + +/** + * Pulls recent kind 31990 app definitions so the management screen has + * candidates to recommend beyond what already happens to sit in cache. + */ +fun filterRecentAppDefinitions(relays: Set): List { + if (relays.isEmpty()) return emptyList() + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + limit = 100, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt new file mode 100644 index 0000000000..bbce9cc824 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt @@ -0,0 +1,46 @@ +/* + * 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.apps.recommendations.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +class ProfileAppRecommendationsQueryState( + val account: Account, +) + +@Stable +class ProfileAppRecommendationsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ProfileAppRecommendationsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..28e7c463b5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssemblerSubscription.kt @@ -0,0 +1,36 @@ +/* + * 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.apps.recommendations.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun ProfileAppRecommendationsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + val state = + remember(accountViewModel.account) { + ProfileAppRecommendationsQueryState(accountViewModel.account) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().profileAppRecommendations) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsSubAssembler.kt new file mode 100644 index 0000000000..60e2ab5810 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsSubAssembler.kt @@ -0,0 +1,45 @@ +/* + * 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.apps.recommendations.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class ProfileAppRecommendationsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: ProfileAppRecommendationsQueryState) = key.account.userProfile() + + override fun updateFilter( + key: ProfileAppRecommendationsQueryState, + since: SincePerRelayMap?, + ): List = + filterMyAppRecommendations( + pubkey = user(key).pubkeyHex, + relays = key.account.outboxRelays.flow.value, + ) + + filterRecentAppDefinitions( + relays = key.account.defaultGlobalRelays.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt index 534bc03854..253880b693 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt @@ -31,7 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt index 49d2ef8d14..f1ea4346b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent class ArticlesFeedFilter( @@ -74,5 +74,5 @@ class ArticlesFeedFilter( } } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt index 0499409976..babc7e96d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent class BadgesFeedFilter( @@ -47,35 +47,17 @@ class BadgesFeedFilter( override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() - private fun myPubkey(): String = account.userProfile().pubkeyHex - override fun feed(): List { + val params = buildFilterParams(account) val notes = - if (followList() == TopFilter.Mine) { - val me = myPubkey() - LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it -> - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me - } - } else { - val params = buildFilterParams(account) - LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it -> - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays) - } + LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it -> + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays) } return sort(notes) } override fun applyFilter(newItems: Set): Set { - if (followList() == TopFilter.Mine) { - val me = myPubkey() - return newItems.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me - } - } - val params = buildFilterParams(account) return newItems.filterTo(HashSet()) { val noteEvent = it.event @@ -89,5 +71,5 @@ class BadgesFeedFilter( account.hiddenUsers.flow.value, ) - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt index 5971b8b407..f4d5b0288a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt @@ -42,15 +42,11 @@ class BadgesSubAssembler( key: BadgesQueryState, since: SincePerRelayMap?, ): List { - val listName = key.listName() + // "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author + // filter scoped to the user, so followsPerRelay() already carries authors=[me]. val defaultSince = key.feedStates.badgesFeed.lastNoteCreatedAtIfFilled() - return if (listName == TopFilter.Mine) { - val outbox = key.account.outboxRelays.flow.value - filterBadgesMine(key.account.userProfile().pubkeyHex, outbox, since) - } else { - makeBadgesFilter(key.followsPerRelay(), since, defaultSince) - } + return makeBadgesFilter(key.followsPerRelay(), since, defaultSince) } override fun user(key: BadgesQueryState) = key.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt index ba08ae6329..a4151d33a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt @@ -48,27 +48,6 @@ fun makeBadgesFilter( else -> emptyList() } -fun filterBadgesMine( - pubkey: HexKey, - relays: Set, - since: SincePerRelayMap?, -): List { - if (relays.isEmpty() || pubkey.isEmpty()) return emptyList() - val authors = listOf(pubkey) - return relays.map { relay -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = listOf(BadgeDefinitionEvent.KIND), - authors = authors, - limit = BADGE_FEED_LIMIT, - since = since?.get(relay)?.time, - ), - ) - } -} - private fun filterBadgesByAuthorsOnRelay( relay: NormalizedRelayUrl, authors: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 667f91fe7c..35f9774204 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -33,10 +33,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index af5c4bcf59..37caa116b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -42,6 +42,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.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState @@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsFeedView( defaultBookmarks: BookmarkListState, oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, + repositories: GitRepositoryListState, groupListFeedSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, + openRepositories: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -91,6 +94,11 @@ fun ListOfBookmarkGroupsFeedView( HorizontalDivider(thickness = DividerThickness) } + item { + RepositoriesBookmarkList(repositories, openRepositories) + HorizontalDivider(thickness = DividerThickness) + } + itemsIndexed( bookmarkGroupFeedState, key = { _: Int, item: LabeledBookmarkList -> item.identifier }, @@ -197,6 +205,50 @@ fun PinnedNotesList( ) } +@Composable +fun RepositoriesBookmarkList( + repositories: GitRepositoryListState, + openRepositories: () -> Unit, +) { + val repositoryAddresses by repositories.publicRepositoryAddressSet.collectAsStateWithLifecycle() + + ListItem( + modifier = Modifier.clickable(onClick = openRepositories), + headlineContent = { + Text(stringRes(R.string.repository_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.repository_bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Code, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = repositoryAddresses.size, + articleBookmarksSize = 0, + ) + } + }, + ) +} + @Composable fun OldBookmarkList( oldBookmarks: OldBookmarkListState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 2ac0c207cd..e1aa912ebd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier 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.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState @@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsScreen( defaultBookmarks = accountViewModel.account.bookmarkState, oldBookmarks = accountViewModel.account.oldBookmarkState, pinnedNotes = accountViewModel.account.pinState, + repositories = accountViewModel.account.gitRepositoryListState, listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, openDefaultBookmarks = { nav.nav(Route.Bookmarks) }, openOldBookmarks = { nav.nav(Route.OldBookmarks) }, openPinnedNotes = { nav.nav(Route.PinnedNotes) }, + openRepositories = { nav.nav(Route.BookmarkedRepositories) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -101,10 +104,12 @@ fun ListOfBookmarkGroupsFeed( defaultBookmarks: BookmarkListState, oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, + repositories: GitRepositoryListState, listSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, + openRepositories: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -148,10 +153,12 @@ fun ListOfBookmarkGroupsFeed( defaultBookmarks = defaultBookmarks, oldBookmarks = oldBookmarks, pinnedNotes = pinnedNotes, + repositories = repositories, groupListFeedSource = listSource, openDefaultBookmarks = openDefaultBookmarks, openOldBookmarks = openOldBookmarks, openPinnedNotes = openPinnedNotes, + openRepositories = openRepositories, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt new file mode 100644 index 0000000000..9f5749cd77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal.BookmarkRepositoriesFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address + +@Composable +fun BookmarkedRepositoriesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel = + viewModel( + key = "NostrBookmarkRepositoriesFeedViewModel", + factory = BookmarkRepositoriesFeedViewModel.Factory(accountViewModel.account), + ) + + val repositoryBookmarks by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet + .collectAsStateWithLifecycle() + + LaunchedEffect(repositoryBookmarks) { + repositoriesFeedViewModel.invalidateData() + } + + // Preload any bookmarked repo announcements not yet in cache so they don't pop in one by one. + PreloadRepositoryEvents(repositoryBookmarks, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarWithBackButton(stringRes(id = R.string.repository_bookmarks), nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableFeedView( + repositoriesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun PreloadRepositoryEvents( + repositoryBookmarks: Set
, + accountViewModel: AccountViewModel, +) { + val eventFinder = accountViewModel.dataSources().eventFinder + val account = accountViewModel.account + + val queries = + remember(repositoryBookmarks) { + repositoryBookmarks + .map { account.cache.getOrCreateAddressableNote(it) } + .filter { it.event == null } + .map { EventFinderQueryState(it, account) } + } + + DisposableEffect(queries) { + eventFinder.subscribe(queries) + onDispose { + eventFinder.unsubscribe(queries) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt new file mode 100644 index 0000000000..a04feb1f8e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +/** + * The user's bookmarked (starred) git repositories — the public NIP-51 kind 10018 + * [com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState] addresses + * resolved to their addressable notes, newest first. + */ +class BookmarkRepositoriesFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .hashCode() + .toString() + + override fun feed(): List = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .map { account.cache.getOrCreateAddressableNote(it) } + .sortedByDescending { it.createdAt() ?: 0L } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt new file mode 100644 index 0000000000..cbef857aec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class BookmarkRepositoriesFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(BookmarkRepositoriesFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkRepositoriesFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt new file mode 100644 index 0000000000..a74ac3db4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -0,0 +1,797 @@ +/* + * 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.browser + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions +import com.vitorpamplona.amethyst.commons.browser.SuggestedWebApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter +import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry +import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import com.vitorpamplona.amethyst.commons.R as CommonsR + +/** How many of the most recent history entries the idle browser home surfaces under "Recent". */ +private const val RECENTS_LIMIT = 12 + +/** + * The Browser tab — a **launcher**, not a content surface. The user types a URL here and each opened + * site lands in its own full-screen + * [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own + * task/recents entry), so apps are swapped the normal Android way and a running app never carries an + * editable address bar. + * + * Idle, the body is the [BrowserHome]: pinned favorites on top, then recent visits. As the user types it + * becomes a grouped omnibox suggestion list (favorites first + highlighted, then recents) with inline + * ghost-text completion. Both decorate sites with the favicon captured when they were last opened. + * + * Works on any API level: each site loads in a full-screen direct-WebView activity, never the + * cross-process SurfaceControlViewHost surface that the *embedded* favorite-app tabs require (API 30+). + */ +@Composable +fun BrowserScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + BrowserLauncher(accountViewModel, nav) +} + +@Composable +private fun BrowserLauncher( + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle() + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + + // Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of + // showing "isn't loaded yet" until the user happens to visit the nsite/napplet feed. + PreloadFavoriteNostrApps(apps, accountViewModel) + + var field by remember { mutableStateOf(TextFieldValue("")) } + + // Favorites + visit history + the hardcoded Discover apps, flattened into the neutral candidate shape + // the ranker consumes — so typing the omnibox finds a suggested app even before its first visit. The + // ranker dedupes by host, and favorites/history outscore a plain default, so a default that the user + // already pinned or visited collapses into that higher-ranked row instead of showing twice. + val candidates = + remember(apps, history) { + buildList { + apps.forEach { if (it is FavoriteApp.WebApp) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) } + history.forEach { + add( + OmniboxSuggestions.Candidate( + url = it.url, + label = it.title.ifBlank { it.host }, + isFavorite = false, + visitCount = it.visitCount, + lastVisitedAt = it.lastVisitedAt, + ), + ) + } + DefaultWebClients.list.forEach { add(OmniboxSuggestions.Candidate(it.app.url, it.app.label, isFavorite = false)) } + } + } + + // Visited URLs, so the suggestion list can tell a Recent result from a Discover one (and only the + // former offers "Remove from history"). + val historyUrls = remember(history) { history.mapTo(HashSet()) { it.url } } + + // Discover nsites & napplets — the NIP-5A sites and NIP-5D apps published by the people the user + // follows. Same feed + follow-list filter the dedicated nSites/nApplets screens use (set those to + // "All Follows" for a pure follows list): the subscriptions pull manifests into LocalCache while the + // Browser tab is open, and we observe the addressable store and keep the matching authors'. + NsitesFilterAssemblerSubscription(accountViewModel) + NappletsFilterAssemblerSubscription(accountViewModel) + + val nsiteNotes by remember { + Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND))) + }.collectAsStateWithLifecycle(emptyList()) + val nappletNotes by remember { + Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND))) + }.collectAsStateWithLifecycle(emptyList()) + + val nsiteFollows by accountViewModel.account.liveNsitesFollowLists.collectAsStateWithLifecycle() + val nappletFollows by accountViewModel.account.liveNappletsFollowLists.collectAsStateWithLifecycle() + + // Drop ones already pinned — they show under Favorites, not twice. + val favoriteCoordinates = remember(apps) { apps.filterIsInstance().mapTo(HashSet()) { it.coordinate } } + val followedNsites = + remember(nsiteNotes, nsiteFollows, favoriteCoordinates) { + nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates) + } + val followedNapplets = + remember(nappletNotes, nappletFollows, favoriteCoordinates) { + nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) + } + + // What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the + // caret when collapsed, or the start of the highlighted suffix when a completion is showing). + val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length)) + val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates, limit = 12) } + + fun open(text: String) { + val target = OmniboxInput.resolve(text) ?: return + FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor) + } + + // Pin/unpin a plain web URL by its favorite id. Shared by the suggestion list and the Recent rows. + fun toggleFavorite( + url: String, + label: String, + ) { + val id = "url:$url" + if (FavoriteAppsRegistry.isFavorite(id)) { + FavoriteAppsRegistry.remove(id) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis())) + } + } + + // Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so + // the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched. + fun onValueChange(new: TextFieldValue) { + val prevTyped = field.text.take(field.selection.min.coerceIn(0, field.text.length)) + val newText = new.text + val appended = + new.selection.collapsed && + new.selection.start == newText.length && + newText.length > prevTyped.length && + newText.startsWith(prevTyped) + if (appended) { + val completion = OmniboxSuggestions.completion(newText, OmniboxSuggestions.rank(newText, candidates)) + if (completion != null) { + // Keep the user's own casing for the typed prefix; append only the remaining suffix. + val full = newText + completion.substring(newText.length) + field = TextFieldValue(full, TextRange(newText.length, full.length)) + return + } + } + field = new + } + + Scaffold( + topBar = { + OmniBar( + nav = nav, + field = field, + onValueChange = ::onValueChange, + onClear = { field = TextFieldValue("") }, + onOpen = { open(field.text) }, + ) + }, + bottomBar = { + AppBottomBar(Route.Browser, nav, accountViewModel) { route -> nav.navBottomBar(route) } + }, + ) { padding -> + val contentModifier = + Modifier + .fillMaxSize() + .padding(padding) + when { + typed.isNotBlank() && suggestions.isNotEmpty() -> + SuggestionGrid( + suggestions = suggestions, + iconKeys = iconKeys, + historyUrls = historyUrls, + onOpen = { open(it.url) }, + onToggleFavorite = { toggleFavorite(it.url, it.label) }, + onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) }, + modifier = contentModifier, + ) + else -> { + val favoriteUrls = remember(apps) { apps.filterIsInstance().mapTo(HashSet()) { it.url } } + // Hardcoded starter web apps, minus any the user already pinned (those show under Favorites). + val suggested = remember(favoriteUrls) { DefaultWebClients.list.filter { it.app.url !in favoriteUrls } } + BrowserHome( + apps = apps, + history = history, + iconKeys = iconKeys, + favoriteUrls = favoriteUrls, + suggested = suggested, + nsites = followedNsites, + napplets = followedNapplets, + onOpenApp = { FavoriteAppLauncher.launch(context, it) }, + onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, + onAddApp = { FavoriteAppsRegistry.add(it) }, + onOpenUrl = { open(it) }, + onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) }, + onRemoveRecent = { BrowserHistoryRegistry.remove(it) }, + modifier = contentModifier, + ) + } + } + } +} + +@Composable +private fun OmniBar( + nav: INav, + field: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + onClear: () -> Unit, + onOpen: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + // The omnibox is a plain Row in the topBar slot (not a Material3 TopAppBar), so it must + // apply the status-bar inset itself — otherwise it draws under the status bar. + .statusBarsPadding() + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // When reached from the drawer (pushed onto the back stack) rather than as a bottom-bar tab, show + // a back arrow — same rule as the other launcher/feed top bars (see NappletsTopBar). + if (nav.canPop()) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } + TextField( + value = field, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { Text(stringResource(CommonsR.string.browser_address_hint)) }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { onOpen() }), + trailingIcon = { + if (field.text.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon(MaterialSymbols.Clear, contentDescription = stringResource(R.string.browser_clear)) + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + if (field.text.isNotBlank()) { + IconButton(onClick = onOpen) { + Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringResource(R.string.browser_go)) + } + } + } +} + +/** + * The typed-state body: ranked suggestions split into a highlighted Favorites group, then Recent (visited + * sites), then Discover (the hardcoded web apps the user hasn't pinned or visited yet). Every row carries + * the same 3-dot menu as the idle Recent cards (pin/unpin; plus remove-from-history for visited sites). + */ +@Composable +private fun SuggestionGrid( + suggestions: List, + iconKeys: Set, + historyUrls: Set, + onOpen: (OmniboxSuggestions.Suggestion) -> Unit, + onToggleFavorite: (OmniboxSuggestions.Suggestion) -> Unit, + onRemoveFromHistory: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val favorites = suggestions.filter { it.isFavorite } + val recent = suggestions.filter { !it.isFavorite && it.url in historyUrls } + val discover = suggestions.filter { !it.isFavorite && it.url !in historyUrls } + + fun LazyGridScope.section( + keyPrefix: String, + title: Int, + rows: List, + highlighted: Boolean, + ) { + if (rows.isEmpty()) return + item(key = "h-$keyPrefix") { SectionHeader(stringResource(title)) } + items(rows, key = { "$keyPrefix:" + it.url }) { suggestion -> + SuggestionRow( + suggestion = suggestion, + iconKeys = iconKeys, + highlighted = highlighted, + removableFromHistory = suggestion.url in historyUrls, + onClick = { onOpen(suggestion) }, + onToggleFavorite = { onToggleFavorite(suggestion) }, + onRemoveFromHistory = { onRemoveFromHistory(suggestion.url) }, + ) + } + } + + LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) { + section("f", R.string.browser_favorites, favorites, highlighted = true) + section("o", R.string.favorite_app_recent, recent, highlighted = false) + section("s", R.string.browser_suggested, discover, highlighted = false) + } +} + +@Composable +private fun SuggestionRow( + suggestion: OmniboxSuggestions.Suggestion, + iconKeys: Set, + highlighted: Boolean, + removableFromHistory: Boolean, + onClick: () -> Unit, + onToggleFavorite: () -> Unit, + onRemoveFromHistory: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else Color.Transparent) + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SiteIcon(suggestion.host, suggestion.isFavorite, iconKeys, Modifier.size(24.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + suggestion.host, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (highlighted) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (suggestion.label.isNotBlank() && !suggestion.label.equals(suggestion.host, ignoreCase = true)) { + Text( + suggestion.label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(if (suggestion.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add)) }, + leadingIcon = { + Icon(if (suggestion.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null) + }, + onClick = { + menuOpen = false + onToggleFavorite() + }, + ) + if (removableFromHistory) { + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_recent_remove)) }, + leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onRemoveFromHistory() + }, + ) + } + } + } + } +} + +/** The idle body: favorites grid on top, then recent visits — in one grid so they scroll together. */ +@Composable +private fun BrowserHome( + apps: List, + history: List, + iconKeys: Set, + favoriteUrls: Set, + suggested: List, + nsites: List, + napplets: List, + onOpenApp: (FavoriteApp) -> Unit, + onRemoveApp: (FavoriteApp) -> Unit, + onAddApp: (FavoriteApp) -> Unit, + onOpenUrl: (String) -> Unit, + onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit, + onRemoveRecent: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val recents = remember(history) { history.take(RECENTS_LIMIT) } + LazyVerticalGrid( + columns = GridCells.Adaptive(96.dp), + modifier = modifier, + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (apps.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) } + favoriteAppItems(apps, onOpenApp, onRemoveApp) + } + if (recents.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) } + items(recents, span = { GridItemSpan(maxLineSpan) }, key = { "r:" + it.url }) { entry -> + RecentRow( + entry = entry, + iconKeys = iconKeys, + isFavorited = entry.url in favoriteUrls, + onClick = { onOpenUrl(entry.url) }, + onToggleFavorite = { onToggleRecentFavorite(entry) }, + onRemove = { onRemoveRecent(entry.url) }, + ) + } + } + if (nsites.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-nsite") { SectionHeader(stringResource(R.string.browser_discover_nsites)) } + items(nsites, span = { GridItemSpan(maxLineSpan) }, key = { "ns:" + it.app.coordinate }) { entry -> + NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) }) + } + } + if (napplets.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-napp") { SectionHeader(stringResource(R.string.browser_discover_napplets)) } + items(napplets, span = { GridItemSpan(maxLineSpan) }, key = { "np:" + it.app.coordinate }) { entry -> + NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) }) + } + } + if (suggested.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-sug") { SectionHeader(stringResource(R.string.browser_suggested)) } + items(suggested, span = { GridItemSpan(maxLineSpan) }, key = { "s:" + it.app.url }) { entry -> + SuggestedRow( + entry = entry, + iconKeys = iconKeys, + onClick = { onOpenApp(entry.app) }, + onAddFavorite = { onAddApp(entry.app) }, + ) + } + } + } +} + +/** A Discover row: the app's own icon, its name, and a one-line description, with a star to pin it. */ +@Composable +private fun SuggestedRow( + entry: SuggestedWebApp, + iconKeys: Set, + onClick: () -> Unit, + onAddFavorite: () -> Unit, +) { + val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(BrowserIconRegistry::iconModelFor) } + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FavoriteAppIcon( + app = entry.app, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + iconModel = iconModel, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.app.label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onAddFavorite) { + Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) + } + } +} + +/** A followed nsite/napplet, resolved into its launchable [app] plus the manifest [description]. */ +private data class DiscoverNostrApp( + val app: FavoriteApp.NostrApp, + val description: String?, +) + +/** How many followed nsites/napplets each Discover section surfaces, so the launcher stays tidy. */ +private const val DISCOVER_NOSTR_LIMIT = 12 + +/** + * Keeps the [Note]s authored by the followed set, drops ones already pinned, maps each to its + * launchable [DiscoverNostrApp], and caps the result. Covers the "Mine" selection too: the shared + * matcher now resolves Mine to the user's own pubkey, so [matchAuthor] already narrows to the user. + */ +private fun List.toDiscoverApps( + matchAuthor: (String) -> Boolean, + excludeCoordinates: Set, +): List = + asSequence() + .filter { note -> + val author = note.event?.pubKey ?: return@filter false + matchAuthor(author) + }.mapNotNull { it.toDiscoverNostrApp() } + .filter { it.app.coordinate !in excludeCoordinates } + .take(DISCOVER_NOSTR_LIMIT) + .toList() + +/** Resolve a cached nsite/napplet manifest note into a launchable favorite + its description, or null. */ +private fun Note.toDiscoverNostrApp(): DiscoverNostrApp? { + val event = event ?: return null + val coordinate = FavoriteAppLauncher.coordinateOf(event) + val label: String + val description: String? + when (event) { + is RootSiteEvent -> { + label = event.title()?.ifBlank { null } ?: "Website" + description = event.description() + } + is NamedSiteEvent -> { + label = event.title()?.ifBlank { null } ?: event.identifier() + description = event.description() + } + is RootNappletEvent -> { + label = event.title()?.ifBlank { null } ?: "App" + description = event.description() + } + is NamedNappletEvent -> { + label = event.title()?.ifBlank { null } ?: event.identifier() + description = event.description() + } + else -> return null + } + return DiscoverNostrApp(FavoriteApp.NostrApp(coordinate, label, 0L), description) +} + +/** A Discover row for a followed nsite/napplet: its manifest icon, name, and description, with a pin star. */ +@Composable +private fun NostrAppRow( + discover: DiscoverNostrApp, + onClick: () -> Unit, + onAddFavorite: () -> Unit, +) { + val app = discover.app + val iconModel = rememberNappletIconModel(app.coordinate) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FavoriteAppIcon( + app = app, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + iconModel = iconModel, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + app.label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!discover.description.isNullOrBlank()) { + Text( + discover.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onAddFavorite) { + Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) + } + } +} + +@Composable +private fun RecentRow( + entry: BrowserHistoryEntry, + iconKeys: Set, + isFavorited: Boolean, + onClick: () -> Unit, + onToggleFavorite: () -> Unit, + onRemove: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SiteIcon(entry.host, isFavorite = isFavorited, iconKeys = iconKeys, modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.title.ifBlank { entry.host }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.host, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(if (isFavorited) R.string.favorite_app_remove else R.string.favorite_app_add)) }, + leadingIcon = { + Icon(if (isFavorited) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null) + }, + onClick = { + menuOpen = false + onToggleFavorite() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_recent_remove)) }, + leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onRemove() + }, + ) + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +/** A site's captured favicon, falling back to a glyph (a star for favorites, the globe otherwise). */ +@Composable +private fun SiteIcon( + host: String, + isFavorite: Boolean, + iconKeys: Set, + modifier: Modifier = Modifier, +) { + val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } + val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public + val tint = MaterialTheme.colorScheme.onSurfaceVariant + if (model == null) { + Icon(symbol, contentDescription = null, modifier = modifier, tint = tint) + } else { + val glyph = rememberMaterialSymbolPainter(symbol, tint) + AsyncImage( + model = model, + contentDescription = null, + modifier = modifier.clip(RoundedCornerShape(6.dp)), + placeholder = glyph, + error = glyph, + fallback = glyph, + ) + } +} + +/** The host of [url] for a favorite's default label, falling back to the raw string. */ +private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: url diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt new file mode 100644 index 0000000000..33a49a3cc6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -0,0 +1,338 @@ +/* + * 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.browser + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.SystemClock +import androidx.annotation.RequiresApi +import androidx.compose.runtime.mutableStateListOf +import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory +import androidx.privacysandbox.ui.client.view.SandboxedSdkView +import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicLong + +/** + * Client-side handle to the embedded browser. Binds [NappletBrowserService] (in the keyless `:napplet` + * process), hands its `SandboxedUiAdapter` to a [SandboxedSdkView] so the remote WebView renders inside + * the main activity, and relays chrome controls (navigate/reload/back/Tor) while receiving URL updates + * that drive the trusted, main-process address bar. + */ +@RequiresApi(Build.VERSION_CODES.R) +class EmbeddedWebAppController( + private val appContext: Context, + private val proxyPort: Int, + private val initialUseTor: Boolean, + private val backgroundColor: Int, + private val themeType: String = "SYSTEM", +) : EmbeddedSurfaceController, + EmbeddedImeBridge, + EmbeddedMagnifierProbe, + ConsoleBridge { + private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage)) + private var serviceMessenger: Messenger? = null + private var bound = false + + private var sandboxedSdkView: SandboxedSdkView? = null + private var pendingAdapter: SandboxedUiAdapter? = null + private var startUrl: String = "about:blank" + + private var hasLoadedReal = false + private var blankRecovered = false + + /** Last known main-frame load state, so the tab layer renders the right overlay immediately. */ + override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus() + private set + + /** Notified on the main thread whenever [loadStatus] changes. */ + override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null + + /** JavaScript console output received from the embedded WebView, capped at [MAX_CONSOLE_LOGS] entries. */ + override val consoleLogs = mutableStateListOf() + + override fun clearConsoleLogs() = consoleLogs.clear() + + // A single NappletBrowserService instance serves every embedded browser tab, so each controller + // stamps its own id on every message; the provider uses it to route controls/updates to this tab. + private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}" + + /** Invoked on the main thread when the page navigates: (url, canGoBack). */ + var onUrlChanged: ((String, Boolean) -> Unit)? = null + + override var onImeEvent: ((ImeEvent) -> Unit)? = null + + // SPIKE (magnifier #4, option B): provider-side capture round-trip. Removed once the loupe lands. + override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null + + private val connection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + service: IBinder?, + ) { + serviceMessenger = Messenger(service) + sendCreateSession() + } + + override fun onServiceDisconnected(name: ComponentName?) { + serviceMessenger = null + } + } + + fun bind(startUrl: String) { + this.startUrl = startUrl + val intent = Intent().setClassName(appContext, NappletBrowserContract.BROWSER_SERVICE_CLASS) + bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + fun unbind() { + if (bound) { + runCatching { appContext.unbindService(connection) } + bound = false + } + // Drop refs so an evicted controller doesn't pin the surface view or the remote messenger. + serviceMessenger = null + sandboxedSdkView = null + pendingAdapter = null + onUrlChanged = null + onImeEvent = null + onMagnifierFrame = null + onLoadStatusChanged = null + consoleLogs.clear() + } + + override fun teardown() = unbind() + + /** Hands the surface view to the controller; applies the adapter if it already arrived. */ + override fun attachView(view: SandboxedSdkView) { + sandboxedSdkView = view + // Paint the surface placeholder in the app's theme background so there's no white flash before + // the remote WebView delivers its first frame. + view.setBackgroundColor(backgroundColor) + pendingAdapter?.let { + view.setAdapter(it) + pendingAdapter = null + } + } + + private fun sendCreateSession() { + val msg = + Message.obtain(null, NappletBrowserContract.MSG_CREATE_SESSION).apply { + replyTo = incoming + data = + Bundle().apply { + putString(NappletBrowserContract.KEY_SESSION_ID, sessionId) + putString(NappletBrowserContract.KEY_URL, startUrl) + putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort) + putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor) + putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor) + putString(NappletBrowserContract.KEY_THEME, themeType) + } + } + runCatching { serviceMessenger?.send(msg) } + } + + private fun onServiceMessage(msg: Message): Boolean { + when (msg.what) { + NappletBrowserContract.MSG_SESSION_READY -> { + val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true + val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo) + val view = sandboxedSdkView + if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter + } + NappletBrowserContract.MSG_URL_CHANGED -> { + val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() + val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false + onUrlChanged?.invoke(url, canGoBack) + } + NappletBrowserContract.MSG_IME_EVENT -> { + val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true + parseImeEvent(payload)?.let { event -> onImeEvent?.invoke(event) } + } + NappletBrowserContract.MSG_LOAD_STATE -> { + val isLoading = msg.data?.getBoolean(NappletBrowserContract.KEY_IS_LOADING, false) ?: false + val failed = msg.data?.getBoolean(NappletBrowserContract.KEY_LOAD_FAILED, false) ?: false + val loadedUrl = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() + onLoadState(isLoading, failed, loadedUrl) + } + NappletBrowserContract.MSG_CONSOLE_LOG -> { + val level = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_LEVEL) ?: "LOG" + val message = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_MESSAGE).orEmpty() + val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty() + val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0 + if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0) + consoleLogs.add(ConsoleLogEntry(level, message, source, line)) + } + NappletBrowserContract.MSG_MAGNIFIER_FRAME -> { + val data = msg.data ?: return true + val bytes = data.getByteArray(NappletBrowserContract.KEY_MAG_BYTES) ?: return true + onMagnifierFrame?.invoke( + MagnifierFrame( + bytes = bytes, + width = data.getInt(NappletBrowserContract.KEY_MAG_W), + height = data.getInt(NappletBrowserContract.KEY_MAG_H), + captureMs = data.getDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS), + requestStampNanos = data.getLong(NappletBrowserContract.KEY_MAG_REQ_T), + ), + ) + } + else -> return false + } + return true + } + + fun navigate(url: String) = send(NappletBrowserContract.MSG_NAVIGATE) { putString(NappletBrowserContract.KEY_URL, url) } + + fun reload() = send(NappletBrowserContract.MSG_RELOAD) {} + + /** + * User-triggered recovery for a stuck, blank, or failed session: reload the canonical [startUrl] from + * scratch. Unlike [reload] (which re-fetches whatever the WebView currently shows — `about:blank` for a + * session that never got its URL), this re-navigates to the favorite's real URL. + */ + override fun retry() { + blankRecovered = false + hasLoadedReal = false + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) + navigate(startUrl) + } + + private fun onLoadState( + isLoading: Boolean, + failed: Boolean, + loadedUrl: String, + ) { + // A favorite whose session settled on about:blank never received its real URL (a warm session built + // before the URL was wired through). Re-navigate once to the canonical URL — reload() can't fix this + // because it would just reload about:blank. Scoped to a real startUrl, so the generic browser's + // intentional about:blank new-tab page is left alone. + if (!isLoading && !failed && loadedUrl.isBlankPage() && !startUrl.isBlankPage() && !blankRecovered) { + blankRecovered = true + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) + navigate(startUrl) + return + } + if (!isLoading && !failed && !loadedUrl.isBlankPage()) hasLoadedReal = true + publishLoadStatus(EmbeddedLoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal)) + } + + private fun publishLoadStatus(status: EmbeddedLoadStatus) { + loadStatus = status + onLoadStatusChanged?.invoke(status) + } + + private fun String.isBlankPage() = isEmpty() || this == "about:blank" + + fun back() = send(NappletBrowserContract.MSG_BACK) {} + + fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } + + override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) } + + // Stamp the client send time so the reply can be matched / stale frames dropped (same-process clock). + override fun requestMagnifier( + surfaceX: Float, + surfaceY: Float, + boxWidthPx: Int, + boxHeightPx: Int, + zoom: Float, + ) = send(NappletBrowserContract.MSG_MAGNIFIER_REQUEST) { + putFloat(NappletBrowserContract.KEY_MAG_X, surfaceX) + putFloat(NappletBrowserContract.KEY_MAG_Y, surfaceY) + putInt(NappletBrowserContract.KEY_MAG_BOX_W, boxWidthPx) + putInt(NappletBrowserContract.KEY_MAG_BOX_H, boxHeightPx) + putFloat(NappletBrowserContract.KEY_MAG_ZOOM, zoom) + putLong(NappletBrowserContract.KEY_MAG_REQ_T, SystemClock.elapsedRealtimeNanos()) + } + + private fun parseImeEvent(payload: String): ImeEvent? { + val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null + return when (o.optString("type")) { + "ime.focus" -> + ImeEvent.Focus( + inputType = o.optString("inputType", "text"), + enterKeyHint = o.optString("enterKeyHint", ""), + multiline = o.optBoolean("multiline", false), + text = o.optString("text", ""), + selStart = o.optInt("selStart", 0), + selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.blur" -> ImeEvent.Blur + "ime.state" -> + ImeEvent.State( + text = o.optString("text", ""), + selStart = o.optInt("selStart", 0), + selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.pagesel" -> + ImeEvent.PageSelection( + active = o.optBoolean("active", false), + text = o.optString("text", ""), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) + "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) + else -> null + } + } + + private inline fun send( + what: Int, + crossinline block: Bundle.() -> Unit, + ) { + val msg = + Message.obtain(null, what).apply { + data = + Bundle().apply { + putString(NappletBrowserContract.KEY_SESSION_ID, sessionId) + block() + } + } + runCatching { serviceMessenger?.send(msg) } + } + + private companion object { + private val SESSION_SEQ = AtomicLong() + private const val MAX_CONSOLE_LOGS = 200 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt new file mode 100644 index 0000000000..9b903b4b33 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -0,0 +1,207 @@ +/* + * 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.browser + +import android.net.Uri +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds +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.screen.loggedIn.embed.EmbeddedTabChrome +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost + +/** + * A **Web app** (a Nostr web client, reached by [url]) rendered as an **in-app tab** — for *any* URL, + * favorited or not; favoriting is just the star toggle in the chrome. The embedded `:napplet` browser surface is drawn + * by the persistent [EmbeddedTabHost]/[com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer] + * layer, which keeps the session warm across tab swaps. This screen owns only the chrome — it publishes + * the controls as an [EmbeddedTabChrome] and the layer draws a top pull-down sheet over the (z-below) + * surface. No title bar or editable address bar. + * + * Only bottom-row favorites stay warm; if this URL isn't a bottom-bar favorite, its session is evicted + * (restarted) when the screen leaves. Requires API 30+ for the cross-process surface. + */ +@Composable +fun WebAppScreen( + url: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + EmbeddedWebAppTab(url, accountViewModel, nav) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.browser_unsupported), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.R) +@Composable +private fun EmbeddedWebAppTab( + url: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + // Matches FavoriteApp.WebApp.id, so warm-keep membership lines up with the bottom-bar favorites. + val id = "url:$url" + + var currentUrl by remember { mutableStateOf(url) } + var canGoBack by remember { mutableStateOf(false) } + + val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null } + // Start from this site's remembered Tor choice (some sites' servers reject Tor exits, so the user + // can opt one out and it must stick). Only meaningful when Tor is actually available. + var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) } + + val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } } + + val backgroundColor = MaterialTheme.colorScheme.background.toArgb() + + // Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this + // re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction). + val controller = + remember(id, EmbeddedTabHost.themeEpoch) { + EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor) + } + + // Keep the URL/back callback fresh (cheap, needs the latest closure). + SideEffect { + controller.onUrlChanged = { newUrl, back -> + if (newUrl != "about:blank") currentUrl = newUrl + canGoBack = back + } + } + + // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. + val chrome = + remember(currentUrl, torOn, proxyAvailable, isFavorite, controller) { + EmbeddedTabChrome( + title = hostLabel(currentUrl), + isSandbox = false, + onReload = { controller.reload() }, + onOpenFull = { FavoriteAppLauncher.launchUrl(context, url) }, + torOn = if (proxyAvailable) torOn else null, + onToggleTor = { + torOn = !torOn + controller.setTor(torOn) + WebAppNetworkRegistry.set(url, torOn) + }, + isFavorite = isFavorite, + onFavorite = { + val favId = "url:$currentUrl" + if (FavoriteAppsRegistry.isFavorite(favId)) { + FavoriteAppsRegistry.remove(favId) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) + } + }, + // NIP-07 grants for a plain web client are keyed per visited origin as `browser:` + // (see NappletBrokerService.BROWSER_IDENTITY_AUTHOR); jump straight to that detail screen. + onPermissions = + browserOrigin(currentUrl)?.let { origin -> + { nav.nav(Route.ConnectedAppDetail("browser:$origin")) } + }, + ) + } + // Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a + // SideEffect so it runs after [setActive]; the host short-circuits the identical remembered instance. + SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) } + + val bottomBarFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems + DisposableEffect(id) { + val token = EmbeddedTabHost.setActive(id) + onDispose { + EmbeddedTabHost.clearActiveIfOwner(token) + EmbeddedTabHost.clearActiveChrome(id) + // Only bottom-row apps stay warm; anything else restarts when it leaves. + if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id) + } + } + + BackHandler(enabled = canGoBack) { controller.back() } + + Scaffold( + bottomBar = { + AppBottomBar(Route.WebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) } + }, + ) { padding -> + // Reserve the full content area; the warm surface, its top sheet, and the loading/error overlay + // are all drawn over these bounds by the tab layer. + Box( + Modifier + .fillMaxSize() + .padding(padding) + .onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) }, + ) + } +} + +/** The host of [url] for the tab title, falling back to the raw string. */ +internal fun hostLabel(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + +/** + * The `scheme://host[:port]` origin of [url] — the exact form the sandbox reports for NIP-07 consent, so + * it matches the `browser:` permission-ledger key. Null when [url] has no usable scheme/host. + */ +internal fun browserOrigin(url: String): String? { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return null + val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return null + val host = uri.host?.takeIf { it.isNotBlank() } ?: return null + return "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt index 359d2b60e4..a878c1c62f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -54,10 +54,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip52Calendar.IcsExport import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index 6cd1e7fa29..3f963bc351 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -57,8 +57,8 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKe import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt index b5e311f8b9..a59c918903 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt @@ -43,10 +43,10 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt index 69322f2b74..4bbc2ba141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt @@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBa import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarScaffoldPadding.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarScaffoldPadding.kt index 5e22270e1a..11f4ed223e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarScaffoldPadding.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarScaffoldPadding.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.composed -import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding /** * Applies the surrounding [com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold]'s reserved diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt index 46b9d8b4d9..6b617323fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt @@ -55,8 +55,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt index 637f0f0d49..0d4c791244 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -118,6 +117,7 @@ class NewCalendarEventViewModel : ViewModel() { participants.clear() participants.addAll(existing.participants().map { it.pubKey }) } + is CalendarDateSlotEvent -> { isAllDay.value = true title.value = existing.title().orEmpty() @@ -162,7 +162,7 @@ class NewCalendarEventViewModel : ViewModel() { if (!::account.isInitialized) return false isUploadingImage.value = true try { - val server = account.settings.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] + val server = account.settings.defaultFileServer val result = UploadOrchestrator().upload( uri = uri, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt index 1abd38f17f..73f1710908 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent class CalendarCollectionsFeedFilter( @@ -70,5 +70,5 @@ class CalendarCollectionsFeedFilter( } } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 9fc8f3aa96..dd6384eb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -58,8 +58,23 @@ fun RefreshingChatroomFeedView( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, scrollStateKey: String? = null, + // Opt-in hook handed the feed's scroll state, so a specific screen (e.g. private DMs) can + // attach scroll-driven loading. No-op for the public-chat / channel callers that don't paginate. + listStateObserver: @Composable (LazyListState) -> Unit = {}, + // Optional footer rendered at the oldest end of the thread (a "load more" / spinner affordance). + // Null for callers that load their whole history at once (public chats / channels). + olderBoundary: (@Composable () -> Unit)? = null, + // Optional per-gap hook: invoked between each message and its next-older neighbour with their + // createdAt bounds, so a caller (private DMs) can draw per-relay paging markers at the depth each + // relay has reached. No-op for callers without per-relay progress. + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + // Optional hoisted load driver: handed the loaded message list and its scroll state once (above the + // LazyColumn), so a caller (private DMs) can drive demand-driven paging off viewport visibility + // rather than per-row composition. No-op for callers that don't paginate. + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> + listStateObserver(listState) RenderChatFeedView( feedContentState, accountViewModel, @@ -69,6 +84,9 @@ fun RefreshingChatroomFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + olderBoundary, + markersInGap, + sentinels, ) } } @@ -83,6 +101,9 @@ fun RenderChatFeedView( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -110,6 +131,9 @@ fun RenderChatFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + olderBoundary, + markersInGap, + sentinels, ) } } @@ -126,9 +150,16 @@ fun ChatFeedLoaded( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() + // Hoisted load driver (above the LazyColumn): pages each relay off viewport visibility, so feed + // reorders no longer re-fire paging. The per-gap markers below are pure UI. + sentinels?.invoke(items.list, listState) + LaunchedEffect(items.list.firstOrNull()) { if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) @@ -169,7 +200,24 @@ fun ChatFeedLoaded( ) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) + + // Per-relay paging markers belonging in the gap toward the next-older message. With the + // reverse layout this draws just above the message (the older side), so a relay's marker + // appears right below the oldest message it has reached and slides down as it pages. + markersInGap?.invoke( + item.event?.createdAt, + items.list + .getOrNull(index + 1) + ?.event + ?.createdAt, + ) } } + + // Reverse layout: a trailing item sits at the highest index, i.e. the visual TOP (oldest end). + // That's where the caller's "load more" affordance / spinner lives. + if (olderBoundary != null) { + item(key = "olderBoundary") { olderBoundary() } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt new file mode 100644 index 0000000000..23f2fcbcbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.DefaultInlineQuoteRenderer +import com.vitorpamplona.amethyst.ui.components.InlineQuoteRenderer +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent + +/** + * Inline-quote renderer provided by [ChatroomMessageCompose] so that a chat + * message quoted inside another chat message's text keeps the chat reply + * design ([ChatroomMessageCompose] with `innerQuote = true`, the same renderer + * the reply row uses) instead of the default quoted-note card. Non-chat events + * (and quotes whose event hasn't arrived yet) fall through to + * [DefaultInlineQuoteRenderer]. + * + * The callbacks are the host bubble's, so tapping a quote of a message in the + * same room scrolls to it, exactly like tapping a reply quote. + */ +fun chatInlineQuoteRenderer( + onWantsToReply: (Note) -> Unit, + onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)?, +) = InlineQuoteRenderer { note, quotesLeft, backgroundColor, accountViewModel, nav -> + WatchNoteEvent( + baseNote = note, + onNoteEventFound = { + if (isChatEvent(note.event)) { + ChatroomMessageCompose( + baseNote = note, + routeForLastRead = null, + innerQuote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, + ) + } else { + DefaultInlineQuoteRenderer.Render(note, quotesLeft, backgroundColor, accountViewModel, nav) + } + }, + onBlank = { + DefaultInlineQuoteRenderer.Render(note, quotesLeft, backgroundColor, accountViewModel, nav) + }, + accountViewModel = accountViewModel, + ) +} + +private fun isChatEvent(event: Event?) = + event is ChatroomKeyable || + event is ChatEvent || + event is IsInPublicChatChannel || + event is LiveActivitiesChatMessageEvent || + event is EphemeralChatEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index d0ac5a38e1..30ca93806a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -43,6 +44,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -81,9 +83,11 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -106,8 +110,19 @@ fun ChatroomMessageCompose( onScrollToNote: ((Note) -> Unit)? = null, shouldHighlight: Boolean = false, onHighlightFinished: (() -> Unit)? = null, + // Replaces the generic "post not found" blank while baseNote's event hasn't loaded. Used for + // reply quotes inside a DM, where the target is simply older than the loaded window (see + // LoadingReplyNote). Null keeps the default blank for every other caller. + onBlank: (@Composable () -> Unit)? = null, ) { - WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { + // Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted + // chat message renders with the chat reply design instead of the quoted-note card. + val inlineQuoteRenderer = + remember(onWantsToReply, onWantsToEditDraft, onScrollToNote) { + chatInlineQuoteRenderer(onWantsToReply, onWantsToEditDraft, onScrollToNote) + } + + val onFound: @Composable () -> Unit = { WatchBlockAndReport( note = baseNote, showHiddenWarning = false, @@ -140,6 +155,14 @@ fun ChatroomMessageCompose( } } } + + CompositionLocalProvider(LocalInlineQuoteRenderer provides inlineQuoteRenderer) { + if (onBlank != null) { + WatchNoteEvent(baseNote = baseNote, onNoteEventFound = onFound, onBlank = onBlank, accountViewModel = accountViewModel) + } else { + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav, onNoteEventFound = onFound) + } + } } @Composable @@ -164,7 +187,7 @@ fun NormalChatNote( if (routeForLastRead != null) { LaunchedEffect(key1 = routeForLastRead) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt()) + accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt(), dismissNotificationId = note.idHex) } } @@ -372,11 +395,28 @@ fun RenderReplyRow( onWantsToEditDraft: (Note) -> Unit, onScrollToNote: ((Note) -> Unit)? = null, ) { - if (!innerQuote && note.replyTo?.lastOrNull() != null) { + val replyTo = note.replyTo?.lastOrNull() + if (!innerQuote && replyTo != null && !isCitedInContent(note, replyTo)) { RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote) } } +/** + * Whether the reply target is already mentioned (`nostr:...` citation) in the middle of + * the message text. If it is, the inline quote renderer draws it at that spot, so the + * reply row would show the same message twice. Happens in MLS kind-9 chats, where the + * `q`-tagged parent of a quote is also cited inline — `Note.replyTo` keeps both replies + * and quotes for that kind. + */ +@Composable +private fun isCitedInContent( + note: Note, + replyTo: Note, +): Boolean = + remember(note.event, replyTo) { + (note.event as? BaseNoteEvent)?.findCitations()?.contains(replyTo.idHex) == true + } + @Composable private fun RenderReply( note: Note, @@ -396,9 +436,23 @@ private fun RenderReply( } } - replyTo.value?.let { note -> + replyTo.value?.let { replyNote -> + // For a DM, a reply target that hasn't arrived isn't lost — it's older than the loaded + // window (and for gift wraps can't be fetched by id). Swap the generic blank for one that + // walks history backward until it surfaces. Pick the pager by the PARENT's protocol; leave + // public chats / marmot groups (not a DM event here) on the default blank. + val replyBlank: (@Composable () -> Unit)? = + when (note.event) { + is ChatMessageEvent, is ChatMessageEncryptedFileHeaderEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP17, accountViewModel) } + } + is PrivateDmEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP04, accountViewModel) } + } + else -> null + } ChatroomMessageCompose( - baseNote = note, + baseNote = replyNote, routeForLastRead = null, innerQuote = true, parentBackgroundColor = bgColor, @@ -407,6 +461,7 @@ private fun RenderReply( onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, onScrollToNote = onScrollToNote, + onBlank = replyBlank, ) } } diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.android.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt similarity index 66% rename from commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.android.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt index 551c4ea03a..7b6ab94391 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.android.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt @@ -18,20 +18,15 @@ * 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.threading +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed -import android.os.Looper +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** - * Android implementation of checkNotInMainThread. - * Uses Looper to detect if running on main thread. + * The Android locale date formatter for the shared [com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard] + * — passed in so the shared (KMP) card carries no `java.time` dependency. Formats a paging reach point + * (epoch seconds) to a short month-year label, e.g. "Jun 2026". */ -actual fun checkNotInMainThread() { - // BuildConfig check removed - commons doesn't have BuildConfig - // Enable this check in debug builds at the app level if needed - if (isMainThread()) { - throw OnMainThreadException("It should not be in the MainThread") - } -} - -private fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() +fun formatHistoryReachDate(epochSeconds: Long): String = SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(epochSeconds * 1000)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt new file mode 100644 index 0000000000..9c5e3e024e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryRelayDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.historySubtitle +import com.vitorpamplona.amethyst.commons.ui.feeds.incompleteSubtitle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +/** Which DM history pager backs the conversation an unloaded reply belongs to. */ +enum class DmReplyProtocol { + // NIP-17 gift wraps. The rumor id of the reply target is NOT queryable on relays (only the outer + // 1059 wrap id is), so the only way to surface it is to keep paging gift-wrap history until the wrap + // carrying it is decrypted — hence we drive the account-wide gift-wrap history pager. + NIP17, + + // NIP-04 legacy DMs (kind 4). Paged per relay for the open conversation. + NIP04, +} + +/** + * The inner-quote placeholder for a reply whose target message has not been paged in yet — used in + * place of the generic [com.vitorpamplona.amethyst.ui.note.BlankNote] ("post not found") that the main + * feeds show. A reply target inside a conversation isn't *missing*, it's simply older than the window + * loaded so far; for gift wraps it can't even be fetched by id. So instead of declaring it lost, this + * card actively walks the conversation's history backward — kicking the protocol's `loadMore` each time + * the previous page settles — until either the target decrypts (the surrounding + * [com.vitorpamplona.amethyst.ui.note.WatchNoteEvent] crossfades the real message in and disposes this) + * or that protocol's history runs dry, at which point it settles into the terminal "not found" text. + * + * It runs regardless of scroll position (no oldest-end gate like the scroll-driven loader) precisely so + * that opening a thread and seeing a reply to something off-screen pulls that something in on its own. + * The drive loop is idempotent and gated on the pager's own `loadingMore`/`exhausted`, so several + * unloaded replies on screen — and the scroll loader — all coalesce onto the same paging window. + */ +@Composable +fun LoadingReplyNote( + protocol: DmReplyProtocol, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + + val loadingFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.loadingMore + DmReplyProtocol.NIP04 -> nip04History.loadingMore + } + val statusFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.status + DmReplyProtocol.NIP04 -> nip04History.status + } + val protocolTag = + when (protocol) { + DmReplyProtocol.NIP17 -> "NIP-17" + DmReplyProtocol.NIP04 -> "NIP-04" + } + + // One snapshot collector instead of five; the fields below are plain reads off it (downstream unchanged). + val status by statusFlow.collectAsStateWithLifecycle() + val exhausted = status.exhausted + val relayCount = status.relayCount + val stalledCount = status.stalledCount + val reachedBack = status.reachedBack + val relayProgress = status.relayProgress + + LaunchedEffect(protocol, loadingFlow, statusFlow) { + // Step the next, older page whenever the previous one has settled and history isn't exhausted. + // The target may surface mid-page (this composable then leaves composition and cancels us); if + // not, we keep walking until the protocol bottoms out and the filter stops passing. + combine(loadingFlow, statusFlow) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { + Log.d("DMPagination") { "reply blank: widen → $protocol advanceAll (searching for unloaded reply)" } + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.advanceAll() + DmReplyProtocol.NIP04 -> nip04History.advanceAll() + } + } + } + + // Tapping opens the same per-relay popup the history card uses, so when the search gives up the user + // can see exactly which relays were reached and which stalled. Empty progress keeps it non-interactive. + var showRelays by remember { mutableStateOf(false) } + if (showRelays) { + DmHistoryRelayDialog(protocolTag, relayProgress, ::formatHistoryReachDate) { showRelays = false } + } + + // Same chrome as DmHistoryLoadingCard (the older-history status card at the oldest end) so an + // unloaded reply reads as the same kind of "reaching back into history" state, just inline in the + // quote: rounded translucent surface, a spinner-in-a-box, then the status line. + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .then(if (relayProgress.isNotEmpty()) Modifier.clickable { showRelays = true } else Modifier), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 2.dp, + ) { + // When the walk gives up it splits the same way the history card does: some relays stalled + // (couldn't reach them — the message may still be out there) vs every relay genuinely bottomed + // out (it really isn't in your history). Either way we say what happened instead of a bare glyph. + val stalledOut = exhausted && stalledCount > 0 + Crossfade(targetState = exhausted, animationSpec = tween(500), label = "loadingReplyState") { isExhausted -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + when { + stalledOut -> + // Stalled-out: same red "…" the per-relay dialog and history card use for unreachable. + Text( + "…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + isExhausted -> + // Genuinely searched everything and it isn't there. + Text( + "✕", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = + if (isExhausted) { + stringRes(R.string.chats_reply_not_found) + } else { + stringRes(R.string.chats_reply_searching_history) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // While loading: which protocol, how many relays, how far back. When it gives up: either + // "N relays unreachable · tap to see which" (stalled) or "searched every relay · tap to see". + Text( + text = + when { + stalledOut -> incompleteSubtitle(stalledCount) + isExhausted -> stringRes(R.string.chats_reply_searched) + else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, ::formatHistoryReachDate) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 7178a3e3c9..7a70aef232 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -41,10 +41,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.Dispatchers @@ -87,7 +89,7 @@ fun CreateGroupScreen( Toast .makeText( context, - "Failed to create group: ${e.message}", + stringRes(context, R.string.marmot_failed_to_create_group, e.message), Toast.LENGTH_LONG, ).show() } @@ -119,7 +121,7 @@ fun CreateGroupScreen( .padding(horizontal = 16.dp), ) { Text( - text = "Create Marmot Group", + text = stringRes(R.string.marmot_create_group_title), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), ) @@ -127,13 +129,13 @@ fun CreateGroupScreen( OutlinedTextField( value = groupName, onValueChange = { groupName = it }, - label = { Text("Group Name") }, + label = { Text(stringRes(R.string.marmot_group_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) Text( - "A new MLS group will be created. You can add members after.", + stringRes(R.string.marmot_create_group_footer), modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -169,23 +171,18 @@ private fun MissingKeyPackageRelayListDialog( ) { AlertDialog( onDismissRequest = onCancel, - title = { Text("KeyPackage Relays not set") }, + title = { Text(stringRes(R.string.marmot_keypackage_relays_not_set_title)) }, text = { - Text( - "You don't have a KeyPackage Relay List yet (MIP-00). " + - "This list tells other people where your KeyPackage is " + - "published so they can invite you to group chats.\n\n" + - "Use your current outbox relays for this?", - ) + Text(stringRes(R.string.marmot_keypackage_relays_not_set_message)) }, confirmButton = { TextButton(onClick = onConfirm) { - Text("Use outbox relays") + Text(stringRes(R.string.marmot_use_outbox_relays)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Skip for now") + Text(stringRes(R.string.marmot_skip_for_now)) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt index 0f27c6e7e6..1314597009 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -87,7 +88,7 @@ fun EditGroupInfoScreen( ) launch(Dispatchers.Main) { Toast - .makeText(context, "Group info updated", Toast.LENGTH_SHORT) + .makeText(context, stringRes(context, R.string.marmot_group_info_updated), Toast.LENGTH_SHORT) .show() } nav.popBack() @@ -97,7 +98,7 @@ fun EditGroupInfoScreen( Toast .makeText( context, - "Failed to update: ${e.message}", + stringRes(context, R.string.marmot_failed_to_update, e.message), Toast.LENGTH_LONG, ).show() } @@ -121,8 +122,8 @@ fun EditGroupInfoScreen( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Group name") }, - placeholder = { Text("Enter group name") }, + label = { Text(stringRes(R.string.marmot_group_name)) }, + placeholder = { Text(stringRes(R.string.marmot_group_name_placeholder)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !isSaving, @@ -133,8 +134,8 @@ fun EditGroupInfoScreen( OutlinedTextField( value = description, onValueChange = { description = it }, - label = { Text("Description") }, - placeholder = { Text("Enter group description (optional)") }, + label = { Text(stringRes(R.string.description)) }, + placeholder = { Text(stringRes(R.string.marmot_group_description_placeholder)) }, modifier = Modifier.fillMaxWidth(), minLines = 3, maxLines = 5, @@ -144,7 +145,7 @@ fun EditGroupInfoScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Changes will be committed to the group via MLS and propagated to all members.", + text = stringRes(R.string.marmot_edit_info_footer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index fe3413d65e..7175395138 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -35,8 +35,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp 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.ui.layouts.DisappearingScaffold @@ -45,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @@ -73,7 +76,7 @@ fun MarmotGroupChatScreen( IconButton(onClick = { nav.popBack() }) { Icon( symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Back", + contentDescription = stringRes(R.string.back), ) } }, @@ -102,11 +105,11 @@ fun MarmotGroupChatScreen( accountViewModel = accountViewModel, ) } else { - Text("Marmot Group") + Text(stringRes(R.string.marmot_group_default_name)) } if (memberCount > 0) { Text( - text = "$memberCount members", + text = pluralStringResource(R.plurals.marmot_member_count, memberCount, memberCount), style = MaterialTheme.typography.bodySmall, ) } @@ -117,7 +120,7 @@ fun MarmotGroupChatScreen( IconButton(onClick = { nav.nav(Route.MarmotGroupInfo(nostrGroupId)) }) { Icon( symbol = MaterialSymbols.GroupAdd, - contentDescription = "Add Member", + contentDescription = stringRes(R.string.marmot_add_member_action), ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index b5936e8f2c..a45ca0ce25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -27,16 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,16 +46,19 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote @@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList @@ -96,18 +96,9 @@ fun MarmotGroupChatView( WatchLifecycleAndUpdateModel(feedViewModel) - val chatroom = - remember(nostrGroupId) { - accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) - } - - DisposableEffect(nostrGroupId) { - chatroom.markAsRead() - onDispose { } - } - - val messageState = remember(nostrGroupId) { TextFieldState() } - val replyTo = remember(nostrGroupId) { mutableStateOf(null) } + val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel") + newMessageModel.init(accountViewModel) + newMessageModel.load(nostrGroupId) // Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS // message in the Notifications screen) into the actual Note once it has @@ -116,14 +107,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) } } @@ -138,8 +129,8 @@ fun MarmotGroupChatView( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, nav = nav, - routeForLastRead = "MarmotGroup/$nostrGroupId", - onWantsToReply = { note -> replyTo.value = note }, + routeForLastRead = marmotGroupLastReadRoute(nostrGroupId), + onWantsToReply = { note -> newMessageModel.reply(note) }, onWantsToEditDraft = { }, ) } @@ -148,8 +139,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, - messageState = messageState, - replyTo = replyTo, + newMessageModel = newMessageModel, accountViewModel = accountViewModel, nav = nav, onMessageSent = { @@ -162,49 +152,59 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, - messageState: TextFieldState, - replyTo: MutableState, + newMessageModel: MarmotNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, onMessageSent: suspend () -> Unit, ) { val scope = rememberCoroutineScope() - val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } + val canPost by remember { derivedStateOf { newMessageModel.canPost() } } val context = LocalContext.current var isUploading by remember { mutableStateOf(false) } - val uploadState = - remember { - ChatFileUploadState( - defaultServer = accountViewModel.account.settings.defaultFileServer, - defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload, - ) - } - // Upload dialog - uploadState.multiOrchestrator?.let { - MarmotGroupFileUploadDialog( - nostrGroupId = nostrGroupId, - state = uploadState, - accountViewModel = accountViewModel, - nav = nav, - onUpload = { onMessageSent() }, - onCancel = uploadState::reset, - ) + DisposableEffect(nostrGroupId) { + onDispose { newMessageModel.userSuggestions?.reset() } } - replyTo.value?.let { + // Upload dialog + newMessageModel.uploadState?.let { uploadState -> + uploadState.multiOrchestrator?.let { + MarmotGroupFileUploadDialog( + nostrGroupId = nostrGroupId, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } + } + + newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { - replyTo.value = null + newMessageModel.clearReply() } } Column(modifier = EditFieldModifier) { + newMessageModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + newMessageModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, + ) + } + ThinPaddingTextField( - state = messageState, + state = newMessageModel.message, + onTextChanged = { newMessageModel.onMessageChanged() }, onContentReceived = { uri, mimeType -> - uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) + newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType))) }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), modifier = Modifier.fillMaxWidth(), shape = EditFieldBorder, placeholder = { @@ -216,9 +216,7 @@ fun MarmotGroupMessageComposer( leadingIcon = { MarmotGalleryLeadingIcon( isUploading = isUploading, - onImageChosen = { selectedMedia -> - uploadState.load(selectedMedia) - }, + onImageChosen = newMessageModel::pickedMedia, ) }, trailingIcon = { @@ -226,33 +224,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() } } } @@ -304,7 +287,7 @@ private fun MarmotGroupFileUploadDialog( remember(nostrGroupId) { accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) } - Text(chatroom.displayName.value ?: "Marmot Group") + Text(chatroom.displayName.value ?: stringRes(R.string.marmot_group_default_name)) }, upload = { scope.launch(Dispatchers.IO) { @@ -314,7 +297,7 @@ private fun MarmotGroupFileUploadDialog( Toast .makeText( context, - "Not a member of this group", + stringRes(context, R.string.marmot_not_a_member), Toast.LENGTH_SHORT, ).show() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 3d55509bd1..65ccc7dcd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -62,10 +62,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboard 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.TextOverflow import androidx.compose.ui.unit.dp 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.marmot.GroupMemberInfo @@ -80,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestio import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.allGoodColor @@ -137,16 +140,16 @@ fun MarmotGroupInfoScreen( IconButton(onClick = { nav.popBack() }) { Icon( symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Back", + contentDescription = stringRes(R.string.back), ) } }, - title = { Text("Group Info") }, + title = { Text(stringRes(R.string.marmot_group_info_title)) }, actions = { IconButton(onClick = { nav.nav(Route.MarmotGroupEditInfo(nostrGroupId)) }) { Icon( symbol = MaterialSymbols.Edit, - contentDescription = "Edit Group Info", + contentDescription = stringRes(R.string.marmot_edit_group_info), ) } IconButton( @@ -155,7 +158,7 @@ fun MarmotGroupInfoScreen( ) { Icon( symbol = MaterialSymbols.AutoMirrored.ExitToApp, - contentDescription = "Leave Group", + contentDescription = stringRes(R.string.marmot_leave_group), tint = MaterialTheme.colorScheme.error, ) } @@ -180,7 +183,7 @@ fun MarmotGroupInfoScreen( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = displayName ?: "Group ${nostrGroupId.take(8)}...", + text = displayName ?: stringRes(R.string.marmot_group_fallback_name, nostrGroupId.take(8)), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) @@ -193,7 +196,7 @@ fun MarmotGroupInfoScreen( ) } Text( - text = "${members.size} members", + text = pluralStringResource(R.plurals.marmot_member_count, members.size, members.size), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -226,7 +229,7 @@ fun MarmotGroupInfoScreen( item { Text( - text = "Members", + text = stringRes(R.string.members), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), @@ -285,7 +288,7 @@ fun MarmotGroupInfoScreen( onAdd = { user -> isAdding = true isAddError = false - addStatus = "Adding ${user.toBestDisplayName()}..." + addStatus = stringRes(context, R.string.marmot_adding_user, user.toBestDisplayName()) val targetPubkey = user.pubkeyHex val targetName = user.toBestDisplayName() scope.launch(Dispatchers.IO) { @@ -297,11 +300,11 @@ fun MarmotGroupInfoScreen( addSearchInput = "" userSuggestions.reset() } else { - addStatus = "Failed to add $targetName: ${result.removePrefix("Error: ")}" + addStatus = stringRes(context, R.string.marmot_failed_to_add_user, targetName, result.removePrefix("Error: ")) isAddError = true } } catch (e: Exception) { - addStatus = "Failed to add $targetName: ${e.message ?: "unknown error"}" + addStatus = stringRes(context, R.string.marmot_failed_to_add_user, targetName, e.message ?: stringRes(context, R.string.marmot_unknown_error)) isAddError = true } finally { isAdding = false @@ -314,7 +317,7 @@ fun MarmotGroupInfoScreen( if (showLeaveDialog) { LeaveGroupDialog( - groupName = displayName ?: "this group", + groupName = displayName ?: stringRes(R.string.marmot_this_group), onConfirm = { showLeaveDialog = false isLeaving = true @@ -328,7 +331,7 @@ fun MarmotGroupInfoScreen( Toast .makeText( context, - "Failed to leave group: ${e.message}", + stringRes(context, R.string.marmot_failed_to_leave_group, e.message), Toast.LENGTH_LONG, ).show() } @@ -350,7 +353,7 @@ fun MarmotGroupInfoScreen( accountViewModel.removeMarmotGroupMember(nostrGroupId, member.leafIndex) launch(Dispatchers.Main) { Toast - .makeText(context, "Member removed", Toast.LENGTH_SHORT) + .makeText(context, stringRes(context, R.string.marmot_member_removed), Toast.LENGTH_SHORT) .show() } } catch (e: Exception) { @@ -358,7 +361,7 @@ fun MarmotGroupInfoScreen( Toast .makeText( context, - "Failed to remove member: ${e.message}", + stringRes(context, R.string.marmot_failed_to_remove_member, e.message), Toast.LENGTH_LONG, ).show() } @@ -380,7 +383,7 @@ fun MarmotGroupInfoScreen( accountViewModel.grantMarmotGroupAdmin(nostrGroupId, member.pubkey) launch(Dispatchers.Main) { Toast - .makeText(context, "Admin privileges granted", Toast.LENGTH_SHORT) + .makeText(context, stringRes(context, R.string.marmot_admin_granted), Toast.LENGTH_SHORT) .show() } } catch (e: Exception) { @@ -388,7 +391,7 @@ fun MarmotGroupInfoScreen( Toast .makeText( context, - "Failed to grant admin: ${e.message}", + stringRes(context, R.string.marmot_failed_to_grant_admin, e.message), Toast.LENGTH_LONG, ).show() } @@ -410,7 +413,7 @@ fun MarmotGroupInfoScreen( accountViewModel.revokeMarmotGroupAdmin(nostrGroupId, member.pubkey) launch(Dispatchers.Main) { Toast - .makeText(context, "Admin privileges revoked", Toast.LENGTH_SHORT) + .makeText(context, stringRes(context, R.string.marmot_admin_revoked), Toast.LENGTH_SHORT) .show() } } catch (e: Exception) { @@ -418,7 +421,7 @@ fun MarmotGroupInfoScreen( Toast .makeText( context, - "Failed to revoke admin: ${e.message}", + stringRes(context, R.string.marmot_failed_to_revoke_admin, e.message), Toast.LENGTH_LONG, ).show() } @@ -451,7 +454,7 @@ private fun AddMemberInline( modifier = SuggestionListDefaultHeightChat, onEmpty = { Text( - "They must have published a KeyPackage (kind:30443) to be added.", + stringRes(R.string.marmot_keypackage_required), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -461,7 +464,7 @@ private fun AddMemberInline( IconButton(onClick = { onAdd(user) }) { Icon( symbol = MaterialSymbols.PersonAdd, - contentDescription = "Add to group", + contentDescription = stringRes(R.string.marmot_add_to_group), tint = MaterialTheme.colorScheme.primary, ) } @@ -487,8 +490,8 @@ private fun AddMemberInline( OutlinedTextField( value = searchInput, onValueChange = onSearchInputChange, - label = { Text("Add member") }, - placeholder = { Text("Name, npub, or NIP-05") }, + label = { Text(stringRes(R.string.marmot_add_member)) }, + placeholder = { Text(stringRes(R.string.marmot_add_member_placeholder)) }, modifier = Modifier .fillMaxWidth() @@ -530,11 +533,13 @@ fun MemberRow( ) Column(modifier = Modifier.weight(1f)) { LoadUser(baseUserHex = member.pubkey, accountViewModel = accountViewModel) { user -> - val displayName = user?.toBestDisplayName() ?: "${member.pubkey.take(16)}..." + val displayName = user?.toBestDisplayName() ?: stringRes(R.string.marmot_user_fallback_name, member.pubkey.take(16)) + val youSuffix = stringRes(R.string.marmot_member_suffix_you) + val adminSuffix = stringRes(R.string.marmot_member_suffix_admin) val suffix = buildString { - if (isMe) append(" (you)") - if (isAdmin) append(" - admin") + if (isMe) append(youSuffix) + if (isAdmin) append(adminSuffix) } Text( text = "$displayName$suffix", @@ -549,7 +554,7 @@ fun MemberRow( IconButton(onClick = onPromoteClick) { Icon( symbol = MaterialSymbols.StarBorder, - contentDescription = "Grant admin privileges", + contentDescription = stringRes(R.string.marmot_grant_admin_privileges), tint = MaterialTheme.colorScheme.primary, ) } @@ -557,7 +562,7 @@ fun MemberRow( IconButton(onClick = onDemoteClick) { Icon( symbol = MaterialSymbols.Star, - contentDescription = "Revoke admin privileges", + contentDescription = stringRes(R.string.marmot_revoke_admin_privileges), tint = MaterialTheme.colorScheme.primary, ) } @@ -566,7 +571,7 @@ fun MemberRow( IconButton(onClick = onRemoveClick) { Icon( symbol = MaterialSymbols.PersonRemove, - contentDescription = "Remove Member", + contentDescription = stringRes(R.string.marmot_remove_member), tint = MaterialTheme.colorScheme.error, ) } @@ -582,18 +587,18 @@ fun LeaveGroupDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Leave Group") }, + title = { Text(stringRes(R.string.marmot_leave_group)) }, text = { - Text("Are you sure you want to leave \"$groupName\"? You will no longer receive messages from this group.") + Text(stringRes(R.string.marmot_leave_group_confirm, groupName)) }, confirmButton = { TextButton(onClick = onConfirm) { - Text("Leave", color = MaterialTheme.colorScheme.error) + Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringRes(R.string.cancel)) } }, ) @@ -608,21 +613,21 @@ private fun ConfirmRemoveMemberDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Remove Member") }, + title = { Text(stringRes(R.string.marmot_remove_member)) }, text = { LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user -> - val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..." - Text("Are you sure you want to remove \"$name\" from this group?") + val name = user?.toBestDisplayName() ?: stringRes(R.string.marmot_user_fallback_name, memberPubkey.take(16)) + Text(stringRes(R.string.marmot_remove_member_confirm, name)) } }, confirmButton = { TextButton(onClick = onConfirm) { - Text("Remove", color = MaterialTheme.colorScheme.error) + Text(stringRes(R.string.remove), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringRes(R.string.cancel)) } }, ) @@ -637,24 +642,21 @@ private fun ConfirmGrantAdminDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Grant Admin Privileges") }, + title = { Text(stringRes(R.string.marmot_grant_admin_title)) }, text = { LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user -> - val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..." - Text( - "Make \"$name\" an admin of this group? Admins can add or remove members, " + - "change group info, and grant admin privileges to other members.", - ) + val name = user?.toBestDisplayName() ?: stringRes(R.string.marmot_user_fallback_name, memberPubkey.take(16)) + Text(stringRes(R.string.marmot_grant_admin_confirm, name)) } }, confirmButton = { TextButton(onClick = onConfirm) { - Text("Grant") + Text(stringRes(R.string.marmot_grant)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringRes(R.string.cancel)) } }, ) @@ -669,21 +671,21 @@ private fun ConfirmRevokeAdminDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Revoke Admin Privileges") }, + title = { Text(stringRes(R.string.marmot_revoke_admin_title)) }, text = { LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user -> - val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..." - Text("Revoke admin privileges from \"$name\"? They will remain a member of the group.") + val name = user?.toBestDisplayName() ?: stringRes(R.string.marmot_user_fallback_name, memberPubkey.take(16)) + Text(stringRes(R.string.marmot_revoke_admin_confirm, name)) } }, confirmButton = { TextButton(onClick = onConfirm) { - Text("Revoke", color = MaterialTheme.colorScheme.error) + Text(stringRes(R.string.marmot_revoke), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringRes(R.string.cancel)) } }, ) @@ -817,7 +819,7 @@ fun RelayHealthSection( Column(modifier = Modifier.fillMaxWidth()) { Text( - text = "Relays", + text = stringRes(R.string.marmot_relays_header), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), @@ -854,9 +856,9 @@ private fun RelayHealthRow( } val subtitle = if (lastSeen == null) { - "no events yet" + stringRes(R.string.marmot_relay_no_events) } else { - "last event${timeAgo(lastSeen, context)}" + stringRes(R.string.marmot_relay_last_event, timeAgo(lastSeen, context)) } Row( modifier = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupLastRead.kt similarity index 69% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupLastRead.kt index 929d1f00dd..252fb79e8a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupLastRead.kt @@ -18,23 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip59Giftwrap +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -@Immutable -open class WrappedEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - @kotlinx.serialization.Transient - @kotlin.jvm.Transient - var host: HostStub? = null // host event to broadcast when needed -} +/** + * Key into the account's persisted last-read map (`lastReadPerRoute`) for a + * Marmot MLS group. The mark-as-read side (the open chat's feed) and the + * unread indicators (Messages screen row, group list row) must use the + * identical key, or read state silently stops persisting for one of them. + */ +fun marmotGroupLastReadRoute(nostrGroupId: HexKey): String = "MarmotGroup/$nostrGroupId" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index dc91f269fc..a1c57b1f7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -53,12 +53,14 @@ 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.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.marmotGroups.MarmotGroupChatroom @@ -68,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @@ -110,17 +113,17 @@ fun MarmotGroupListScreen( IconButton(onClick = { nav.popBack() }) { Icon( symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Back", + contentDescription = stringRes(R.string.back), ) } }, - title = { Text("Marmot Groups") }, + title = { Text(stringRes(R.string.marmot_groups_title)) }, ) }, floatingActionButton = { FabBottomBarPadded(nav) { FloatingActionButton(onClick = { nav.nav(Route.CreateMarmotGroup) }, shape = CircleShape) { - Icon(MaterialSymbols.Add, contentDescription = "Create Group") + Icon(MaterialSymbols.Add, contentDescription = stringRes(R.string.marmot_create_group)) } } }, @@ -132,7 +135,12 @@ fun MarmotGroupListScreen( onClick = { selectedTab = 0 }, text = { Text( - text = if (knownGroups.isEmpty()) "Known" else "Known (${knownGroups.size})", + text = + if (knownGroups.isEmpty()) { + stringRes(R.string.marmot_tab_known) + } else { + stringRes(R.string.marmot_tab_known_count, knownGroups.size) + }, ) }, ) @@ -141,7 +149,12 @@ fun MarmotGroupListScreen( onClick = { selectedTab = 1 }, text = { Text( - text = if (newRequestGroups.isEmpty()) "New Requests" else "New Requests (${newRequestGroups.size})", + text = + if (newRequestGroups.isEmpty()) { + stringRes(R.string.marmot_tab_new_requests) + } else { + stringRes(R.string.marmot_tab_new_requests_count, newRequestGroups.size) + }, ) }, ) @@ -157,16 +170,21 @@ fun MarmotGroupListScreen( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = if (selectedTab == 0) "No groups yet" else "No invitations", + text = + if (selectedTab == 0) { + stringRes(R.string.marmot_no_groups) + } else { + stringRes(R.string.marmot_no_invitations) + }, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) Text( text = if (selectedTab == 0) { - "Create a group or accept an invitation." + stringRes(R.string.marmot_no_groups_desc) } else { - "When someone adds you to a group it will show up here until you reply." + stringRes(R.string.marmot_no_invitations_desc) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -222,11 +240,17 @@ fun MarmotGroupListItem( onClick: () -> Unit, ) { val displayName by chatroom.displayName.collectAsStateWithLifecycle() - val unread by chatroom.unreadCount.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle() val memberPubkeys = remember(members) { members.map { it.pubkey } } val newestMessage = chatroom.newestMessage + val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(groupId)).collectAsStateWithLifecycle() + // Not remembered: chatroom.messages can shrink without newestMessage or + // lastReadTime changing (pruning, kind:5 deletion of an older message), + // so caching on those keys would serve a stale count. The set is pruned + // to ~100 entries, so counting per recomposition is cheap. + val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime } + Row( modifier = Modifier @@ -260,7 +284,7 @@ fun MarmotGroupListItem( ) } else { Text( - text = "Group ${groupId.take(8)}...", + text = stringRes(R.string.marmot_group_fallback_name, groupId.take(8)), style = MaterialTheme.typography.titleSmall, fontWeight = if (unread > 0) FontWeight.Bold else FontWeight.Normal, maxLines = 1, @@ -278,7 +302,7 @@ fun MarmotGroupListItem( ) } else { Text( - text = "No messages yet", + text = stringRes(R.string.marmot_no_messages_yet), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), @@ -305,7 +329,7 @@ fun MarmotGroupListItem( } } else { Text( - text = "${chatroom.messages.size} msgs", + text = pluralStringResource(R.plurals.marmot_message_count, chatroom.messages.size, chatroom.messages.size), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt index b16d142265..bf512b716d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt @@ -44,13 +44,13 @@ class MarmotFileSender( dimensions = upload.dimensions, blurhash = upload.blurhash, thumbhash = upload.thumbhash, + alt = upload.caption, ) accountViewModel.sendMarmotGroupMediaMessage( nostrGroupId = nostrGroupId, url = upload.url, imeta = imeta, - caption = upload.caption, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt new file mode 100644 index 0000000000..d5501a39cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList + +/** + * Composition state for the Marmot/MLS group message field, mirroring the + * structure of the other chat composers (ChatNewMessageViewModel, + * ChannelNewMessageViewModel, NestNewMessageViewModel): @-mention + * suggestions, reply state, and file-upload state. Sending goes through + * AccountViewModel.sendMarmotGroupMessage, which owns mention tagging. + */ +@Stable +open class MarmotNewMessageViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var nostrGroupId: HexKey? = null + var chatroom: MarmotGroupChatroom? = null + + val message = TextFieldState() + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + var userSuggestions: UserSuggestionState? = null + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + + this.userSuggestions?.reset() + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { chatroom?.members?.value?.mapTo(mutableSetOf()) { it.pubkey } ?: emptySet() }, + ) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + open fun load(nostrGroupId: HexKey) { + if (this.nostrGroupId != nostrGroupId) { + this.nostrGroupId = nostrGroupId + this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + this.message.clearText() + this.replyTo.value = null + } + } + + fun reply(note: Note) { + replyTo.value = note + } + + fun clearReply() { + replyTo.value = null + } + + fun editFromDraft(draftMessage: String) { + message.setTextAndPlaceCursorAtEnd(draftMessage) + } + + fun canPost() = message.text.isNotBlank() + + fun onMessageChanged() { + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestions?.reset() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { + it.replaceCurrentWord(message, message.currentWord(), item) + it.reset() + } + } + + fun pickedMedia(media: ImmutableList) { + uploadState?.load(media) + } + + /** Sends the field's text. Mention rewriting and p-tagging happen in + * AccountViewModel.sendMarmotGroupMessage. Throws on send failure so + * the caller can surface the error. */ + suspend fun sendPost() { + val groupId = nostrGroupId ?: return + val text = message.text.toString().trim() + if (text.isEmpty()) return + + // Capture id+pubKey snapshot before suspending so a slow send + // doesn't race a user-cleared reply state. + val parentEvent = replyTo.value?.event + + accountViewModel.sendMarmotGroupMessage( + nostrGroupId = groupId, + text = text, + replyToInnerEventId = parentEvent?.id, + replyToInnerAuthorPubKey = parentEvent?.pubKey, + ) + + message.clearText() + replyTo.value = null + userSuggestions?.reset() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 06d9bf0532..4632448358 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -26,12 +26,28 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -40,15 +56,23 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Composable @@ -134,6 +158,47 @@ fun ChatroomView( ) } +/** + * Bootstraps history when the conversation has no messages yet (the live tail came back empty for a + * thread whose newest message is older than a week). There's nothing on screen to host the per-relay + * window-limit markers that normally drive paging, so while the feed is empty we step every relay one + * page at a time (each protocol independently, gated on its own loader) until messages appear — at which + * point the on-screen markers take over — or the protocol is exhausted. Once the feed is Loaded this + * does nothing; paging is then purely demand-driven by the markers' visibility. + */ +@Composable +private fun BootstrapHistoryWhenEmpty( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + // Empty only (never the transient Loading navigation flashes through), and debounced below, so + // re-opening a conversation that has messages doesn't kick a hunt. + val needsBootstrap = feedState is FeedState.Empty + + LaunchedEffect(needsBootstrap, giftWrapsHistory) { + if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(giftWrapsHistory.loadingMore, giftWrapsHistory.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { giftWrapsHistory.advanceAll() } + } + LaunchedEffect(needsBootstrap, nip04History) { + if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(nip04History.loadingMore, nip04History.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { nip04History.advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before messages re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + @Composable fun ChatroomViewUI( room: ChatroomKey, @@ -145,6 +210,50 @@ fun ChatroomViewUI( WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) + DisposableEffect(room) { + Log.d("DMPagination") { "convo: OPEN room=${room.hashCode()}" } + onDispose { Log.d("DMPagination") { "convo: CLOSE room=${room.hashCode()}" } } + } + + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() + // One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six + // separate collectors — the status card and the per-relay markers read all of it together anyway. + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val user = accountViewModel.userProfile() + + // Both protocols' per-relay window limits, each carrying the advance() that pulls its own next page. + // Placed in the stream as sentinels (see RelayReachMarkers): a relay pages only while its + // marker is on screen, and keeps paging while it stays there. A protocol drops out once exhausted. + val limits = + remember(nip04Status, giftWrapsStatus, user) { + buildList { + if (!giftWrapsStatus.exhausted) { + giftWrapsStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) + } + } + if (!nip04Status.exhausted) { + nip04Status.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) + } + } + } + } + val nip17Name = stringResource(R.string.chats_history_proto_nip17) + val nip04Name = stringResource(R.string.chats_history_proto_nip04) + + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the detail popup. + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + + BootstrapHistoryWhenEmpty(feedViewModel.feedState, accountViewModel) + Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -163,6 +272,32 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + // One status card per protocol at the oldest end: each shows what it's reaching for + // while it pages and crossfades to "All caught up" when that protocol runs dry. + olderBoundary = { + Column { + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate) + } + }, + // Each relay's window-limit marker, placed at its reached cursor (pure UI). Hidden once + // both protocols are exhausted. + markersInGap = + if (limits.isEmpty()) { + null + } else { + { newer, older -> RelayReachMarkers(limits, newer, older) { syncDetail = it } } + }, + // The hoisted load driver that pulls each relay's next page while its marker is on screen, + // off viewport visibility (see RelayReachSentinels) so feed reorders don't re-page. + sentinels = + if (limits.isEmpty()) { + null + } else { + { items, listState -> + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + } + }, ) } @@ -183,3 +318,16 @@ fun ChatroomViewUI( ) } } + +private fun reachState(p: RelayPagingProgress): RelayReachState = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index b5cc945fcd..9366082074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -36,9 +36,16 @@ class ChatroomQueryState( class ChatroomFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomNip04HistorySubAssembler(client, ::allKeys) + val group = listOf( - ChatroomFilterSubAssembler(client, ::allKeys), + nip04, + nip04History, ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt new file mode 100644 index 0000000000..4cbe77bffb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads older NIP-04 DMs (kind 4) for one conversation by **`until`+`limit` paging, per relay, on + * demand**. Each relay advances exactly one page when the conversation's on-screen window-limit marker + * for that relay asks ([advance]); otherwise it parks. Nothing is walked proactively — a relay pages + * only while its marker is visible and keeps paging while it stays visible. + * + * The per-relay cursors live on the conversation's [Chatroom][com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom] + * (so reopening the room keeps its progress); this class binds the single-active [BackwardRelayPager] + * orchestrator to the open room's cursors on [newSub], builds the (per-relay scoped) NIP-04 REQ + * filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page; + * one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] + * flips once every relay is either done or stalled. + */ +class ChatroomNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + private val pager = BackwardRelayPager("convo.nip04.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun user(key: ChatroomQueryState) = key.account.userProfile() + + override fun list(key: ChatroomQueryState) = key.listId + + // This conversation's persistent paging cursors, held on its Chatroom (per account + room). + private fun cursorsFor(key: ChatroomQueryState) = + key.account.chatroomList + .getOrCreatePrivateChatroom(key.room) + .nip04History + + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ): List? { + val relays = nip04DmRelayRouting(key.room.users, key.account) + if (!key.account.isWriteable() || relays == null) return emptyList() + + // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked + // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. + val armed = pager.armedRelays(relays.all).toSet() + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("convo.nip04.history", key.account) + val scoped = + Nip04DmRelayRouting( + toMeRelays = relays.toMeRelays.filterKeys { it in armed }, + fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, + ) + return filterNip04DMsHistory(key.account, scoped, pager.pageLimit) { relay -> + pager.requestedUntilFor(relay) + } + } + + /** Steps a single [relay] to its next, older page for the open conversation. Driven by its marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For a thread too short to scroll. */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d("DMPagination") { "[convo.nip04.history] advanceAll (empty-thread bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: ChatroomQueryState): Subscription { + // Repoint the single-active orchestrator at this conversation's cursors (on its Chatroom) and the + // relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(cursorsFor(key), key.account.scope) { nip04DmRelayRouting(key.room.users, key.account)?.all } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ChatroomQueryState): SubscriptionListener { + // A just-backgrounded room's subscription can still deliver after the orchestrator rebinds to + // another room; gate the pager (single-active) on whether it's still bound to THIS room's cursors + // so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway. + val myCursors = cursorsFor(key) + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } + } + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt new file mode 100644 index 0000000000..f7c8bfb686 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Always-on **live tail** for one conversation's NIP-04 DMs (kind 4). A fixed one-week floor, no + * upper bound, never widens — older history is loaded by [ChatroomNip04HistorySubAssembler] in + * bounded slices that follow the gift-wrap history window. + */ +class ChatroomNip04SubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("convo.nip04.live") + val loadingMore: StateFlow = windowLoad.loading + + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ): List? = + if (key.account.isWriteable()) { + val sinceTime = DmHistoryTuning.recentBoundary() + val filters = filterNip04DMs(key.room.users, key.account, sinceTime) + windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) + DmRelayLog.log("convo.nip04.live", key.account) + Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (no until) on ${filters?.size ?: 0} relay-filter(s): ${filters?.map { it.relay.url }?.distinct()}" } + filters + } else { + windowLoad.setExpectedRelays(emptySet()) + emptyList() + } + + override fun user(key: ChatroomQueryState) = key.account.userProfile() + + override fun list(key: ChatroomQueryState) = key.listId + + override fun newSub(key: ChatroomQueryState): Subscription { + windowLoad.startLoading(key.account.scope) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index ec277266de..5072ce8db0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -30,19 +29,55 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -fun filterNip04DMs( +/** + * Where a conversation's NIP-04 DMs flow, resolved via the outbox model and **scoped per relay** so + * every filter only names the keys that actually own the relay it is sent to. + * + * Each map is `relay -> the counterpart keys to name in that relay's filter`: + * - [toMeRelays] queries messages **to me** (`authors=[those keys], #p=[me]`). A relay appears here + * when it is my inbox (then the key set is the whole group) and/or a counterpart's outbox (then + * the key set is just the counterparts who publish there). + * - [fromMeRelays] queries messages **from me** (`authors=[me], #p=[those keys]`). A relay appears + * here when it is my outbox (then the key set is the whole group) and/or a counterpart's inbox + * (then the key set is just the counterparts who read there). + * + * Scoping the key set per relay is what keeps us from sending, e.g., `authors=[bob]` to a relay that + * is only charlie's — a filter that relay has no reason to serve. + */ +class Nip04DmRelayRouting( + val toMeRelays: Map>, + val fromMeRelays: Map>, +) { + val all: Set get() = toMeRelays.keys + fromMeRelays.keys +} + +private fun addAll( + map: MutableMap>, + relays: Collection, + keys: Collection, +) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) } + +fun nip04DmRelayRouting( group: Set?, account: Account?, - since: SincePerRelayMap?, -): List? { +): Nip04DmRelayRouting? { if (group.isNullOrEmpty() || account == null) return null val userOutboxRelays = account.homeRelays.flow.value val userInboxRelays = account.dmRelays.flow.value - val groupOutboxRelays = mutableSetOf() - val groupInboxRelays = mutableSetOf() + // relay -> counterpart keys whose messages-to-me we ask that relay for (authors set, #p=[me]). + val toMe = mutableMapOf>() + // relay -> counterpart keys whose copy of my messages we ask that relay for (#p set, authors=[me]). + val fromMe = mutableMapOf>() + // My own relays carry the whole conversation: my inbox holds everyone's messages to me, my outbox + // holds all of mine. Both filters name the full group on these relays. + addAll(toMe, userInboxRelays, group) + addAll(fromMe, userOutboxRelays, group) + + // Each counterpart's own relays only get a filter naming that counterpart: their outbox (where + // they publish their messages to me) and their inbox (where they keep my messages to them). group.forEach { val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) @@ -53,42 +88,81 @@ fun filterNip04DMs( ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } ?: emptyList() - groupOutboxRelays.addAll(outbox) - val inbox = authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } ?: LocalCache.getUserIfExists(it)?.allUsedRelaysOrNull() ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } ?: emptyList() - groupInboxRelays.addAll(inbox) + addAll(toMe, outbox, listOf(it)) + addAll(fromMe, inbox, listOf(it)) } - val toMeRelays = (userInboxRelays + groupOutboxRelays) - val fromMeRelays = (userOutboxRelays + groupInboxRelays) - - return toMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = group.toList(), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = since?.get(it)?.time, - ), - ) - } + - fromMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to group.toList()), - since = since?.get(it)?.time, - ), - ) - } + return Nip04DmRelayRouting(toMe, fromMe) } + +private fun toMeFilter( + relay: NormalizedRelayUrl, + authors: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = authors.toList(), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = since, + until = until, + limit = limit, + ), +) + +private fun fromMeFilter( + relay: NormalizedRelayUrl, + pTags: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to pTags.toList()), + since = since, + until = until, + limit = limit, + ), +) + +/** Live-tail filters: everything since [windowStart], open-ended at the top (new messages keep arriving). */ +fun filterNip04DMs( + group: Set?, + account: Account?, + windowStart: Long, +): List? { + if (group.isNullOrEmpty() || account == null) return null + val relays = nip04DmRelayRouting(group, account) ?: return null + return relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = windowStart, until = null, limit = null) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = windowStart, until = null, limit = null) } +} + +/** + * History filters: a bounded backward page per relay. Each relay is asked for [limit] events older + * than [untilFor]`(relay)` (no `since`), so it can be paged down to empty independently. The author / + * `#p` key set per relay comes straight from [relays], so each relay only sees the keys it owns. + */ +fun filterNip04DMsHistory( + account: Account, + relays: Nip04DmRelayRouting, + limit: Int, + untilFor: (NormalizedRelayUrl) -> Long?, +): List = + relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = null, until = untilFor(relay), limit = limit) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = null, until = untilFor(relay), limit = limit) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 45d481b0af..c5c94e64c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -126,6 +127,11 @@ class ChatNewMessageViewModel : IExpiration { val draftTag = DraftTagState() + // Strong reference to the live cache note for the current draft tag (derived from the + // versions flow below), so LocalCache cannot weakly collect it before a deletion needs it. + var draftNote: AddressableNote? = null + private set + lateinit var accountViewModel: AccountViewModel lateinit var account: Account @@ -134,6 +140,7 @@ class ChatNewMessageViewModel : draftTag.versions.collectLatest { // don't save the first if (it > 0) { + draftNote = account.getOrCreateDraftNote(draftTag.current) accountViewModel.launchSigner { sendDraftSync() } @@ -261,7 +268,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) @@ -322,6 +334,7 @@ class ChatNewMessageViewModel : val oldTag = (draft.event as? AddressableEvent)?.dTag() if (oldTag != null) { draftTag.set(oldTag) + draftNote = account.getOrCreateDraftNote(oldTag) } loadFromDraft(innerNote) } @@ -413,17 +426,17 @@ class ChatNewMessageViewModel : } suspend fun sendPostSync() { - val version = draftTag.current + val draftToDelete = draftNote innerSendPost(null) cancel() accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.account.deleteDraftIgnoreErrors(version) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } suspend fun sendDraftSync() { if (message.text.toString().isBlank()) { - account.deleteDraftIgnoreErrors(draftTag.current) + account.deleteDraftIgnoreErrors(draftNote) } else if (accountViewModel.settings.automaticallyCreateDrafts()) { innerSendPost(draftTag.current) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index b621b520fa..531dec2695 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -66,10 +66,11 @@ class ChatFileSender( blurhash = result.fileHeader.blurHash?.blurhash, thumbhash = result.fileHeader.thumbHash?.thumbhash, ) { + // NIP-94 accessibility description of the file (kept; the deprecated + // generic NIP-31 boilerplate alt is not written). if (!caption.isNullOrEmpty()) { alt(caption) } - contentWarningReason?.let { contentWarning(it) } }, ) @@ -89,9 +90,6 @@ class ChatFileSender( ChatMessageEvent.build(result.url, toUsers) { references(listOf(result.url)) - if (!caption.isNullOrEmpty()) { - alt(caption) - } contentWarningReason?.let { contentWarning(it) } imetas(iMetaAttachments.filterIsIn(setOf(result.url))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index fea85e90bb..521b12ae59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder class ChannelFeedFilter( val channel: Channel, @@ -44,5 +44,5 @@ class ChannelFeedFilter( .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } .toSet() - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index f65be46e81..c16ec99623 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -136,12 +136,15 @@ private fun ChannelMetadataScaffold( accountViewModel: AccountViewModel, nav: INav, ) { + val channelRelays by postViewModel.channelRelays.collectAsStateWithLifecycle() + val canPost = postViewModel.canPost && channelRelays.isNotEmpty() + Scaffold( topBar = { if (postViewModel.isNewChannel()) { CreatingTopBar( titleRes = R.string.public_chat, - isActive = postViewModel::canPost, + isActive = { canPost }, onCancel = { postViewModel.clear() nav.popBack() @@ -163,7 +166,7 @@ private fun ChannelMetadataScaffold( } else { SavingTopBar( titleRes = R.string.public_chat, - isActive = postViewModel::canPost, + isActive = { canPost }, onCancel = { postViewModel.clear() nav.popBack() @@ -183,8 +186,6 @@ private fun ChannelMetadataScaffold( } }, ) { pad -> - val feedState by postViewModel.channelRelays.collectAsStateWithLifecycle() - LazyColumn( Modifier .fillMaxSize() @@ -220,7 +221,7 @@ private fun ChannelMetadataScaffold( ) } - itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.relay }) { _, item -> + itemsIndexed(channelRelays, key = { _, item -> "ChatRelays" + item.relay }) { _, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index f826189c6d..142c025d54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -112,11 +113,17 @@ open class ChannelNewMessageViewModel : IExpiration { val draftTag = DraftTagState() + // Strong reference to the live cache note for the current draft tag (derived from the + // versions flow below), so LocalCache cannot weakly collect it before a deletion needs it. + var draftNote: AddressableNote? = null + private set + init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { // don't save the first if (it > 0) { + draftNote = account.getOrCreateDraftNote(draftTag.current) accountViewModel.launchSigner { sendDraftSync() } @@ -188,7 +195,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) @@ -220,6 +236,7 @@ open class ChannelNewMessageViewModel : val oldTag = (draft.event as? AddressableEvent)?.dTag() if (oldTag != null) { draftTag.set(oldTag) + draftNote = account.getOrCreateDraftNote(oldTag) } loadFromDraft(innerNote) } @@ -292,24 +309,20 @@ open class ChannelNewMessageViewModel : val template = createTemplate() ?: return val channelRelays = channel?.relays() ?: emptySet() - val version = draftTag.current + val draftToDelete = draftNote cancel() - if (channel is LiveActivitiesChannel) { - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { - channelRelays.toList() - } - } else { - accountViewModel.account.signAndSendPrivately(template, channelRelays) + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { + channelRelays.toList() } accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.account.deleteDraftIgnoreErrors(version) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } suspend fun sendDraftSync() { if (message.text.toString().isBlank()) { - account.deleteDraftIgnoreErrors(draftTag.current) + account.deleteDraftIgnoreErrors(draftNote) } else if (accountViewModel.settings.automaticallyCreateDrafts()) { val attachments = mutableSetOf() nip95attachments.forEach { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 16e0941d2f..9633847b23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,6 +42,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle 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.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -63,12 +67,15 @@ import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -260,7 +267,6 @@ private fun MarmotGroupRoomCompose( nav: INav, ) { val displayName by chatroom.displayName.collectAsStateWithLifecycle() - val unread by chatroom.unreadCount.collectAsStateWithLifecycle() val author = lastMessage.author val noteEvent = lastMessage.event @@ -274,13 +280,15 @@ private fun MarmotGroupRoomCompose( stringRes(R.string.marmot_group_no_messages_yet) } + val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(chatroom.nostrGroupId)).collectAsStateWithLifecycle() + ChannelName( channelIdHex = chatroom.nostrGroupId, channelPicture = null, channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) }, channelLastTime = lastMessage.createdAt(), channelLastContent = lastContent, - hasNewMessages = unread > 0, + hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = @@ -338,6 +346,11 @@ private fun UserRoomCompose( accountViewModel: AccountViewModel, nav: INav, ) { + var popupExpanded by remember { mutableStateOf(false) } + // Kept as a State (no `by`) so `.value` is only read inside the firstRow and + // menu-text slots, confining pin-toggle invalidations to those scopes. + val pinnedRooms = accountViewModel.pinnedChatroomsFlow().collectAsStateWithLifecycle() + ChatHeaderLayout( channelPicture = { NonClickableUserPictures( @@ -348,6 +361,15 @@ private fun UserRoomCompose( }, firstRow = { RoomNameDisplay(room, Modifier.weight(1f), accountViewModel) + if (room in pinnedRooms.value) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(R.string.pinned_to_top), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(modifier = StdHorzSpacer) + } TimeAgo(lastMessage.createdAt()) }, secondRow = { @@ -379,7 +401,23 @@ private fun UserRoomCompose( } }, onClick = { nav.nav(Route.Room(room)) }, + onLongClick = { popupExpanded = true }, ) + + DropdownMenu( + expanded = popupExpanded, + onDismissRequest = { popupExpanded = false }, + ) { + DropdownMenuItem( + text = { + Text(stringRes(if (room in pinnedRooms.value) R.string.unpin_conversation else R.string.pin_conversation)) + }, + onClick = { + accountViewModel.toggleChatroomPin(room) + popupExpanded = false + }, + ) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index eeabb81cc3..292f599baf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -25,9 +25,10 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -63,7 +64,7 @@ class ChatroomListKnownFeedFilter( .getOrCreatePublicChatChannel(channelId) .notes .filter { _, it -> account.isAcceptable(it) && it.event != null } - .sortedWith(DefaultFeedOrder) + .sortedByDefaultFeedOrder() .firstOrNull() } @@ -75,7 +76,7 @@ class ChatroomListKnownFeedFilter( .getOrCreateEphemeralChannel(it) .notes .filter { _, it -> account.isAcceptable(it) && it.event != null } - .sortedWith(DefaultFeedOrder) + .sortedByDefaultFeedOrder() .firstOrNull() } @@ -88,7 +89,7 @@ class ChatroomListKnownFeedFilter( } } - return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder) + return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups).toSet()) } override fun updateListWith( @@ -261,7 +262,31 @@ class ChatroomListKnownFeedFilter( return newRelevantPrivateMessages } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List { + val pinned = account.settings.syncedSettings.chats.pinnedChatrooms.value + if (pinned.isEmpty()) return items.sortedByDefaultFeedOrder() + + val me = account.userProfile().pubkeyHex + // Snapshots isPinned + createdAt once per note so the comparator stays consistent + // even if another thread swaps a Note's event mid-sort. Avoids TimSort's + // "Comparison method violates its general contract!" IllegalArgumentException. + return items + .map { Triple(it, isPinned(it, me, pinned), it.createdAt() ?: 0L) } + .sortedWith( + compareByDescending> { it.second } + .thenByDescending { it.third } + .thenBy { it.first.idHex }, + ).map { it.first } + } + + private fun isPinned( + note: Note, + myPubKey: HexKey, + pinned: Set, + ): Boolean { + val room = (note.event as? ChatroomKeyable)?.chatroomKey(myPubKey) ?: return false + return room in pinned + } // Maps a note that represents a public chat row to its channel id. The // representative note for a channel may be the channel's create event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 7cf22d65e9..3b26f37d54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -56,7 +56,7 @@ class ChatroomListNewFeedFilter( } } - return (privateMessages + marmotGroups).sortedWith(DefaultFeedOrder) + return (privateMessages + marmotGroups).sortedByDefaultFeedOrder() } override fun updateListWith( @@ -122,7 +122,7 @@ class ChatroomListNewFeedFilter( if (room != null && ( newNote.author?.pubkeyHex != me.pubkeyHex && - room.senderIntersects(followingKeySet) && + !room.senderIntersects(followingKeySet) && !account.chatroomList.hasSentMessagesTo(roomKey) ) && !account.isAllHidden(roomKey.users) @@ -142,5 +142,5 @@ class ChatroomListNewFeedFilter( return newRelevantPrivateMessages } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 2b9c534502..489b241971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -35,9 +35,16 @@ class ChatroomListState( class ChatroomListFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomListNip04HistorySubAssembler(client, ::allKeys) + val group = listOf( - DMsFromUserFilterSubAssembler(client, ::allKeys), + nip04, + nip04History, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt new file mode 100644 index 0000000000..73a31dab16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads older NIP-04 DMs (kind 4) for the rooms list by **`until`+`limit` paging, per relay, on + * demand** — the same model as the gift-wrap history loader + * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager]), + * across the account's home (outbox, *from me*) + DM (inbox, *to me*) relays. Each relay advances one + * page when its on-screen window-limit marker asks ([advance]); otherwise it parks. Nothing is walked + * proactively. The per-relay cursors live on the account's + * [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList]; the single-active + * [BackwardRelayPager] orchestrator binds to them on [newSub]. + */ +class ChatroomListNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + private fun allRelays(account: Account) = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + + private val pager = BackwardRelayPager("rooms.nip04.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun user(key: ChatroomListState) = key.account.userProfile() + + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? { + val user = user(key) + if (!key.account.isWriteable()) return emptyList() + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + val armed = pager.armedRelays((homeRelays + dmRelays).toSet()) + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("rooms.nip04.history", key.account) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + buildList { + if (relay in homeRelays) add(filterNip04DMsFromMe(user, relay, since = null, until = until, limit = pager.pageLimit)) + if (relay in dmRelays) add(filterNip04DMsToMe(user, relay, since = null, until = until, limit = pager.pageLimit)) + } + } + } + + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d("DMPagination") { "[rooms.nip04.history] advanceAll (empty-feed bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: ChatroomListState): Subscription { + // Repoint the single-active orchestrator at this account's rooms-list NIP-04 cursors (on its + // ChatroomList) and the relays it fans out to, refreshing the flows from the restored progress. + pager.bind(key.account.chatroomList.nip04History, key.account.scope) { allRelays(key.account) } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ChatroomListState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.nip04History + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } + } + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt new file mode 100644 index 0000000000..cd712abcaa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Always-on **live tail** for the account's NIP-04 DMs (kind 4) in the rooms list. Mirrors the + * gift-wrap live tail: a fixed one-week floor, no upper bound, never widens. Older NIP-04 history is + * loaded by [ChatroomListNip04HistorySubAssembler] in bounded slices. + */ +class ChatroomListNip04SubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("rooms.nip04.live") + val loadingMore: StateFlow = windowLoad.loading + + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? = + if (key.account.isWriteable()) { + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + val sinceTime = DmHistoryTuning.recentBoundary() + DmRelayLog.log("rooms.nip04.live", key.account) + Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (no until) fromMe(outbox)=${homeRelays.map { it.url }} toMe(inbox)=${dmRelays.map { it.url }}" } + homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sinceTime) } + + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sinceTime) } + } else { + windowLoad.setExpectedRelays(emptySet()) + emptyList() + } + + override fun user(key: ChatroomListState) = key.account.userProfile() + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + val user = user(key) + windowLoad.startLoading(key.account.scope) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.homeRelays.flow + .collectLatest { invalidateFilters() } + }, + key.account.scope.launch(Dispatchers.IO) { + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index de96b9f2aa..c000c246d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -30,6 +30,8 @@ fun filterNip04DMsFromMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +40,7 @@ fun filterNip04DMsFromMe( kinds = listOf(PrivateDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, + until = until, + limit = limit, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index dcfdab1602..4b1148e066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -30,6 +30,8 @@ fun filterNip04DMsToMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +40,7 @@ fun filterNip04DMsToMe( kinds = listOf(PrivateDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, + until = until, + limit = limit, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 300f36eda9..217cb50f7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -25,15 +25,30 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -41,18 +56,27 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import java.io.Serializable @Composable @@ -62,6 +86,10 @@ fun ChatroomListFeedView( accountViewModel: AccountViewModel, nav: INav, ) { + DisposableEffect(Unit) { + Log.d("DMPagination") { "rooms.list: OPEN" } + onDispose { Log.d("DMPagination") { "rooms.list: CLOSE" } } + } RefresheableBox(feedContentState, true) { SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> CrossFadeState(feedContentState, listState, accountViewModel, nav) @@ -78,6 +106,24 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + // History is exhausted only once BOTH DM protocols have paged to their end (each stops when a + // round of until+limit pages brings nothing back). Until then an empty feed means "still filling", + // not "no conversations" — keep the spinner up rather than flash empty. + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val historyExhausted = giftWrapsStatus.exhausted && nip04Status.exhausted + + // A *genuinely* empty list has no rows to host the per-relay window-limit markers, so we step every + // relay one page at a time to hunt for the first rooms. Gated on FeedState.Empty only (never the + // transient Loading that navigation flashes through) and debounced, so re-opening Messages with rooms + // already loaded does NOT kick a hunt. Once rooms appear the markers take over, demand-driven. + val user = accountViewModel.userProfile() + val bootstrap = feedState is FeedState.Empty + BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.status) { giftWrapsHistory.advanceAll() } + BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.status) { nip04History.advanceAll() } + CrossfadeIfEnabled( targetState = feedState, animationSpec = tween(durationMillis = 100), @@ -85,7 +131,11 @@ private fun CrossFadeState( ) { state -> when (state) { is FeedState.Empty -> { - FeedEmpty { feedContentState.invalidateData() } + if (historyExhausted) { + FeedEmpty { feedContentState.invalidateData() } + } else { + LoadingFeed() + } } is FeedState.FeedError -> { @@ -114,14 +164,60 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() + // One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six + // separate collectors — the status card and the per-relay markers read all of it together anyway. + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val user = accountViewModel.userProfile() + val nip17Name = stringResource(R.string.chats_history_proto_nip17) + val nip04Name = stringResource(R.string.chats_history_proto_nip04) + val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } + val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } + + // Each relay's window limit, carrying the advance() that pulls its OWN next page. Placed in the list + // at its reached depth as a sentinel (see RelayReachMarkers): a relay pages only while its + // marker is on screen and keeps paging while it stays there, so a spam-dense relay never floods — + // you have to scroll through its messages to pull more. A protocol drops out once exhausted. + val limits = + remember(giftWrapsStatus, nip04Status, user) { + buildList { + if (!giftWrapsStatus.exhausted) { + giftWrapsStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) + } + } + if (!nip04Status.exhausted) { + nip04Status.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) + } + } + } + } + + // Hoisted load driver: pulls each relay's next page off viewport visibility, so feed reorders + // (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI. + RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } + + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the per-relay popup so the + // terse divider isn't a dead end — every count/name is one tap from the full breakdown (which relays, + // protocol, how far back each paged). + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - items( + itemsIndexed( items.list, - key = { item -> chatroomLazyKey(item, myPubKey) }, - ) { item -> + key = { _, item -> chatroomLazyKey(item, myPubKey) }, + ) { index, item -> Row(Modifier.fillMaxWidth()) { ChatroomHeaderCompose( item, @@ -133,10 +229,69 @@ private fun FeedLoaded( HorizontalDivider( thickness = DividerThickness, ) + + // Rendered unconditionally at the protocol's oldest room so the card can run its own + // "All caught up" crossfade-and-collapse when that protocol exhausts. + if (index == oldestNip17Index) { + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate) + } + if (index == oldestNip04Index) { + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate) + } + + // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: + // each pulls its relay's next page while it's on screen. olderCreatedAt is null past the + // oldest loaded room, so relays that have reached the bottom of the list sit there. + RelayReachMarkers( + limits, + item.createdAt(), + items.list.getOrNull(index + 1)?.createdAt(), + ) { syncDetail = it } } } } +/** + * Bootstraps history while the rooms list is genuinely empty: steps every relay one page at a time, + * gated only on its own loader, until rooms appear or the protocol exhausts. Once rooms load this stops + * and the per-relay window-limit markers drive paging on demand. + * + * Leads with a debounce so the brief Empty/Loading flash that navigation passes through does NOT trigger + * a hunt; if [active] drops before it elapses (rooms loaded) the effect cancels and nothing pages. + */ +@Composable +private fun BootstrapHistoryWhenEmpty( + active: Boolean, + loadingMore: StateFlow, + status: StateFlow, + advanceAll: () -> Unit, +) { + LaunchedEffect(active, loadingMore, status) { + if (!active) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(loadingMore, status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before the rooms re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + +private fun reachState(p: RelayPagingProgress) = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') + // Stable per-chatroom key — derived from chatroom identity, not the latest // message id, so reorders move the row instead of recreating it. Compose // stores LazyColumn item keys in a SaveableStateHolder, which on Android diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index aad11d03d0..f1d6644a98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -42,11 +42,11 @@ import androidx.compose.ui.Modifier 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.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection -import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt index 6a7a20842c..c72b2061c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder /** * Recent private-DM conversations only (no public channels, ephemeral chats, or @@ -49,6 +49,6 @@ class ShareDMRoomsFeedFilter( } else { null } - }.sortedWith(DefaultFeedOrder) + }.sortedByDefaultFeedOrder() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt index 883704540a..43de6c159f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent /** @@ -77,5 +77,5 @@ class ChessFeedFilter( hiddenUsers = account.hiddenUsers.flow.value, ) - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt index b83b099d87..5bc0455c89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -120,5 +120,5 @@ class CommunityFeedFilter( validator: CommunityRulesValidator?, ): Boolean = validator == null || !violatesCommunityRules(validator, candidate) - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt index 2da71cdd56..95c9c6909f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -142,5 +142,5 @@ class CommunityModerationFeedFilter( validator: CommunityRulesValidator?, ): Boolean = validator == null || !violatesCommunityRules(validator, candidate) - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt index 57d27807d7..05c625e419 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError @@ -43,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt index 3b5decf1a0..67864beb81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter -import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.model.mapNotNullIntoSet import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter @@ -40,19 +39,7 @@ class CommunitiesFeedFilter( override fun followList(): TopFilter = account.settings.defaultCommunitiesFollowList.value - private fun myPubkey(): String = account.userProfile().pubkeyHex - override fun feed(): List { - if (followList() == TopFilter.Mine) { - val me = myPubkey() - val notes = - LocalCache.addressables.filterIntoSet(CommunityDefinitionEvent.KIND) { _, note -> - val noteEvent = note.event - noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me - } - return sort(notes) - } - val filterParams = FilterByListParams.create( followLists = account.liveCommunitiesFollowLists.value, @@ -77,15 +64,6 @@ class CommunitiesFeedFilter( override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) override fun innerApplyFilter(collection: Collection): Set { - if (followList() == TopFilter.Mine) { - val me = myPubkey() - return collection - .filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me - } - } - val filterParams = FilterByListParams.create( followLists = account.liveCommunitiesFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt index 61cef800a0..99af61deea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt @@ -43,15 +43,11 @@ class CommunitiesListSubAssembler( key: CommunitiesListQueryState, since: SincePerRelayMap?, ): List { - val listName = key.listName() + // "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author + // filter scoped to the user, so followsPerRelay() already carries authors=[me]. val defaultSince = key.feedStates.communitiesList.lastNoteCreatedAtIfFilled() - return if (listName == TopFilter.Mine) { - val outbox = key.account.outboxRelays.flow.value - filterCommunitiesMine(key.account.userProfile().pubkeyHex, outbox, since) - } else { - makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince) - } + return makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince) } override fun user(key: CommunitiesListQueryState) = key.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index ac83a79bd9..84ce056f9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -56,11 +56,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableGridFeedContentState @@ -68,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -476,6 +477,8 @@ private fun DiscoverFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchLoadedFeedMedia(loaded, listState, accountViewModel) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -511,6 +514,8 @@ private fun DiscoverFeedColumnsLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchLoadedFeedMedia(loaded, listState, accountViewModel) + LazyVerticalGrid( columns = GridCells.Fixed(2), contentPadding = rememberFeedContentPadding(FeedPadding), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index c0b260a430..8f3a39aa1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent open class DiscoverLongFormFeedFilter( @@ -74,5 +74,5 @@ open class DiscoverLongFormFeedFilter( } } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index b986a8be8e..cac2107755 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -128,6 +129,11 @@ class LongFormPostViewModel : IExpiration { val draftTag = DraftTagState() + // Strong reference to the live cache note for the current draft tag (derived from the + // versions flow below), so LocalCache cannot weakly collect it before a deletion needs it. + var draftNote: AddressableNote? = null + private set + lateinit var accountViewModel: AccountViewModel lateinit var account: Account @@ -136,6 +142,7 @@ class LongFormPostViewModel : draftTag.versions.collectLatest { // don't save the first if (it > 0) { + draftNote = account.getOrCreateDraftNote(draftTag.current) accountViewModel.launchSigner { sendDraftSync() } @@ -237,6 +244,7 @@ class LongFormPostViewModel : val oldTag = (draft.event as? AddressableEvent)?.dTag() if (oldTag != null) { draftTag.set(oldTag) + draftNote = account.getOrCreateDraftNote(oldTag) } loadFromDraft(innerNote) } @@ -324,7 +332,7 @@ class LongFormPostViewModel : suspend fun sendPostSync() { val template = createTemplate() ?: return - val version = draftTag.current + val draftToDelete = draftNote cancel() if (accountViewModel.settings.useTrackedBroadcasts()) { @@ -342,13 +350,13 @@ class LongFormPostViewModel : } accountViewModel.launchSigner { - accountViewModel.account.deleteDraftIgnoreErrors(version) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } suspend fun sendDraftSync() { if (message.text.toString().isBlank() && title.text.isBlank()) { - accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) + accountViewModel.account.deleteDraftIgnoreErrors(draftNote) } else if (accountViewModel.settings.automaticallyCreateDrafts()) { val template = createTemplate() ?: return accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, emptySet()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt index 22c8c7e9b5..1fd3e836cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent open class DiscoverFollowSetsFeedFilter( @@ -74,5 +74,5 @@ open class DiscoverFollowSetsFeedFilter( } } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 500a6cddd5..b77ddd849f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -106,10 +106,17 @@ open class DiscoverLiveFeedFilter( val allParticipants = items.associate { it to counter.countFollowsThatParticipateOn(it, null) } + // Snapshots the status order once per item. convertStatusToOrder reads the + // OnlineChecker cache and the moving five-minute window, both of which can change + // mid-sort (a background online check can update the cache). Reading it lazily inside + // the comparator makes the ordering unstable and TimSort throws + // "Comparison method violates its general contract!". + val statusOrders = items.associateWith { convertStatusToOrder(it.event) } + return items .sortedWith( compareBy( - { convertStatusToOrder(it.event) }, + { statusOrders[it] }, { participantCounts[it] }, { allParticipants[it] }, { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 5574c773b8..0cbfe7a650 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -114,6 +115,11 @@ open class NewProductViewModel : IExpiration { val draftTag = DraftTagState() + // Strong reference to the live cache note for the current draft tag (derived from the + // versions flow below), so LocalCache cannot weakly collect it before a deletion needs it. + var draftNote: AddressableNote? = null + private set + lateinit var accountViewModel: AccountViewModel lateinit var account: Account @@ -122,6 +128,7 @@ open class NewProductViewModel : draftTag.versions.collectLatest { // don't save the first if (it > 0) { + draftNote = account.getOrCreateDraftNote(draftTag.current) accountViewModel.launchSigner { sendDraftSync() } @@ -218,6 +225,7 @@ open class NewProductViewModel : val oldTag = (draft.event as? AddressableEvent)?.dTag() if (oldTag != null) { draftTag.set(oldTag) + draftNote = account.getOrCreateDraftNote(oldTag) } loadFromDraft(innerNote) } @@ -315,18 +323,18 @@ open class NewProductViewModel : suspend fun sendPostSync() { val template = createTemplate() ?: return - val version = draftTag.current + val draftToDelete = draftNote cancel() accountViewModel.account.signAndSendPrivatelyOrBroadcast(template, relayList = { relayList }) accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.account.deleteDraftIgnoreErrors(version) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } suspend fun sendDraftSync() { if (message.text.toString().isBlank()) { - accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) + accountViewModel.account.deleteDraftIgnoreErrors(draftNote) } else if (accountViewModel.settings.automaticallyCreateDrafts()) { val template = createTemplate() ?: return accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 155c40d7b6..b0b9326051 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -46,13 +46,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt index 1afc9346ca..c33397c8c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent class DraftEventsFeedFilter( @@ -52,5 +52,5 @@ class DraftEventsFeedFilter( return noteEvent is DraftWrapEvent && noteEvent.pubKey == account.userProfile().pubkeyHex } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index b112e5264a..bf971317fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -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 = @@ -422,9 +441,9 @@ fun DvmPaymentActions( } if (amountInInvoice != null) { - Text(text = "Pay $amountInInvoice sats to the DVM") + Text(text = stringRes(R.string.dvm_pay_amount_to_dvm, amountInInvoice.toString())) } else { - Text(text = "Pay Invoice from the DVM") + Text(text = stringRes(R.string.dvm_pay_invoice_from_dvm)) } } } else if (amount != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt new file mode 100644 index 0000000000..8b970e9df3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt @@ -0,0 +1,231 @@ +/* + * 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.embed + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.R as CommonsR + +/** + * A bottom **pull-up sheet** for JavaScript console output. Collapsed it's a small grabber at the + * bottom edge of the embedded surface. Pull it up (or tap) to reveal a scrollable log of console + * messages captured from the page via [WebChromeClient.onConsoleMessage]. The page can't draw over + * it (the surface is z-ordered below this layer). + * + * Mirrors [TopControlSheet]'s pattern but anchored at the bottom using a [Box] with + * [Alignment.BottomCenter]. + */ +@Composable +fun BottomConsoleSheet( + logs: List, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier, contentAlignment = Alignment.BottomCenter) { + // Column anchored at BottomCenter. Grabber sits at the top of the column so it rides up + // with the panel as it expands — standard bottom-sheet handle behaviour. + Column( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Grabber — the only touch target when collapsed; stays at the top of the panel when open + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) + .clickable { onExpandedChange(!expanded) } + .draggable( + orientation = Orientation.Vertical, + state = + rememberDraggableState { delta -> + if (delta < -1f) { + onExpandedChange(true) + } else if (delta > 1f) { + onExpandedChange(false) + } + }, + ).padding(horizontal = 16.dp, vertical = 7.dp), + ) { + Spacer( + Modifier + .width(36.dp) + .height(5.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)), + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut(), + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 3.dp, + shadowElevation = 6.dp, + shape = RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp), + ) { + val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.4f).dp + Column(Modifier.fillMaxWidth().heightIn(max = maxHeight)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(CommonsR.string.browser_console_title, logs.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onClear) { + Text( + stringResource(CommonsR.string.browser_console_clear), + style = MaterialTheme.typography.labelSmall, + ) + } + } + HorizontalDivider() + val scrollState = rememberScrollState() + LaunchedEffect(logs.size) { scrollState.scrollTo(Int.MAX_VALUE) } + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + logs.forEach { entry -> + ConsoleLogRow(entry) + } + } + } + } + } + } + } +} + +@Composable +private fun ConsoleLogRow(entry: ConsoleLogEntry) { + val levelColor = consoleLevelColor(entry.level) + val dimColor = MaterialTheme.colorScheme.onSurfaceVariant + val srcShort = + entry.source + .substringAfterLast("/") + .substringAfterLast("\\") + .let { if (it.isBlank()) entry.source.takeLast(20) else it } + Row( + Modifier.fillMaxWidth().padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + consoleLevelChar(entry.level), + color = levelColor, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.width(14.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + buildAnnotatedString { + withStyle(SpanStyle(color = levelColor)) { + append(entry.message) + } + if (srcShort.isNotBlank()) { + withStyle(SpanStyle(color = dimColor)) { + append(" $srcShort:${entry.lineNumber}") + } + } + }, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.weight(1f), + overflow = TextOverflow.Visible, + ) + } +} + +@Composable +private fun consoleLevelColor(level: String): Color { + val warningAmber = if (isSystemInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFE65100) + return when (level.uppercase()) { + "ERROR" -> MaterialTheme.colorScheme.error + "WARNING" -> warningAmber + "TIP" -> MaterialTheme.colorScheme.tertiary + "DEBUG" -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + } +} + +private fun consoleLevelChar(level: String): String = + when (level.uppercase()) { + "ERROR" -> "E" + "WARNING" -> "W" + "TIP" -> "T" + "DEBUG" -> "D" + else -> "I" + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt new file mode 100644 index 0000000000..83875219dc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import androidx.compose.runtime.snapshots.SnapshotStateList + +/** + * A surface controller that exposes JavaScript console output captured from the embedded WebView. + * The [consoleLogs] list is Compose snapshot state so [BottomConsoleSheet] recomposes as messages + * arrive. Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController]. + */ +interface ConsoleBridge { + val consoleLogs: SnapshotStateList + + fun clearConsoleLogs() +} + +data class ConsoleLogEntry( + val level: String, + val message: String, + val source: String, + val lineNumber: Int, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt new file mode 100644 index 0000000000..d0b55dc108 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -0,0 +1,137 @@ +/* + * 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.embed + +import org.json.JSONObject + +/** Parses the `geom` object of an `ime.pagesel` payload into a [SelectionGeometry], or null if absent. */ +fun parseSelectionGeometry(o: JSONObject?): SelectionGeometry? { + if (o == null) return null + return SelectionGeometry( + left = o.optDouble("l", 0.0).toFloat(), + top = o.optDouble("t", 0.0).toFloat(), + right = o.optDouble("r", 0.0).toFloat(), + bottom = o.optDouble("b", 0.0).toFloat(), + startX = o.optDouble("sx", 0.0).toFloat(), + startBottom = o.optDouble("sb", 0.0).toFloat(), + endX = o.optDouble("ex", 0.0).toFloat(), + endBottom = o.optDouble("eb", 0.0).toFloat(), + viewportWidth = o.optDouble("vw", 0.0).toFloat(), + caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null, + caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null, + caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null, + isRange = o.optBoolean("rng", false), + ) +} + +/** + * A cross-process editable: the focused field lives in the embedded WebView (a different process/window + * that can't host the soft keyboard), so the main app keeps the keyboard and relays editing across this + * bridge. The host view ([RemoteImeView]) drives the keyboard from [ImeEvent]s and forwards every edit + * op back via [sendImeOp]. Implemented by the controllers whose surface can take text input. + */ +interface EmbeddedImeBridge { + /** Page → host: a field gained focus, the field blurred, or its text changed outside the keyboard. */ + var onImeEvent: ((ImeEvent) -> Unit)? + + /** Host → page: a single IME op as a JSON envelope (`{type:"ime.commit", ...}`). */ + fun sendImeOp(json: String) +} + +/** What the focused page field reports up to the host keyboard. */ +sealed interface ImeEvent { + /** A field took focus; carries enough to configure the keyboard and seed the editing buffer. */ + data class Focus( + val inputType: String, + val enterKeyHint: String, + val multiline: Boolean, + val text: String, + val selStart: Int, + val selEnd: Int, + val geometry: SelectionGeometry? = null, + ) : ImeEvent + + /** The field lost focus — dismiss the keyboard. */ + data object Blur : ImeEvent + + /** The page changed the field's text/selection itself; resync the keyboard's buffer. */ + data class State( + val text: String, + val selStart: Int, + val selEnd: Int, + val geometry: SelectionGeometry? = null, + ) : ImeEvent + + /** + * Ordinary page text (not an input) gained or lost a selection. The embedded WebView can't present + * Chrome's copy toolbar/handles in its cross-process surface, so the host draws its own over the page; + * [text] is the selected text to put on the clipboard, [geometry] places the toolbar + drag handles. + */ + data class PageSelection( + val active: Boolean, + val text: String, + val geometry: SelectionGeometry?, + ) : ImeEvent + + /** + * The embedded page started ([active] true) or finished ([active] false) scrolling. Host-drawn selection + * UI hides while scrolling (its geometry is stale mid-scroll) and reappears, repositioned, on settle — + * the shim re-reports geometry just before the `active=false` so the overlays land in the right place. + */ + data class Scroll( + val active: Boolean, + ) : ImeEvent + + /** + * The user tapped the focused field, placing a bare caret. Re-shows the insertion handle even when the + * tap didn't move the caret (so no [State] fired) — native shows the handle on every tap. [geometry] + * carries the caret rect. Gated host-side to non-empty fields, like `Editor`. + */ + data class CaretTap( + val geometry: SelectionGeometry?, + ) : ImeEvent +} + +/** + * Selection geometry in the page's CSS px (viewport coords). The host scales by surface-width / [viewportWidth] + * to screen px. [left]/[top]/[right]/[bottom] is the bounding box (toolbar anchors above it); ([startX], + * [startBottom]) and ([endX], [endBottom]) are the caret feet where the two drag handles sit. + */ +data class SelectionGeometry( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val startX: Float, + val startBottom: Float, + val endX: Float, + val endBottom: Float, + val viewportWidth: Float, + // Present only when the field's selection is a bare caret (no range): the caret rect, so the host can + // show a draggable insertion handle below it. Null for a range or page-text selection. + val caretX: Float? = null, + val caretTop: Float? = null, + val caretBottom: Float? = null, + // True when this is an in-field *range* selection whose [startX]/[startBottom]/[endX]/[endBottom] are the + // real selection-endpoint feet (not the field-box placeholders). Lets the host hold the range geometry + // through Chrome's transient collapse-to-caret reports during the re-assert fight. + val isRange: Boolean = false, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadOverlay.kt new file mode 100644 index 0000000000..11d4cc4e09 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadOverlay.kt @@ -0,0 +1,77 @@ +/* + * 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.embed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R + +/** + * Cover for an embedded tab's (z-below) surface while it has nothing to show yet: a spinner until the + * page paints, or an error message + Retry when the load failed or stalled. Painted in the app's theme + * background so a slow / blank / failed load isn't a bare black/white void. Shared by the web-app and + * napplet/nsite favorite screens. + */ +@Composable +fun BoxScope.EmbeddedLoadOverlay( + failed: Boolean, + onRetry: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (failed) { + Text( + text = stringResource(R.string.embedded_tab_load_failed), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } else { + CircularProgressIndicator() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadStatus.kt new file mode 100644 index 0000000000..ab42559534 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadStatus.kt @@ -0,0 +1,35 @@ +/* + * 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.embed + +/** + * Main-frame load state of an embedded tab (browser web app or napplet/nsite), reported by its + * controller from the provider's WebView. Lets the favorite screen draw a loading spinner / error+retry + * overlay over the (z-below) surface instead of leaving a bare black/white void on a slow or failed load. + * + * [hasLoadedReal] flips true once a real page has finished, so re-entering a warm, already-loaded tab + * doesn't flash a spinner over working content. + */ +data class EmbeddedLoadStatus( + val isLoading: Boolean = false, + val failed: Boolean = false, + val hasLoadedReal: Boolean = false, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifier.kt new file mode 100644 index 0000000000..f4762f77ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifier.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +/** + * Hoisted state for the embedded-surface selection loupe (magnifier #4). The bubble shows a magnified, + * live slice of the page captured in the `:napplet` provider (host-side `PixelCopy` can't read the sandbox + * surface — see [EmbeddedMagnifierProbe]). While a caret/selection handle is dragged, [anchorPx] tracks the + * drag point in this layer's px (so the bubble follows smoothly even between captures) and [image] is the + * latest decoded frame. Throttle bookkeeping ([lastRequestUptimeMs]/[awaitingFrame]) lives here too but is + * intentionally NOT Compose state — it must not trigger recomposition. + */ +class MagnifierUiState { + var visible by mutableStateOf(false) + var anchorPx by mutableStateOf(Offset.Zero) + var image by mutableStateOf(null) + + var lastRequestUptimeMs: Long = 0L + var awaitingFrame: Boolean = false + + fun hide() { + visible = false + image = null + awaitingFrame = false + } +} + +/** + * The loupe bubble: a rounded, elevated rectangle showing [MagnifierUiState.image], floated above the drag + * point and centered on it horizontally, clamped inside [layerSize]. Like Android's `Magnifier`, it sits a + * little above the finger; if there's no room above (drag near the top), it flips below. Nothing is drawn + * until there's a frame to show. + */ +@Composable +fun Magnifier( + state: MagnifierUiState, + layerSize: IntSize, + bubbleSize: DpSize, +) { + if (!state.visible) return + val img = state.image ?: return + val density = LocalDensity.current + val wPx = with(density) { bubbleSize.width.toPx() } + val hPx = with(density) { bubbleSize.height.toPx() } + val gapPx = with(density) { 28.dp.toPx() } + + val left = (state.anchorPx.x - wPx / 2f).coerceIn(0f, (layerSize.width - wPx).coerceAtLeast(0f)) + val above = state.anchorPx.y - hPx - gapPx + val top = if (above >= 0f) above else state.anchorPx.y + gapPx + + val shape = RoundedCornerShape(12.dp) + Box( + Modifier + .absoluteOffset { IntOffset(left.roundToInt(), top.roundToInt()) } + .size(bubbleSize) + .shadow(8.dp, shape) + .clip(shape) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape), + ) { + // The captured frame is built so its px size ≈ the bubble's px size (source rect = bubble / zoom, + // provider scaled by zoom), so FillBounds maps it 1:1 without distortion. + Image( + bitmap = img, + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier.fillMaxSize(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifierProbe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifierProbe.kt new file mode 100644 index 0000000000..8729500c86 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifierProbe.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +/** + * Magnifier (#4) capture path — see `amethyst/plans/2026-06-25-embed-text-selection-native-parity.md`. + * + * Host-side `PixelCopy` of the sandboxed surface returns `ERROR_SOURCE_NO_DATA` (the WebView pixels live in a + * child SurfaceControl the host never draws into), so the selection loupe's content must be captured INSIDE + * the keyless `:napplet` provider — where the WebView is a real in-window view with real pixels — and shipped + * back over the Messenger channel. A controller whose surface can produce such captures implements this; + * [EmbeddedTabLayer] discovers it by `as?` cast, mirroring how it treats [EmbeddedImeBridge]. + */ +interface EmbeddedMagnifierProbe { + /** A captured loupe frame arrived from the provider. */ + var onMagnifierFrame: ((MagnifierFrame) -> Unit)? + + /** + * Ask the provider for a zoomed slice of the live page centered on ([surfaceX], [surfaceY]) in surface px + * (which equal the remote WebView's view px — the SCVH surface is 1:1 with the SandboxedSdkView). + * [boxWidthPx]×[boxHeightPx] is the source rectangle; the returned image is that, scaled by [zoom]. + */ + fun requestMagnifier( + surfaceX: Float, + surfaceY: Float, + boxWidthPx: Int, + boxHeightPx: Int, + zoom: Float, + ) +} + +/** + * A loupe frame returned by the provider: a PNG ([bytes]) of [width]×[height], plus timing — [captureMs] is + * the provider-side draw+encode cost and [requestStampNanos] echoes the client's send stamp so out-of-order + * frames can be dropped. + */ +data class MagnifierFrame( + val bytes: ByteArray, + val width: Int, + val height: Int, + val captureMs: Double, + val requestStampNanos: Long, +) { + override fun equals(other: Any?) = this === other + + override fun hashCode() = System.identityHashCode(this) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSelectionDrag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSelectionDrag.kt new file mode 100644 index 0000000000..7c33e9712d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSelectionDrag.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Process-level flag: true while the user is dragging a selection/caret handle over an embedded surface. + * + * The left navigation drawer's edge-swipe-to-open competes with a handle drag near the left edge (and with + * the [auto-scroll][EmbeddedTabLayer] edge drag), opening the drawer mid-drag. The drawer reads this to + * suspend its gesture while a handle is held. Kept out of the API-gated [EmbeddedTabHost] so non-R UI (the + * drawer) can read it without a version guard; [EmbeddedTabLayer] is the only writer. + */ +object EmbeddedSelectionDrag { + var dragging by mutableStateOf(false) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt new file mode 100644 index 0000000000..bab6cdd01c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.privacysandbox.ui.client.view.SandboxedSdkView + +/** + * The minimal surface controls [EmbeddedTabHost] needs to keep a session warm regardless of whether + * it's a browser ([com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController]) + * or an nsite/napplet ([com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNostrAppController]). + * + * Warm-keep works by keeping the session's [SandboxedSdkView] **attached** to the window the whole + * time (just moved off-screen when not active) — the privacy-sandbox view only closes its session on + * detach, so an attached-but-hidden view stays alive. [onShown]/[onHidden] let a controller pause its + * applet's JS while hidden; [teardown] tears the session down for good (eviction). + */ +@RequiresApi(Build.VERSION_CODES.R) +interface EmbeddedSurfaceController { + fun attachView(view: SandboxedSdkView) + + /** The session became the visible tab. */ + fun onShown() {} + + /** The session is warm but off-screen; a controller may pause its applet here. */ + fun onHidden() {} + + /** Permanently close the session (unbind the service); used on eviction. */ + fun teardown() + + /** + * Current main-frame load state, so [EmbeddedTabLayer] can draw a loading spinner / error+retry + * overlay over this (z-below) surface — the surface itself sits above the nav screens, so the overlay + * can't live in the screen. The default is "already loaded" (no overlay) for any controller that + * doesn't report state. + */ + val loadStatus: EmbeddedLoadStatus get() = EmbeddedLoadStatus(hasLoadedReal = true) + + /** Set by [EmbeddedTabLayer] for the active tab; notified on the main thread when [loadStatus] changes. */ + var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? + + /** Re-attempt the load from scratch (the overlay's Retry); default no-op. */ + fun retry() {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceTouchHolder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceTouchHolder.kt new file mode 100644 index 0000000000..7b243f54b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceTouchHolder.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import android.content.Context +import android.view.MotionEvent +import android.widget.FrameLayout + +/** + * Host-side wrapper for an embedded sandbox surface ([androidx.privacysandbox.ui.client.view.SandboxedSdkView]). + * + * The surface streams a WebView from the keyless `:napplet` process; touch input is forwarded to it, but + * the WebView — being in another process — **can't** call [requestDisallowInterceptTouchEvent] on this + * process's view ancestors. So any scrollable/gesture ancestor (a Compose scroll container, the nav + * drawer, a pull-to-refresh) steals the vertical drag once it passes touch-slop, and the embedded + * WebView receives `ACTION_CANCEL` instead of the scroll — it never moves. + * + * This wrapper closes that gap: on every touch-down it asks all ancestors not to intercept the gesture, + * so the surface keeps it and scrolls normally. (Compose's `AndroidView` honors this and disables its + * own gesture interception for the pointer.) Taps and the surface's own gestures are unaffected. + */ +class EmbeddedSurfaceTouchHolder( + context: Context, +) : FrameLayout(context) { + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + parent?.requestDisallowInterceptTouchEvent(true) + } + // Never intercept ourselves — the child surface handles the gesture. + return false + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + parent?.requestDisallowInterceptTouchEvent(true) + } + return super.dispatchTouchEvent(ev) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt new file mode 100644 index 0000000000..fc28e0cf7d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +/** + * The controls a running app surface offers through its top pull-down sheet — described as plain data so + * [EmbeddedTabLayer] can draw the sheet over the (z-below) surface for the active tab. Deliberately not a + * corner pill: that's where a site usually puts the user's own avatar/menu, so the handle lives at the + * top-center instead and only the actions the surface actually supports are shown. + */ +data class EmbeddedTabChrome( + val title: String, + /** A sandboxed napplet/nsite (shows the shield + "what it can access"), vs a plain web client. */ + val isSandbox: Boolean, + val onReload: () -> Unit, + val onOpenFull: () -> Unit, + /** Current Tor state, or null when this surface has no Tor toggle (Tor off / locked napplet). */ + val torOn: Boolean? = null, + val onToggleTor: () -> Unit = {}, + /** The "what it can access" sheet, for sandboxed napplets/nsites; null for a plain web client. */ + val onInfo: (() -> Unit)? = null, + /** + * Opens this app's editable permission screen (the "Connected Apps" detail) so the user can change + * trust level and per-capability grants as they browse; null when the surface has no managed identity. + */ + val onPermissions: (() -> Unit)? = null, + /** Whether the current URL/app is already saved as a favorite. */ + val isFavorite: Boolean = false, + /** Toggles the current site/app in the favorites registry; null when not applicable. */ + val onFavorite: (() -> Unit)? = null, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt new file mode 100644 index 0000000000..fab2842f38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt @@ -0,0 +1,132 @@ +/* + * 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.embed + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import androidx.annotation.RequiresApi +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.model.ThemeType +import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry +import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController +import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNostrAppController + +/** + * The single place that builds a warm embedded-tab controller, so the favorite screens and the + * [EmbeddedTabPreloader] produce byte-for-byte identical sessions keyed by the same id. Whoever calls + * first (the preloader at startup, or the screen on first visit) wins; the other call returns the + * already-warm controller via [EmbeddedTabHost.acquire]. + * + * The ids ("url:" / "nostr:") match [FavoriteApp.id], so warm-keep, bottom-bar + * membership, and the screens all line up on the same session. + */ +@RequiresApi(Build.VERSION_CODES.R) +object EmbeddedTabFactory { + fun webAppId(url: String) = "url:$url" + + fun nostrAppId(coordinate: String) = "nostr:$coordinate" + + /** Acquires (or returns) the warm browser controller for [url], routing over Tor per the site's choice. */ + fun acquireWebApp( + context: Context, + url: String, + backgroundColor: Int, + ): EmbeddedWebAppController = + EmbeddedTabHost.acquire(webAppId(url)) { + val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + val initialUseTor = proxyPort > 0 && WebAppNetworkRegistry.useTor(url) + val themeType = Amethyst.instance.uiPrefs.value.theme.value + val theme = + when (themeType) { + ThemeType.DARK -> "DARK" + ThemeType.LIGHT -> "LIGHT" + ThemeType.SYSTEM -> { + val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT" + } + } + EmbeddedWebAppController(context.applicationContext, proxyPort, initialUseTor, backgroundColor, theme).also { it.bind(url) } + } as EmbeddedWebAppController + + /** + * Acquires (or returns) the warm nsite/napplet controller for [coordinate] using already-resolved + * launch [params] (the caller has the metadata anyway). [backgroundColor] is stamped into the params + * so the sandbox WebView doesn't flash white before paint. + */ + fun acquireNostrApp( + context: Context, + coordinate: String, + params: Bundle, + backgroundColor: Int, + ): EmbeddedNostrAppController { + params.putInt(NappletHostContract.EXTRA_BG_COLOR, backgroundColor) + return EmbeddedTabHost.acquire(nostrAppId(coordinate)) { + EmbeddedNostrAppController(context.applicationContext, params).also { it.bind() } + } as EmbeddedNostrAppController + } + + /** + * Preload entry for a single favorite: builds its warm session if not already warm. Returns true once + * the favorite is warm (or already was), false if it can't be warmed yet (a napplet whose event + * hasn't loaded), so the preloader can retry. + */ + fun preload( + context: Context, + app: FavoriteApp, + backgroundColor: Int, + ): Boolean = + when (app) { + is FavoriteApp.WebApp -> { + // Privacy: never preload a Tor-routed site over clearnet just because Tor hasn't finished + // connecting yet. A site only "wants Tor" when Tor is enabled AND not opted out for it; in + // that case wait until the proxy port is actually up (the preloader retries). When Tor is + // off, or the user opted this site out, clearnet IS the real route — preload immediately. + val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF + val wantsTor = torEnabled && WebAppNetworkRegistry.useTor(app.url) + val torReady = Amethyst.instance.torManager.activePortOrNull.value != null + if (wantsTor && !torReady) { + false + } else { + acquireWebApp(context, app.url, backgroundColor) + true + } + } + is FavoriteApp.NostrApp -> { + if (EmbeddedTabHost.isWarm(nostrAppId(app.coordinate))) { + true + } else { + val params = FavoriteAppLauncher.embedParams(context, app.coordinate) + if (params == null) { + false + } else { + acquireNostrApp(context, app.coordinate, params, backgroundColor) + true + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt new file mode 100644 index 0000000000..bc0ed87804 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -0,0 +1,183 @@ +/* + * 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.embed + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Rect + +/** + * Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm. + * Each warm session's [SandboxedSdkView][androidx.privacysandbox.ui.client.view.SandboxedSdkView] is + * rendered by [EmbeddedTabLayer] and stays attached to the window the whole time, so its WebView (in + * the keyless `:napplet` process) keeps its full JS state; the active one is positioned over the + * current tab's content area, the rest sit off-screen but alive. + * + * Warm-keep is scoped to **bottom-row apps**: a session is retained only while its app is a bottom-bar + * favorite (see [retainOnly], driven by the bottom-bar settings). A favorite opened outside the bottom + * row restarts when it leaves, and a low-memory trim ([evictAll]) drops everything. + * + * State is Compose snapshot state so [EmbeddedTabLayer] recomposes as sessions / the active id / the + * content bounds change. Main-thread only. + */ +@RequiresApi(Build.VERSION_CODES.R) +object EmbeddedTabHost { + class Warm( + val id: String, + val controller: EmbeddedSurfaceController, + ) + + private val warm = mutableStateListOf() + val sessions: List get() = warm + + /** Id of the session shown over the current content area, or null when no embedded tab is on top. */ + var activeId by mutableStateOf(null) + private set + + /** + * Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed + * WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change + * means rebuilding the surface — the favorite screens and the preloader key their acquisition on this + * so they re-acquire a freshly-themed session instead of the stale warm one. + */ + var themeEpoch by mutableStateOf(0) + private set + + /** Window-space bounds of the active tab's reserved content area. */ + var contentBounds by mutableStateOf(Rect.Zero) + private set + + /** The active tab's top-sheet controls, drawn by [EmbeddedTabLayer] over the (z-below) surface. */ + var activeChrome by mutableStateOf(null) + private set + + private var chromeOwner: String? = null + + fun setActiveChrome( + id: String, + chrome: EmbeddedTabChrome, + ) { + // During a nav cross-fade the outgoing screen can recompose once more; ignore its publish so it + // can't clobber the incoming (now-active) tab's chrome. [setActive] runs before this screen's + // first SideEffect, so the active tab's own publish always lands. + if (activeId != id) return + // Screens publish a remembered chrome instance, so a no-op recomposition hands us the same + // object — skip the snapshot write to avoid recomposing the whole tab layer every frame. + if (chromeOwner == id && activeChrome === chrome) return + chromeOwner = id + activeChrome = chrome + } + + fun clearActiveChrome(id: String) { + // Same twin race as the active id: on a same-route re-nav the new instance has already published + // its chrome (same id) before the old one disposes. Only clear when this id is no longer the + // active tab, so the incoming tab's chrome survives. + if (chromeOwner == id && activeId != id) { + chromeOwner = null + activeChrome = null + } + } + + /** Returns the existing warm controller for [id], or creates + registers one via [factory]. */ + fun acquire( + id: String, + factory: () -> EmbeddedSurfaceController, + ): EmbeddedSurfaceController { + warm.firstOrNull { it.id == id }?.let { return it.controller } + val controller = factory() + warm.add(Warm(id, controller)) + return controller + } + + /** True if a warm session already exists for [id] (used by the preloader to skip re-acquiring). */ + fun isWarm(id: String): Boolean = warm.any { it.id == id } + + /** + * Seeds [contentBounds] with an approximate full-content rect when no tab has reported real bounds + * yet, so surfaces preloaded before the user visits any tab lay out at a realistic viewport (and so + * actually download their content) instead of at the 1dp off-screen fallback. A real visit's + * [reportBounds] overwrites this with the exact area. + */ + fun seedBoundsIfUnset(bounds: Rect) { + if (contentBounds == Rect.Zero) contentBounds = bounds + } + + // Monotonic ownership token. Double-tapping a bottom-bar tab pops and re-adds the SAME route, so the + // outgoing screen instance disposes *after* the incoming one has already called setActive with the + // same id. A plain `clearActiveIfMatches(id)` would then null the active id the new instance just set + // (same id → it "matches"), leaving no active tab and blacking the surface out. Tokening each + // setActive lets the disposer clear only if nobody claimed active in the meantime. + private var activeToken = 0L + + /** Marks [id] the active tab and returns the ownership token to hand back to [clearActiveIfOwner]. */ + fun setActive(id: String): Long { + activeId = id + activeToken += 1 + return activeToken + } + + /** Clears the active tab only if [token] is still the latest claim (no newer [setActive] ran). */ + fun clearActiveIfOwner(token: Long) { + if (activeToken == token) activeId = null + } + + fun reportBounds(bounds: Rect) { + contentBounds = bounds + } + + fun evict(id: String) { + val w = warm.firstOrNull { it.id == id } ?: return + if (activeId == id) activeId = null + warm.remove(w) + w.controller.teardown() + } + + /** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */ + fun retainOnly(keep: Set) { + warm + .filter { it.id !in keep } + .forEach { evict(it.id) } + } + + fun evictAll() { + activeId = null + val copy = warm.toList() + warm.clear() + copy.forEach { it.controller.teardown() } + } + + /** + * The app theme changed: tear down every warm session (their WebViews are pinned to the old theme) + * and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions. + * Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen + * re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface. + */ + fun rebuildAllForTheme() { + val copy = warm.toList() + warm.clear() + copy.forEach { it.controller.teardown() } + themeEpoch += 1 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt new file mode 100644 index 0000000000..98584c6460 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -0,0 +1,875 @@ +/* + * 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.embed + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.BitmapFactory +import android.os.Build +import android.os.SystemClock +import android.view.ViewGroup +import androidx.annotation.RequiresApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.privacysandbox.ui.client.view.SandboxedSdkView +import kotlinx.coroutines.delay +import org.json.JSONObject +import kotlin.math.roundToInt + +// How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width. +private val OFFSCREEN_SHIFT = 10_000.dp + +// Per-drag-move auto-scroll step (CSS px) when a handle is dragged into the surface's top/bottom edge zone. +private const val AUTOSCROLL_STEP_CSS = 40.0 + +/** + * Drives the selection loupe from a handle drag: [active] shows/hides the bubble, [fingerLayerPx] is the + * drag point in the tab layer's px (where the bubble floats), and ([surfaceX], [surfaceY]) is the same point + * in surface px (what the provider captures around). Called on drag start/move with `true`, on end with `false`. + */ +private typealias OnMagnify = (active: Boolean, fingerLayerPx: Offset, surfaceX: Float, surfaceY: Float) -> Unit + +/** Sends a CSS-px positional IME op (caret move / selection extend) to the page; [edge] is null for a caret move. */ +private fun EmbeddedImeBridge.sendFieldOp( + type: String, + edge: String?, + cssX: Float, + cssY: Float, +) = sendImeOp( + JSONObject() + .put("type", type) + .apply { if (edge != null) put("edge", edge) } + .put("x", cssX.toDouble()) + .put("y", cssY.toDouble()) + .toString(), +) + +/** + * The persistent surface layer: a full-window overlay (mounted once in the app shell, below the + * navigation drawer and dialogs) that renders **every** warm embedded session's [SandboxedSdkView] and + * keeps it attached. The active session is positioned over the current tab's reserved content area; the + * rest are parked off-screen but stay attached, so their sessions never detach/close — that's what + * preserves their state across tab swaps. + * + * The surface is z-ordered *below* the client window (privacysandbox.ui locks it there), which still + * forwards touch input to the provider yet lets Compose draw over it — so the active tab's + * [TopControlSheet] is rendered on top of the surface here. Each surface is wrapped in an + * [EmbeddedSurfaceTouchHolder] so a scroll gesture isn't stolen by a host-side ancestor (the + * cross-process WebView can't defend its own gesture). + * + * [barFavoriteIds] are the favorites currently configured as bottom-bar tabs; warm-keep is scoped to + * them ([EmbeddedTabHost.retainOnly]), plus whatever is momentarily active. + */ +@OptIn(ExperimentalLayoutApi::class) +@RequiresApi(Build.VERSION_CODES.R) +@Composable +fun EmbeddedTabLayer(barFavoriteIds: List) { + val activeId = EmbeddedTabHost.activeId + + // Keep only bottom-row apps warm (plus the active tab, even mid-removal). A favorite removed from + // the bar drops its warm session here. + LaunchedEffect(barFavoriteIds, activeId) { + EmbeddedTabHost.retainOnly(barFavoriteIds.toSet() + setOfNotNull(activeId)) + } + + val bounds = EmbeddedTabHost.contentBounds + var layerOrigin by remember { mutableStateOf(Offset.Zero) } + var layerSize by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + + // While the soft keyboard is up (hosted by [RemoteImeView] in this window), shrink the active + // surface so its bottom clears the keyboard — the embedded WebView then reflows and scrolls the + // focused field into view. Only the portion of the keyboard that overlaps the surface counts. + // Use the *snapped* animation target rather than the animated `ime` inset: the cross-process surface + // resize is expensive (a SurfaceControlViewHost reconfigure each frame), so we resize once to the + // final height instead of on every frame of the keyboard slide-in/out. + val imeBottomPx = WindowInsets.imeAnimationTarget.getBottom(density) + + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { + layerOrigin = it.positionInWindow() + layerSize = it.size + }, + ) { + EmbeddedTabHost.sessions.forEach { session -> + // Key on the controller too: a theme rebuild replaces the controller under the same id, and + // the new one needs a fresh SandboxedSdkView (the factory below attaches the surface once). + key(session.id, session.controller) { + val active = session.id == activeId + + LaunchedEffect(active) { + if (active) session.controller.onShown() else session.controller.onHidden() + } + + val placement = + if (bounds.width > 0f && bounds.height > 0f) { + with(density) { + // Parked tabs keep the SAME size as the active one and are only shoved + // off-screen, so bringing one back is a pure translation — no resize, no + // surface re-render, no black flash between tabs. (Parking at 1dp forced a + // resize + re-render on every switch, which flashed black for ~1s.) + val left = (bounds.left - layerOrigin.x).toDp() + (if (active) 0.dp else OFFSCREEN_SHIFT) + // Do NOT shrink the surface for the keyboard: resizing reconfigures the cross-process + // SurfaceControlViewHost surface, and the first frame presented after that reconfigure + // stalls ~1s (the per-focus "freeze"). Keep the surface full-size and let the page bring + // the focused field above the keyboard via the shim's scrollIntoView on focus. + @Suppress("UNUSED_EXPRESSION") + imeBottomPx + Modifier + .absoluteOffset(left, (bounds.top - layerOrigin.y).toDp()) + .size(bounds.width.toDp(), bounds.height.toDp()) + } + } else { + // No content bounds reported yet: park tiny off-screen until a tab is shown. + Modifier + .absoluteOffset(x = (-10000).dp) + .size(1.dp) + } + + AndroidView( + // Wrap the surface so it claims scroll gestures from host-side ancestors (the + // cross-process WebView can't request-disallow-intercept itself). + factory = { ctx -> + EmbeddedSurfaceTouchHolder(ctx).apply { + val surface = SandboxedSdkView(ctx) + addView(surface, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + session.controller.attachView(surface) + } + }, + modifier = placement, + ) + } + } + + // Loading / error overlay for the active tab, drawn AFTER the surfaces so it covers the active + // one's (opaque, pre-first-frame) surface — which itself sits above the nav screens, so the overlay + // can't live in the screen. A spinner until a real page paints, or an error+retry when the load + // failed or stalled, so a slow / blank / failed load isn't a bare black/white void. + val activeController = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller + if (activeController != null && bounds.width > 0f && bounds.height > 0f) { + var loadStatus by remember(activeId) { mutableStateOf(activeController.loadStatus) } + var timedOut by remember(activeId) { mutableStateOf(false) } + DisposableEffect(activeId, activeController) { + activeController.onLoadStatusChanged = { loadStatus = it } + onDispose { activeController.onLoadStatusChanged = null } + } + // Safety net: nothing painted and nothing actively loading after a grace period → offer a retry. + LaunchedEffect(activeId, loadStatus) { + timedOut = false + if (!loadStatus.hasLoadedReal && !loadStatus.failed) { + delay(12_000) + timedOut = true + } + } + if (!loadStatus.hasLoadedReal) { + with(density) { + Box( + Modifier + .absoluteOffset( + (bounds.left - layerOrigin.x).toDp(), + (bounds.top - layerOrigin.y).toDp(), + ).size(bounds.width.toDp(), bounds.height.toDp()), + ) { + EmbeddedLoadOverlay( + failed = loadStatus.failed || timedOut, + onRetry = { + timedOut = false + activeController.retry() + }, + ) + } + } + } + } + + // The active tab's top pull-down sheet, drawn AFTER the surfaces so it sits on top of the + // (z-below) surface, anchored to the top of the active tab's reserved bounds. Its expanded state + // is owned here (reset per tab) so we can draw a full-area dismiss scrim behind the open sheet — + // while collapsed, only the small grabber is interactive and page taps pass through. + val chrome = EmbeddedTabHost.activeChrome + val consoleBridge = activeController as? ConsoleBridge + val consoleCount = consoleBridge?.consoleLogs?.size ?: 0 + + if (chrome != null && bounds.width > 0f && bounds.height > 0f) { + var sheetExpanded by remember(activeId) { mutableStateOf(false) } + var consoleShowing by remember(activeId) { mutableStateOf(false) } + var consoleExpanded by remember(activeId) { mutableStateOf(false) } + + if (sheetExpanded) { + Box( + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { sheetExpanded = false }, + ) + } + + with(density) { + TopControlSheet( + chrome = chrome, + expanded = sheetExpanded, + onExpandedChange = { sheetExpanded = it }, + consoleCount = consoleCount, + consoleShowing = consoleShowing, + onConsole = + if (consoleBridge != null) { + { + if (consoleShowing) { + consoleShowing = false + } else { + consoleShowing = true + consoleExpanded = true + } + } + } else { + null + }, + modifier = + Modifier + .absoluteOffset( + (bounds.left - layerOrigin.x).toDp(), + (bounds.top - layerOrigin.y).toDp(), + ).width(bounds.width.toDp()), + ) + } + + // Bottom console panel: opened via the "Console" row in the top pull-down sheet. + if (consoleShowing && consoleBridge != null) { + with(density) { + BottomConsoleSheet( + logs = consoleBridge.consoleLogs, + expanded = consoleExpanded, + onExpandedChange = { consoleExpanded = it }, + onClear = { consoleBridge.clearConsoleLogs() }, + modifier = + Modifier + .absoluteOffset( + (bounds.left - layerOrigin.x).toDp(), + (bounds.top - layerOrigin.y).toDp(), + ).size(bounds.width.toDp(), bounds.height.toDp()), + ) + } + } + } + + // The embedded surface can't host the soft keyboard, so an invisible main-window EditText takes + // it whenever a field in the ACTIVE tab focuses, relaying edits across [EmbeddedImeBridge]. Lives + // here, in the main app window, so it can actually receive the IME. + val context = LocalContext.current + val imeBridge = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller as? EmbeddedImeBridge + val imeView = remember { RemoteImeView(context) } + // The embedded WebView can't show Chrome's copy/paste toolbar or selection handles in its cross-process + // surface, so we draw our own over the page and route actions through the hidden EditText (which mirrors + // the selection). All the show/hide state lives in one [SelectionUiState] so the rules — toolbar hides + // while dragging or scrolling, handles hide while scrolling — are expressed in one place. + val sel = remember { SelectionUiState() } + DisposableEffect(imeBridge) { + imeView.bind(imeBridge) + imeView.onRangeSelectionChanged = { sel.onFieldRangeToggle(it) } + imeView.onEdited = { sel.onEdited() } + imeBridge?.onImeEvent = { event -> + when (event) { + is ImeEvent.Focus -> { + imeView.onPageFocus(event) + // A field took focus → the browser dropped any page-text selection; clear its overlay so + // the stale page handles/Copy bar don't sit above (and steal drags from) the field UI. + sel.onPageSelection(null) + // Cancel any in-flight scroll-hide from the page phase: otherwise the field's own + // selection-reveal scrolls keep it armed and the new field handles/toolbar never appear. + sel.scrolling = false + sel.onFieldGeometry(event.geometry, event.text.isNotEmpty()) + } + ImeEvent.Blur -> { + imeView.onPageBlur() + sel.onBlur() + } + is ImeEvent.State -> { + imeView.onPageState(event) + sel.onFieldGeometry(event.geometry, event.text.isNotEmpty()) + } + is ImeEvent.PageSelection -> sel.onPageSelection(event.takeIf { it.active }) + is ImeEvent.Scroll -> sel.scrolling = event.active + is ImeEvent.CaretTap -> sel.onCaretTap(event.geometry) + } + } + onDispose { + imeBridge?.onImeEvent = null + imeView.onRangeSelectionChanged = null + imeView.onEdited = null + sel.reset() + imeView.onPageBlur() + imeView.bind(null) + } + } + with(density) { + AndroidView( + factory = { imeView }, + modifier = + Modifier + .absoluteOffset( + (bounds.left - layerOrigin.x).toDp().coerceAtLeast(0.dp), + (bounds.top - layerOrigin.y).toDp().coerceAtLeast(0.dp), + ).size(1.dp), + ) + } + + // Native-style auto-hide: the insertion handle disappears after a few seconds of inactivity; the next + // caret tap re-emits geometry and re-shows it. A drag keeps it alive (dragging gate) so it never + // vanishes mid-drag; the key re-arms whenever the caret moves. + val caretForTimeout = sel.insertionHandle + LaunchedEffect(caretForTimeout, sel.dragging) { + if (caretForTimeout != null && !sel.dragging) { + delay(4_000) + sel.hideCaret() + } + } + + // Selection loupe (magnifier #4): while a caret/selection handle is dragged, show a magnified live + // slice of the page. Host-side PixelCopy can't read the sandbox surface (ERROR_SOURCE_NO_DATA), so the + // pixels are captured in the `:napplet` provider and shipped back here (see [EmbeddedMagnifierProbe]). + // The handles report the drag point via [onMagnify]; we track it for the bubble position and ask the + // provider for frames, throttled to one in flight (with a 100 ms timeout) so a fast drag can't flood + // the IPC channel. The bubble follows the finger every move; the bitmap refreshes as frames land. + val magProbe = imeBridge as? EmbeddedMagnifierProbe + val magnifier = remember { MagnifierUiState() } + val magBubble = DpSize(132.dp, 74.dp) + val magZoom = 1.5f + // Source rect (surface px) = bubble px / zoom, so the provider-scaled frame lands ≈ bubble-sized. + val magSrcW = with(density) { (magBubble.width.toPx() / magZoom).roundToInt() } + val magSrcH = with(density) { (magBubble.height.toPx() / magZoom).roundToInt() } + DisposableEffect(magProbe) { + magProbe?.onMagnifierFrame = { frame -> + if (magnifier.visible) { + magnifier.awaitingFrame = false + BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.let { magnifier.image = it.asImageBitmap() } + } + } + onDispose { + magProbe?.onMagnifierFrame = null + magnifier.hide() + } + } + // Auto-scroll (#9): while dragging a handle near the surface's top/bottom edge, nudge the embedded + // content so the selection can keep extending past the viewport — like Android. We scroll on each + // drag-move that's in the edge zone (the finger is usually still micro-moving); the shim keeps the + // overlays up during this programmatic scroll and re-reports geometry so they track. + val edgeZonePx = with(density) { 56.dp.toPx() } + val onMagnify: OnMagnify = { active, fingerPx, surfaceX, surfaceY -> + // Drives the loupe, the toolbar-hide-while-dragging rule (and the dragged handle hides itself + // locally), edge auto-scroll, and suspending the nav drawer's edge swipe — one drag lifecycle. + sel.dragging = active + EmbeddedSelectionDrag.dragging = active + if (active && bounds.height > 0f) { + val oy = bounds.top - layerOrigin.y + val dy = + when { + fingerPx.y < oy + edgeZonePx -> -AUTOSCROLL_STEP_CSS + fingerPx.y > oy + bounds.height - edgeZonePx -> AUTOSCROLL_STEP_CSS + else -> 0.0 + } + if (dy != 0.0) imeBridge?.sendImeOp(JSONObject().put("type", "ime.autoscroll").put("dy", dy).toString()) + } + if (!active || magProbe == null) { + magnifier.hide() + } else { + magnifier.visible = true + magnifier.anchorPx = fingerPx + val nowMs = SystemClock.uptimeMillis() + if (!magnifier.awaitingFrame || nowMs - magnifier.lastRequestUptimeMs > 100L) { + magnifier.awaitingFrame = true + magnifier.lastRequestUptimeMs = nowMs + magProbe.requestMagnifier(surfaceX, surfaceY, magSrcW, magSrcH, magZoom) + } + } + } + + val originX = bounds.left - layerOrigin.x + val originY = bounds.top - layerOrigin.y + val haveBounds = bounds.width > 0f && bounds.height > 0f + + // In-field (/ +
+ +
scroll region — drag up/down here
+ + + diff --git a/tools/napplet-test/README.md b/tools/napplet-test/README.md new file mode 100644 index 0000000000..863f3017ca --- /dev/null +++ b/tools/napplet-test/README.md @@ -0,0 +1,78 @@ +# Napplet test harness + +A self-contained napplet for **on-device verification** of Amethyst's NIP-5D host — it calls every +`window.napplet.*` API and shows each result on screen, so you can confirm the whole +shim → shell → broker → consent round-trip (and the newer `identity.getList/getZaps/getBadges`, +`identity.onChanged`, `keys.onAction`, and `resource.bytes` `nostr:` paths) works on a real device. + +`index.html` is the napplet: read-only checks run on load; publish/upload/pay are behind buttons; live +pushes (`identity.changed`, `keys.action`) land in the top banner. + +## Publish it + +`amy napplet publish` uploads the whole directory to Blossom (BUD-02 signed) and broadcasts the NIP-5D +event in one step, as the account `amy` is logged in as: + +```bash +./gradlew :cli:installDist # builds amy → cli/build/install/amy/bin/amy +amy napplet publish tools/napplet-test \ + --server https://blossom.primal.net \ + --requires identity,relay,storage,value,resource,upload,keys \ + --d napplet-test --title "Napplet Test Harness" \ + --relay wss://relay.damus.io --relay wss://nos.lol +``` + +- `--d` makes it an addressable kind-35129 napplet; omit it for a root kind-15129. +- `--icon https://…/icon.png` sets the square app icon shown on the launcher card (optional; the card + falls back to a colored monogram from the title when absent). +- For a plain static site, use `amy nsite publish --server …` (kind 15128/35128). +- Use the **same key you're logged in as in Amethyst**, so the napplet appears under your account and + the identity reads (`getProfile`, `getFollows`, …) have data. + +Verify it resolves (optional): `amy napplet fetch --d napplet-test`. +List everything you've published: `amy napplet list ` (or `amy nsite list `). + +## Preview in a browser (optional) + +`amy napplet serve` resolves the published manifest and serves its static content locally (each blob +sha256-verified, just like the device host) so you can eyeball that it loads: + +```bash +amy napplet serve --d napplet-test --port 8080 # then open http://127.0.0.1:8080 +``` + +This serves the **files only** — the `window.napplet.*` runtime needs the Amethyst host, so the API +rows won't pass in a plain browser. Use it to confirm the files publish and route correctly. + +## Run on a device + +Build & install the debug app and watch the logs: + +```bash +./gradlew :amethyst:installPlayDebug +adb logcat -s NappletHostActivity NappletBrokerService NappletContentServer +``` + +Logged in as the publishing key, find **"Napplet Test Harness"** in your Apps / Napplets list (or its +feed card) and tap **Open**. It launches in the sandboxed `:napplet` process. + +## What to verify + +- **On load:** each read row turns green. `shell.supports(identity)` = true, `(bogus)` = false. Every + capability prompts for consent the first time. +- **identity.getList/getZaps/getBadges:** open your own profile first so the cache has your lists / + zaps / badges, then relaunch — the rows show your data (empty arrays are valid if you have none). +- **identity.onChanged:** with the napplet open, switch accounts (or log out) → the banner shows + `identity.changed → `. It must NOT fire on the initial load. +- **keys.onAction:** with a hardware keyboard (or `adb shell input keyevent 47` for "S" while holding + Ctrl), press **Ctrl+S** → banner shows `keys.action → save fired`, and the keystroke is consumed. +- **resource.bytes `nostr:`:** paste a `nostr:nevent1…` / `note1…` / `naddr1…` / `npub1…` and run → + it returns the event JSON as a blob. Also try an `https://…` image URL. +- **Side effects (deliberate):** `relay.publish` signs+broadcasts a note as you; `upload.blob` uploads + a tiny blob; `value.payInvoice` pays a BOLT-11 invoice (needs a connected wallet). Each prompts. +- **Security:** the applet has no direct network (a plain `fetch()` inside it fails — CSP + `connect-src 'none'`); an undeclared capability is denied even if you'd allow it. + +## Notes + +- Re-running `amy napplet publish … --d napplet-test` replaces the same addressable event. diff --git a/tools/napplet-test/index.html b/tools/napplet-test/index.html new file mode 100644 index 0000000000..6418d10735 --- /dev/null +++ b/tools/napplet-test/index.html @@ -0,0 +1,159 @@ + + + + + + + Napplet Test Harness + + + +

Napplet Test Harness

+ +
+ +
+

Side-effectful (tap to run)

+ + +
+ + +
+ +
+ + +
+ + + + diff --git a/tools/tor-network-tests/README.md b/tools/tor-network-tests/README.md new file mode 100644 index 0000000000..787d4f47cf --- /dev/null +++ b/tools/tor-network-tests/README.md @@ -0,0 +1,129 @@ +# Tor networking scenario suite + +A repeatable, device-driven test harness for Amethyst's embedded Tor (Arti) +across the network transitions that have historically broken it. These checks +live outside the JVM/instrumented test tree on purpose: they manipulate the +**device's network** (airplane mode, WiFi/cellular, app lifecycle) and assert on +the real `ArtiNative` bootstrap — things a unit test can't reach. + +## When to run this + +Run the suite whenever you touch anything in the Tor management path, and treat +a green run as a release gate for those changes: + +- **Bumping Arti** (`tools/arti-build`, the rebuilt `libarti_android.so`) — a new + Arti version can change bootstrap timing, the SOCKS error mapping, or guard + handling. This is the most important trigger. +- **`TorService.kt`** — lifecycle (`start`/`stop`/`reset`/`resetWithCleanState`), + the bootstrap-timeout handling (`ARTI_ERROR_BOOTSTRAP_TIMEOUT`), or cache/state + wiping. +- **`TorManager.kt`** — the self-heal watchdog, `onNetworkChange`, cooldowns, or + status routing. +- **The dial gate** — `WebsocketBuilder.canConnect` / the `canDial` wiring in + `AppModules.kt` that holds Tor-routed relays until the SOCKS port is ready. +- **The connectivity layer** — `ConnectivityManager` / `RelayProxyClientConnector` + (pause/resume, reconnect-on-change). + +## How it relates to the other tests + +| Layer | Where | Covers | +|-------|-------|--------| +| Pure logic (fast, deterministic) | `amethyst/src/test/.../tor/TorManagerTest.kt` | watchdog, cooldown, `onNetworkChange`, status routing — virtual time, in-memory fakes, no Arti | +| Pure logic (fast) | `amethyst/src/test/.../service/relayClient/TorCircuitHealthTrackerTest.kt` | the Active-but-failing discriminator (companion circuit-health PR) | +| Real Arti, single process | `amethyst/src/androidTest/.../tor/TorBootstrapInstrumentedTest.kt` (`@Ignore`'d) | real `initialize` → `create_bootstrapped` → SOCKS round-trip + `destroy` | +| **Real Arti + real network transitions** | **this suite** | **bootstrap bounding, network change, airplane, pause/resume — end to end** | + +The unit tests are the first line of defense (run in CI). This suite is the +end-to-end backstop you run by hand (or in a device lab) before shipping a Tor +change, because the failures it catches only appear with a real radio. + +## Device setup (once) + +1. Connect **one** device or emulator with internet egress to the Tor network. + An emulator that exposes both WiFi and Cellular (e.g. a stock Pixel AVD) is + ideal — the `wifi_cellular` scenario needs a second transport to fail over to. +2. Install the **debug** build: `./gradlew :amethyst:installPlayDebug`. +3. In the app, set **Tor = INTERNAL** and enable routing relays over Tor + (Settings → Network/Tor). The suite does **not** change Tor settings; it only + manipulates the network and the app lifecycle, so it tests the config the user + actually runs. + +## Running + +```bash +# full suite +tools/tor-network-tests/run.sh + +# a single scenario +tools/tor-network-tests/run.sh offline_bootstrap + +# override the package (e.g. a different flavor) +AMETHYST_PKG=com.vitorpamplona.amethyst.debug tools/tor-network-tests/run.sh +``` + +Exit code is `0` only if every selected scenario passes. The suite restores a +clean network state (airplane off, WiFi+data on) when it finishes. + +## Scenarios + +Each scenario clears logcat, drives a transition, then asserts on Tor's lifecycle +log markers (`SOCKS proxy active`, `bootstrap timed out`, `onNetworkChange`, +`Pausing/Resuming Relay Services`, `OnOpen`). + +### `cold_start` +Force-restart on a healthy network. **Asserts:** Tor reaches `Active`, and the +number of Tor-routed dials issued *before* the SOCKS port was ready stays ~0 +(the #3223 gate). A spike here means the gate regressed and the pool is hammering +a dead proxy during bootstrap. + +### `offline_bootstrap` — the headline regression test for this PR +Wipe Arti state (empty cache), enable airplane mode, then cold-start so +`create_bootstrapped` runs with no network and nothing cached. **Asserts:** the +native bootstrap is *bounded* — a `bootstrap timed out` line appears (the 60s +`tokio::time::timeout` releasing `lifecycleMutex` so the self-heal watchdog can +run), instead of blocking for many minutes. Then restore the network and assert +recovery to `Active`. + +> Without the timeout (`fix: bound Arti bootstrap with a 60s timeout`, this PR), +> `create_bootstrapped` blocks indefinitely holding the lifecycle lock — observed +> wedging at 95s+ on an emulator — and this scenario fails with "no 'bootstrap +> timed out'". That regression is invisible to every JVM test, which is why this +> suite exists. + +### `wifi_cellular` +With Tor Active, disable WiFi (the default route fails over to cellular), wait, +re-enable. **Asserts:** the transport change drives `onNetworkChange` and Tor +returns to `Active`. Catches the "stale guards/circuits from the old network" +class of wedge. + +### `airplane` +Toggle airplane mode on (full offline) then off. **Asserts:** relays pause +cleanly on connectivity loss (no Tor thrash while offline) and Tor + relays +recover on restore. + +### `pause_resume` +Background the app (HOME), wait for the `WhileSubscribed(30s)` wind-down, then +foreground it. **Asserts:** relays pause (`Pausing Relay Services`) while +backgrounded and reconnect (`OnOpen` increases) on resume. The reconnection burst +on resume is the same shape as the post-Active warmup burst, so this also guards +against the circuit-health self-heal firing a false positive on resume. + +## Interpreting failures + +The summary prints `PASS`/`FAIL` per check with a one-line reason. Common ones: + +- `offline_bootstrap` FAIL "no 'bootstrap timed out'" → the native timeout isn't + in the installed `.so` (rebuild `libarti_android.so` from `tools/arti-build`, + or you're on a branch without the fix). +- `cold_start` FAIL "pre-ready doomed dials >5" → the `canDial`/`canConnect` gate + regressed. +- `wifi_cellular` / `airplane` FAIL "did not reach Active" → bootstrap is slow or + wedged on the new network; pull the full log and look for repeated + `ExitTimeout` / `AllGuardsDown`. + +For a deeper look at any run, dump the lifecycle directly: + +```bash +adb logcat -d | grep -E "TorService|TorManager|ManageRelayServices" \ + | grep -iE "Initializing|bootstrap|proxy active|timed out|self-heal|reset|onNetworkChange|Pausing|Resuming" +``` diff --git a/tools/tor-network-tests/run.sh b/tools/tor-network-tests/run.sh new file mode 100755 index 0000000000..2ec088f781 --- /dev/null +++ b/tools/tor-network-tests/run.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# +# Repeatable Tor networking scenario suite for Amethyst (Android). +# +# Drives a connected device/emulator through the network transitions that have +# historically broken Tor (cold start, hostile/offline bootstrap, WiFi<->Cellular +# handover, airplane mode, app pause/resume) and asserts the Tor lifecycle +# invariants from logcat. See README.md for the rationale of each scenario and +# when to re-run (new Arti version, changes to TorService/TorManager/the dial gate). +# +# Usage: +# tools/tor-network-tests/run.sh [scenario ...] +# +# With no args, runs the full suite. Otherwise runs only the named scenarios: +# cold_start offline_bootstrap wifi_cellular airplane pause_resume +# +# Requirements: +# - adb on PATH with exactly one device/emulator connected and Tor egress. +# - The Amethyst *debug* build installed with Tor set to INTERNAL and routing +# relays over Tor (see README "Device setup"). The suite never changes Tor +# settings — it only manipulates the network and the app lifecycle. +# +# Exit code: 0 if every selected scenario passes, 1 otherwise. + +set -u + +PKG="${AMETHYST_PKG:-com.vitorpamplona.amethyst.debug}" +ACT="${AMETHYST_ACTIVITY:-$PKG/com.vitorpamplona.amethyst.ui.MainActivity}" + +# ---- thresholds (mirror the constants in TorService.kt / TorManager.kt) -------- +BOOTSTRAP_DEADLINE_S=120 # cold start should reach Active within this +TIMEOUT_LOG_DEADLINE_S=90 # empty-cache offline start should log the bootstrap timeout within this +RECOVER_DEADLINE_S=120 # after restoring network, Tor should reach Active within this +PAUSE_WINDDOWN_S=60 # backgrounded relays should pause within this (WhileSubscribed=30s + slack) + +PASS=0 +FAIL=0 +RESULTS=() + +# ---- helpers ------------------------------------------------------------------- + +log() { printf ' %s\n' "$*"; } +banner() { printf '\n=== %s ===\n' "$*"; } + +restart_clean_network() { + adb shell cmd connectivity airplane-mode disable >/dev/null 2>&1 + adb shell svc wifi enable >/dev/null 2>&1 + adb shell svc data enable >/dev/null 2>&1 +} + +force_restart_app() { + adb shell am force-stop "$PKG" >/dev/null 2>&1 + sleep 1 + adb shell am start -n "$ACT" >/dev/null 2>&1 +} + +wipe_arti_state() { + adb shell "run-as $PKG rm -rf /data/data/$PKG/files/arti" >/dev/null 2>&1 +} + +# Wait up to $1 seconds for logcat (current buffer) to contain the regex in $2. +# Returns 0 as soon as it appears, 1 on timeout. +wait_for_log() { + local deadline=$1 pattern=$2 i + for (( i = 0; i < deadline; i += 3 )); do + if adb logcat -d 2>/dev/null | grep -qE "$pattern"; then return 0; fi + sleep 3 + done + return 1 +} + +count_log() { adb logcat -d 2>/dev/null | grep -cE "$1"; } + +# Doomed Tor dials issued BEFORE the SOCKS proxy was ready (the #3223 gate must keep this ~0). +pre_ready_dials() { + local first_active + first_active=$(adb logcat -d 2>/dev/null | grep -m1 "SOCKS proxy active" | awk '{print $2}') + [ -z "$first_active" ] && { echo "-1"; return; } + adb logcat -d 2>/dev/null | grep "SOCKS: Connection refused" \ + | awk -v t="$first_active" '$2 < t' | wc -l | tr -d ' ' +} + +record() { # name pass(0/1) detail + if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); RESULTS+=("PASS $1 — $3"); log "PASS: $3" + else FAIL=$((FAIL+1)); RESULTS+=("FAIL $1 — $3"); log "FAIL: $3"; fi +} + +# ---- scenarios ----------------------------------------------------------------- + +scenario_cold_start() { + banner "cold_start — Tor bootstraps to Active on a healthy network, no pre-ready dial storm" + restart_clean_network; sleep 2 + adb logcat -c + force_restart_app + if wait_for_log "$BOOTSTRAP_DEADLINE_S" "SOCKS proxy active"; then + local pre; pre=$(pre_ready_dials) + if [ "$pre" -le 5 ]; then + record cold_start 0 "reached Active; pre-ready doomed dials=$pre (gate OK)" + else + record cold_start 1 "reached Active but pre-ready doomed dials=$pre (>5 — dial gate regressed)" + fi + else + record cold_start 1 "Tor did not reach Active within ${BOOTSTRAP_DEADLINE_S}s" + fi +} + +scenario_offline_bootstrap() { + banner "offline_bootstrap — empty cache + no network must NOT wedge (PR: 60s bootstrap timeout)" + adb shell am force-stop "$PKG" >/dev/null 2>&1; sleep 1 + wipe_arti_state + adb shell cmd connectivity airplane-mode enable >/dev/null 2>&1 + sleep 3 + adb logcat -c + force_restart_app + log "cold-started offline with empty cache; waiting for the bootstrap-timeout log..." + if wait_for_log "$TIMEOUT_LOG_DEADLINE_S" "bootstrap timed out"; then + record offline_bootstrap 0 "native bootstrap bounded — 'bootstrap timed out' logged (lock released for self-heal)" + else + record offline_bootstrap 1 "no 'bootstrap timed out' within ${TIMEOUT_LOG_DEADLINE_S}s — create_bootstrapped is unbounded (PR #3225 not effective)" + fi + log "restoring network; expecting recovery to Active..." + restart_clean_network + if wait_for_log "$RECOVER_DEADLINE_S" "SOCKS proxy active"; then + record offline_bootstrap_recover 0 "reached Active after network restore" + else + record offline_bootstrap_recover 1 "did not reach Active within ${RECOVER_DEADLINE_S}s after restore" + fi +} + +scenario_wifi_cellular() { + banner "wifi_cellular — WiFi off (failover to cellular) then on triggers onNetworkChange + recovery" + restart_clean_network + if ! wait_for_log "$BOOTSTRAP_DEADLINE_S" "SOCKS proxy active"; then + record wifi_cellular 1 "precondition failed: Tor not Active before the handover"; return + fi + adb logcat -c + adb shell svc wifi disable >/dev/null 2>&1 + sleep 20 + adb shell svc wifi enable >/dev/null 2>&1 + sleep 5 + local changes; changes=$(count_log "onNetworkChange|network identity changed") + if wait_for_log "$RECOVER_DEADLINE_S" "SOCKS proxy active"; then + record wifi_cellular 0 "handover recovered to Active (network-change events seen: $changes)" + else + record wifi_cellular 1 "Tor did not return to Active within ${RECOVER_DEADLINE_S}s after the handover" + fi +} + +scenario_airplane() { + banner "airplane — full offline pauses relays cleanly; restore recovers Tor + resumes relays" + restart_clean_network + wait_for_log "$BOOTSTRAP_DEADLINE_S" "SOCKS proxy active" >/dev/null + adb logcat -c + adb shell cmd connectivity airplane-mode enable >/dev/null 2>&1 + if wait_for_log 30 "Connectivity Off|Pausing Relay Services"; then + log "relays paused on connectivity loss" + else + log "WARN: no 'Pausing Relay Services' seen on airplane-on" + fi + sleep 5 + adb shell cmd connectivity airplane-mode disable >/dev/null 2>&1 + if wait_for_log "$RECOVER_DEADLINE_S" "SOCKS proxy active|Resuming Relay Services"; then + record airplane 0 "recovered after airplane off/on" + else + record airplane 1 "did not recover within ${RECOVER_DEADLINE_S}s after airplane off" + fi +} + +scenario_pause_resume() { + banner "pause_resume — backgrounding winds relays down (~30s); resume reconnects them" + restart_clean_network + wait_for_log "$BOOTSTRAP_DEADLINE_S" "SOCKS proxy active" >/dev/null + adb logcat -c + adb shell input keyevent KEYCODE_HOME + if wait_for_log "$PAUSE_WINDDOWN_S" "Pausing Relay Services"; then + record pause 0 "relays paused after backgrounding (WhileSubscribed wind-down)" + else + record pause 1 "relays did not pause within ${PAUSE_WINDDOWN_S}s of backgrounding" + fi + local before; before=$(count_log "OnOpen") + adb shell am start -n "$ACT" >/dev/null 2>&1 + wait_for_log 30 "Resuming Relay Services" >/dev/null + sleep 15 + local after; after=$(count_log "OnOpen") + if [ "$after" -gt "$before" ]; then + record resume 0 "relays reconnected on resume (OnOpen $before -> $after)" + else + record resume 1 "no relay reconnects observed on resume (OnOpen stuck at $before)" + fi +} + +# ---- driver -------------------------------------------------------------------- + +ndev=$(adb devices | grep -cw "device") +if [ "$ndev" -ne 1 ]; then + echo "ERROR: need exactly one connected device/emulator (found $ndev). Set ANDROID_SERIAL or unplug extras." >&2 + exit 2 +fi +if ! adb shell pm list packages 2>/dev/null | grep -q "$PKG"; then + echo "ERROR: $PKG is not installed. Install the debug build first (see README)." >&2 + exit 2 +fi + +scenarios=("$@") +[ ${#scenarios[@]} -eq 0 ] && scenarios=(cold_start offline_bootstrap wifi_cellular airplane pause_resume) + +echo "Tor network suite — package=$PKG, scenarios: ${scenarios[*]}" +for s in "${scenarios[@]}"; do + case "$s" in + cold_start) scenario_cold_start ;; + offline_bootstrap) scenario_offline_bootstrap ;; + wifi_cellular) scenario_wifi_cellular ;; + airplane) scenario_airplane ;; + pause_resume) scenario_pause_resume ;; + *) echo " unknown scenario: $s" ; FAIL=$((FAIL+1)) ; RESULTS+=("FAIL $s — unknown scenario") ;; + esac +done + +restart_clean_network # leave the device in a good state + +banner "SUMMARY" +for r in "${RESULTS[@]}"; do printf ' %s\n' "$r"; done +printf '\n %d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/zapstore.yaml b/zapstore.yaml index 0eafcad529..60a7d4b331 100644 --- a/zapstore.yaml +++ b/zapstore.yaml @@ -34,6 +34,10 @@ website: https://amethyst.social/ # ═══════════════════════════════════════════════════════════════════ # MEDIA # ═══════════════════════════════════════════════════════════════════ + +# App icon (local path or URL). 512x512 Play Store launcher icon. +icon: ./amethyst/src/main/ic_launcher-playstore.png + # Screenshots (local paths or URLs) images: - ./docs/screenshots/home.png @@ -41,6 +45,97 @@ images: - ./docs/screenshots/notifications.png - ./docs/screenshots/replies.png +# ═══════════════════════════════════════════════════════════════════ +# NOSTR METADATA +# ═══════════════════════════════════════════════════════════════════ + +# NIPs implemented by the app (shown on the Zapstore listing). Kept in sync +# with the "Supported NIPs" checklist in README.md (only the checked items). +supported_nips: + - "01" # Basic protocol flow + - "02" # Follow List + - "03" # OpenTimestamps Attestations + - "04" # Encrypted Direct Message + - "05" # DNS-based Identifiers + - "06" # Key Derivation from Mnemonic + - "08" # Handling Mentions + - "09" # Event Deletion Request + - "10" # Text Notes and Threads + - "11" # Relay Information Document + - "13" # Proof of Work + - "14" # Subject Tag in Text Events + - "15" # Nostr Marketplace + - "17" # Private Direct Messages + - "18" # Reposts + - "19" # bech32-encoded Entities + - "21" # nostr: URI Scheme + - "22" # Comment + - "23" # Long-form Content + - "24" # Extra Metadata Fields and Tags + - "25" # Reactions + - "26" # Delegated Event Signing + - "27" # Text Note References + - "28" # Public Chat + - "29" # Relay-based Groups + - "30" # Custom Emoji + - "31" # Dealing with Unknown Events + - "32" # Labeling + - "34" # git stuff + - "35" # Torrents + - "36" # Sensitive Content + - "37" # Draft Events + - "38" # User Statuses + - "39" # External Identities in Profiles + - "40" # Expiration Timestamp + - "42" # Authentication of Clients to Relays + - "43" # Relay Access Metadata and Requests + - "44" # Encrypted Payloads / Versioned + - "45" # Counting Results + - "46" # Nostr Remote Signing + - "47" # Nostr Wallet Connect + - "48" # Proxy Tags + - "49" # Private Key Encryption + - "50" # Search Capability + - "51" # Lists + - "52" # Calendar Events + - "53" # Live Activities + - "54" # Wiki + - "55" # Android Signer Application + - "56" # Reporting + - "57" # Lightning Zaps + - "58" # Badges + - "59" # Gift Wrap + - "5A" # Pubkey Static Websites + - "60" # Cashu Wallet + - "61" # Nutzaps + - "62" # Request to Vanish + - "64" # Chess / PGN + - "65" # Relay List Metadata + - "66" # Relay Discovery and Liveness Monitoring + - "68" # Picture-first Feeds + - "69" # Peer-to-peer Order Events + - "70" # Protected Events + - "71" # Video Events + - "72" # Moderated Communities + - "73" # External Content IDs + - "75" # Zap Goals + - "77" # Negentropy Syncing + - "78" # Application-specific Data + - "7D" # Threads + - "84" # Highlights + - "85" # Trusted Assertions + - "86" # Relay Management API + - "87" # Ecash Mint Discoverability + - "88" # Polls + - "89" # Recommended Application Handlers + - "90" # Data Vending Machines + - "92" # Media Attachments + - "94" # File Metadata + - "95" # Binary Blobs (Draft) + - "96" # HTTP File Storage Integration + - "98" # HTTP Auth + - "99" # Classified Listings + # ═══════════════════════════════════════════════════════════════════ # VARIANTS # ═══════════════════════════════════════════════════════════════════ @@ -49,3 +144,16 @@ images: variants: fdroid: ".*-fdroid-.*\\.apk$" google: ".*-googleplay-.*\\.apk$" + +# ═══════════════════════════════════════════════════════════════════ +# PUBLISHING RELAYS +# ═══════════════════════════════════════════════════════════════════ +# Relays are NOT configured in this file. `zsp` reads them from the +# RELAY_URLS environment variable (comma-separated) and falls back to +# wss://relay.zapstore.dev when it is unset. To publish the release event +# to additional relays, set RELAY_URLS before running `zsp publish`, e.g.: +# +# RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol" \ +# SIGN_WITH= zsp publish +# +# See RELEASE_OPS.md for the maintainer publishing checklist.