mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/focused-gates-w9dtcw
This commit is contained in:
+43
-25
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
|
||||
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `amethyst`,
|
||||
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`,
|
||||
`desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should
|
||||
contain implementations of Nostr specifications and utilities to help implement them. Commons stores
|
||||
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop
|
||||
@@ -48,11 +48,18 @@ amethyst/
|
||||
│ ├── androidMain/ # Android-specific (crypto, storage)
|
||||
│ ├── jvmMain/ # Desktop JVM-specific
|
||||
│ └── iosMain/ # iOS-specific
|
||||
├── commons/ # Shared UI components (convert to KMP)
|
||||
├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Shared composables, icons, state
|
||||
│ ├── androidMain/ # Android-specific UI utilities
|
||||
│ └── jvmMain/ # Desktop-specific UI utilities
|
||||
│ ├── commonMain/ # Domain models, state holders, ViewModels, services
|
||||
│ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop
|
||||
│ ├── androidMain/ # Android-specific actuals (Keystore, DataStore)
|
||||
│ └── jvmMain/ # Desktop-specific actuals (keyring, upload pipeline)
|
||||
├── commonsUI/ # Shared Compose UI on top of commons (composables, icons, theme, Coil, resources)
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Shared composables, icons, theme, composeResources (strings/fonts)
|
||||
│ ├── jvmAndroid/ # Markdown renderer, Coil OkHttp fetchers
|
||||
│ ├── androidMain/ # Android Coil bridge
|
||||
│ └── jvmMain/ # Desktop Coil bridge (+ skikoMain shared with iOS)
|
||||
├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport)
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
|
||||
@@ -70,12 +77,20 @@ amethyst/
|
||||
|
||||
**Sharing Philosophy:**
|
||||
- `quartz/` = Nostr business logic, protocol, data (no UI)
|
||||
- `commons/` = Shared code for every front end (Android, Desktop, iOS, and the
|
||||
headless `cli`): domain models, state holders, ViewModels, the relay client,
|
||||
shared services, **and** the Compose UI that ≥1 GUI front end renders. The
|
||||
package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?"
|
||||
guide are documented in **`commons/ARCHITECTURE.md`** — read it before adding
|
||||
a new package or dropping code into `commons`.
|
||||
- `commons/` = Shared **headless** code for every front end (Android, Desktop,
|
||||
iOS, and the headless `cli`): domain models, state holders, ViewModels, the
|
||||
relay client, shared services. It may use the Compose *runtime*
|
||||
(`@Stable`/`@Immutable`, snapshot state) but never Compose UI, Coil or
|
||||
Compose resources — the build enforces this: `commons` has no such deps.
|
||||
- `commonsUI/` = Shared **Compose UI** that ≥1 GUI front end renders
|
||||
(composables, `ui/theme`, icons, robohash, Coil fetchers, markdown, the
|
||||
`composeResources` strings/fonts and the generated `Res` class). Depends on
|
||||
`commons` (as `api`); `cli` never depends on it. Files keep their
|
||||
`com.vitorpamplona.amethyst.commons.*` packages — the split is a module
|
||||
boundary, not a package rename. The package taxonomy, the CLI-safe / UI
|
||||
boundary, and a "where does my code go?" guide are documented in
|
||||
**`commons/ARCHITECTURE.md`** (+ `commonsUI/ARCHITECTURE.md`) — read them
|
||||
before adding a new package or dropping code into either module.
|
||||
- `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any
|
||||
KMP project that needs MoQ. Has no Android-framework dependencies.
|
||||
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
|
||||
@@ -88,7 +103,7 @@ amethyst/
|
||||
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
|
||||
- `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`.
|
||||
standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`.
|
||||
|
||||
**Plans per module:** design docs for new subsystems live in the owning
|
||||
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
|
||||
@@ -180,16 +195,19 @@ etc. instead of re-implementing them.
|
||||
|
||||
**Share vs keep platform-native:**
|
||||
|
||||
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
|
||||
`commons/commonMain/` (major UI components, **ViewModels** under
|
||||
`viewmodels/`, icons). ViewModels are platform-agnostic state + logic
|
||||
(StateFlow/SharedFlow), so they belong in `commons`.
|
||||
- **Share** → `quartz/commonMain/` (business logic, data models, protocol),
|
||||
`commons/commonMain/` (**ViewModels** under `viewmodels/`, state holders,
|
||||
relay client, services — headless) and `commonsUI/commonMain/` (major UI
|
||||
components, icons, theme). ViewModels are platform-agnostic state + logic
|
||||
(StateFlow/SharedFlow), so they belong in `commons`; anything that imports
|
||||
`androidx.compose.ui`/`foundation`/`material3`, Coil, or `Res` belongs in
|
||||
`commonsUI`.
|
||||
- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android
|
||||
`Activity`), navigation (sidebar vs bottom nav), platform interactions
|
||||
(gestures, keyboard shortcuts), system integrations (notifications, file
|
||||
pickers).
|
||||
|
||||
When extracting a composable: move it to `commons/commonMain/` (see
|
||||
When extracting a composable: move it to `commonsUI/commonMain/` (see
|
||||
`/compose-expert`), add expect/actual for any platform behavior (see
|
||||
`/kotlin-multiplatform`), then point both Android and Desktop at the shared
|
||||
version. `quartz/` is protocol-only — no composables.
|
||||
@@ -216,7 +234,7 @@ version. `quartz/` is protocol-only — no composables.
|
||||
## Dependency Licensing
|
||||
|
||||
**MANDATORY whenever you introduce a new third-party dependency** — in *any*
|
||||
module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`,
|
||||
module (`quartz`, `commons`, `commonsUI`, `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
|
||||
@@ -253,9 +271,9 @@ JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
|
||||
## Icons
|
||||
|
||||
The Material Symbols font bundled at
|
||||
`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
|
||||
`commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
|
||||
is a **subset** that only contains the glyphs referenced from
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
|
||||
`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
|
||||
|
||||
**MANDATORY:** Whenever you add a new icon — i.e. introduce a
|
||||
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
|
||||
@@ -274,21 +292,21 @@ regenerating.
|
||||
|
||||
### Amethyst's own icons are also a font
|
||||
|
||||
The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
|
||||
The icons in `commonsUI/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
|
||||
**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn
|
||||
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into
|
||||
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
|
||||
a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
|
||||
overrun P90 **-17.4%** on the feed scroll benchmark.
|
||||
|
||||
**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`,
|
||||
**MANDATORY:** whenever you add or change an icon under `commonsUI/.../commons/icons/`,
|
||||
regenerate the font *and* its codepoint table together:
|
||||
|
||||
```bash
|
||||
python3 tools/icon-font/build_icon_font.py \
|
||||
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
|
||||
commons/src/commonMain/composeResources/font/amethyst_icons.ttf \
|
||||
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
|
||||
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
|
||||
commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \
|
||||
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
|
||||
```
|
||||
|
||||
Both outputs must be committed together: codepoints are assigned in filename order,
|
||||
|
||||
@@ -21,7 +21,7 @@ that has to catch it.
|
||||
Repair with:
|
||||
|
||||
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\
|
||||
commons/src/commonMain/composeResources
|
||||
commonsUI/src/commonMain/composeResources
|
||||
|
||||
`--no-unwrap-quotes` is mandatory on already-migrated files: escape conversion is
|
||||
idempotent, quote-unwrapping is not, and a second unwrap strips the real display
|
||||
@@ -77,7 +77,7 @@ def main() -> int:
|
||||
print(
|
||||
"\nRepair:\n"
|
||||
" python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\\n"
|
||||
" commons/src/commonMain/composeResources\n"
|
||||
" commonsUI/src/commonMain/composeResources\n"
|
||||
"(--no-unwrap-quotes is mandatory on already-migrated files.)",
|
||||
file=out,
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ Walk the imports. The usual offenders:
|
||||
| `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). |
|
||||
| `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. |
|
||||
| `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. |
|
||||
| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. |
|
||||
| `androidx.compose.*` | Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` must stay out of `commons` entirely — they belong in `:commonsUI`, which Amy never depends on. Only the Compose *runtime* (`@Stable`, snapshot state) is allowed in `commons`. |
|
||||
|
||||
### Step 3 — Pick a migration strategy per dependency
|
||||
|
||||
@@ -66,7 +66,7 @@ Walk the imports. The usual offenders:
|
||||
# Target location depends on what it is:
|
||||
# - Protocol → quartz/src/commonMain/kotlin/…
|
||||
# - Business logic → commons/src/commonMain/kotlin/…
|
||||
# - UI → commons/src/commonMain/… (needs Compose Multiplatform)
|
||||
# - UI → commonsUI/src/commonMain/… (needs Compose Multiplatform; never used by amy)
|
||||
git mv amethyst/src/main/java/com/.../FollowListManager.kt \
|
||||
commons/src/commonMain/kotlin/com/.../FollowListManager.kt
|
||||
```
|
||||
|
||||
@@ -24,7 +24,7 @@ Visual UI patterns for sharing composables across Android and Desktop.
|
||||
|
||||
## Philosophy: Share by Default
|
||||
|
||||
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
|
||||
**Default to `commonsUI/commonMain`** (shared composables live in `:commonsUI`, the Compose half of the shared layer; headless state/ViewModels stay in `:commons`) unless platform experts indicate otherwise.
|
||||
|
||||
### Always Share
|
||||
|
||||
@@ -416,7 +416,7 @@ fun DataScreen(uiState: UiState) {
|
||||
}
|
||||
```
|
||||
|
||||
**Components** (all in `commons/commonMain`):
|
||||
**Components** (all in `commonsUI/commonMain`):
|
||||
- `LoadingState` - Progress indicator + message
|
||||
- `EmptyState` - Empty message + optional refresh button
|
||||
- `ErrorState` - Error message + optional retry button
|
||||
@@ -527,12 +527,12 @@ fun FeedList(items: List<Item>) {
|
||||
|
||||
| Task | Pattern | Location |
|
||||
|------|---------|----------|
|
||||
| Reusable UI | State hoisting | commons/commonMain |
|
||||
| Reusable UI | State hoisting | commonsUI/commonMain |
|
||||
| Simple state | remember { mutableStateOf() } | Composable scope |
|
||||
| Derived state | derivedStateOf { } | remember block |
|
||||
| Async → state | produceState { } | Composable function |
|
||||
| Custom icons | roboBuilder + PathData | commons/icons |
|
||||
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
|
||||
| Custom icons | roboBuilder + PathData | commonsUI/icons |
|
||||
| Loading/Error | LoadingState, ErrorState | commonsUI/ui/components |
|
||||
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
|
||||
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
|
||||
|
||||
@@ -540,7 +540,7 @@ fun FeedList(items: List<Item>) {
|
||||
|
||||
### Creating a Shared Component
|
||||
|
||||
1. Start in `commons/src/commonMain/kotlin/.../ui/components/`
|
||||
1. Start in `commonsUI/src/commonMain/kotlin/.../ui/components/`
|
||||
2. Use Material3 primitives only
|
||||
3. Hoist state (parameters for data, callbacks for events)
|
||||
4. Add modifier parameter
|
||||
@@ -551,7 +551,7 @@ fun FeedList(items: List<Item>) {
|
||||
|
||||
1. Read current implementation in `amethyst/` or `desktopApp/`
|
||||
2. Identify pure visual logic (no platform APIs)
|
||||
3. Create in `commons/commonMain` with hoisted state
|
||||
3. Create in `commonsUI/commonMain` with hoisted state
|
||||
4. Replace platform implementations with shared component
|
||||
5. Keep platform-specific wrappers if needed
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Shared Composables Catalog
|
||||
|
||||
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
|
||||
This catalog documents shared UI components in `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/` (the Compose half of the shared layer; headless state stays in `commons`).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
commons/src/commonMain/kotlin/.../commons/ui/
|
||||
commonsUI/src/commonMain/kotlin/.../commons/ui/
|
||||
├── components/ # Reusable UI components
|
||||
├── screens/ # Screen-level composables
|
||||
├── theme/ # Theming and styling
|
||||
@@ -232,7 +232,7 @@ private val pathData1 = PathData {
|
||||
```
|
||||
|
||||
**roboBuilder** - Custom ImageVector.Builder DSL
|
||||
- Located in: `commons/robohash/`
|
||||
- Located in: `commonsUI/.../commons/robohash/`
|
||||
- Pattern: Builder-based, composable paths
|
||||
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
|
||||
- Colors: Dynamic (fgColor parameter) + Black constants
|
||||
|
||||
@@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
|
||||
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
|
||||
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
|
||||
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
|
||||
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
|
||||
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
|
||||
row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
|
||||
(`duplicate: a newer version of this replaceable event is already stored`), which the relay
|
||||
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
|
||||
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
|
||||
by lowest id; older re-inserts blocked but acknowledged as already covered.
|
||||
|
||||
**STORE-W02 — addressable supersession.** Same as W01 with unique index
|
||||
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
|
||||
|
||||
@@ -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 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.
|
||||
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/.../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
|
||||
@@ -25,7 +25,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
|
||||
│ ◄── MarmotGroupFeedViewModel │
|
||||
│ │
|
||||
│ │
|
||||
│ commons/.../ui/feeds/ (shared, KMP) │
|
||||
│ commons/.../feeds/ (shared, KMP) │
|
||||
│ IFeedFilter / FeedFilter<T> (abstract base) │
|
||||
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
|
||||
│ ChangesFlowFilter │
|
||||
@@ -68,7 +68,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
|
||||
|
||||
### Shared filter bases (commons)
|
||||
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/`:
|
||||
|
||||
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
|
||||
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
|
||||
@@ -100,7 +100,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
|
||||
|
||||
## Filter Sharing (Android vs Desktop)
|
||||
|
||||
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**.
|
||||
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../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.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
|
||||
|
||||
## Overview
|
||||
|
||||
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
|
||||
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs the missing keys, then offers the two things that close them: **translate** the ones needing translation, and **copy the English value verbatim** for the ones a locale deliberately keeps in English (since 2026-09-12 that copy is what seeds Crowdin — see Background).
|
||||
|
||||
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
|
||||
|
||||
@@ -24,9 +24,9 @@ There are two separate `strings.xml` trees, each with its own default `values/`
|
||||
| Tree | Default file | Per-locale file |
|
||||
|------|--------------|-----------------|
|
||||
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
|
||||
| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-<locale>/strings.xml` |
|
||||
| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` |
|
||||
|
||||
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
|
||||
The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
|
||||
|
||||
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
|
||||
|
||||
@@ -37,7 +37,7 @@ The `commons` tree appeared when shared event-renderer composables were extracte
|
||||
Detect name-overlap **and flag value mismatches** in one pass:
|
||||
|
||||
```bash
|
||||
cdef=commons/src/commonMain/composeResources/values/strings.xml
|
||||
cdef=commonsUI/src/commonMain/composeResources/values/strings.xml
|
||||
adef=amethyst/src/main/res/values/strings.xml
|
||||
comm -12 \
|
||||
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
|
||||
@@ -54,7 +54,7 @@ Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the
|
||||
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
|
||||
|
||||
```bash
|
||||
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml
|
||||
grep -nE '<string name="[^"]*">"' commonsUI/src/commonMain/composeResources/values-*/strings.xml
|
||||
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
|
||||
```
|
||||
|
||||
@@ -67,15 +67,23 @@ grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values
|
||||
|
||||
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
|
||||
|
||||
## Background: Crowdin strip-identical behavior
|
||||
## Background: source-identical translations and the `import_eq_suggestions` flag
|
||||
|
||||
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
|
||||
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin does not *store* a translation that exactly equals the source unless it is told to, so historically a key a translator deliberately kept as English (brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, version prefixes like `"v%1$s"`) never appeared in the locale's `strings.xml`, even though the Crowdin UI showed it as 100% translated.
|
||||
|
||||
**That changed on 2026-09-12.** `.github/workflows/crowdin.yml` now passes `import_eq_suggestions: true` to `crowdin/github-action`, so `upload_translations` no longer skips values equal to the source — whatever sits in the repo's locale files is seeded into Crowdin's database, identical values included. `auto_approve_imported` stays at its default `false`, so they arrive as **pending** translations for a translator to approve.
|
||||
|
||||
Confirmed end-to-end the same day: the first sync after the flag landed (workflow run `34706537802` → PR #4107) rewrote all five touched locale files in Crowdin's own key order with **zero net key changes** — 323 additions and 323 removals that pair up exactly. All 330 identical values pushed that morning came back down intact, unapproved included. Since Crowdin's download *replaces* file content with its export, a value it did not hold would have vanished; none did.
|
||||
|
||||
**Reading such a sync diff: compare key *sets* per file, never `-`/`+` lines separately.** A reorder looks identical to a mass strip under `grep '^-'`, and it will convince you the mechanism failed when nothing changed at all.
|
||||
|
||||
What this means for this skill:
|
||||
|
||||
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
|
||||
2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
|
||||
3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
|
||||
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is genuinely untranslated, *or* a source-identical entry stripped before 2026-09-12 that no sync has re-seeded yet. Both are reported, and both are now actionable in the repo — translate the first, copy English into the second. The Crowdin web UI ("N untranslated") remains the ground truth for what needs human work.
|
||||
2. **Source-identical entries are still recognizable, but no longer skipped.** Brand terms (`Nowhere X`), loanwords (`Apps` / `Feed` / `Issues`), symbol- or format-only values (`v%1$s`, `+%1$d`, `%1$d/%2$d`, `∞`, 👀) and example placeholders (`iPhone 13`, `https://example.com`) are the usual cases. Copy the English value into the locale file verbatim so the upload can seed it.
|
||||
3. **DO add source-identical values — that is now the mechanism, not churn.** A key absent from a locale file is invisible to `upload_translations`; writing the English value in is what gets it into Crowdin, so a translator approves it once in bulk instead of typing it into the UI ~70 times per locale. (Runtime behaviour is unchanged either way: Android still falls back to `values/strings.xml`.) Two exclusions:
|
||||
- **Never for `<plurals>`.** Copying English `one`/`other` into cs/pl trips `MissingQuantity`, which is a CI error (cs needs `one`/`few`/`many`/`other`). Plurals stay a Crowdin-UI job.
|
||||
- **Not for words a locale would genuinely translate.** German `buzz_dm_workspace` ("Arbeitsbereich"), `workout` ("Training"), `relay_group_threads_title` ("Themen"), `calendar_rsvp_section` ("Zusagen") are *gaps*, not deliberate English keeps. Copying English there seeds a wrong pending suggestion — list those for the human to translate rather than approve.
|
||||
|
||||
4. **A repo-side edit to a translated value only sticks where Crowdin's database
|
||||
doesn't contradict it.** Download replaces file content with Crowdin's current
|
||||
@@ -96,6 +104,13 @@ What this means for this skill:
|
||||
from `values/strings.xml` removes it project-wide, and attributes declared
|
||||
there propagate into every export.
|
||||
|
||||
**This does not contradict item 3 — the two cases differ.** Seeding a key
|
||||
Crowdin holds *nothing* for (the identical-value copy) sticks, because there is
|
||||
no stored value to contradict it; that is exactly why the copy pass works.
|
||||
*Overwriting* a value Crowdin already holds differently — including an empty
|
||||
one — still loses on the next sync. Add missing entries in the repo; change
|
||||
existing translations in the UI.
|
||||
|
||||
> **Historical note:** an earlier version of this skill tried to auto-filter the
|
||||
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
|
||||
> the last `New Crowdin translations` commit). It was **dropped** because it
|
||||
@@ -132,14 +147,14 @@ Default: amethyst/src/main/res/values/strings.xml
|
||||
Target: amethyst/src/main/res/values-<locale>/strings.xml
|
||||
|
||||
# commons tree
|
||||
Default: commons/src/commonMain/composeResources/values/strings.xml
|
||||
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
|
||||
Default: commonsUI/src/commonMain/composeResources/values/strings.xml
|
||||
Target: commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml
|
||||
```
|
||||
|
||||
A convenient way to run the whole technique twice is to loop over the two base dirs:
|
||||
|
||||
```bash
|
||||
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do
|
||||
for base in amethyst/src/main/res commonsUI/src/commonMain/composeResources; do
|
||||
echo "########## tree: $base ##########"
|
||||
# ... run the diff/count/value-extraction commands with $base/values[...] ...
|
||||
done
|
||||
@@ -172,7 +187,7 @@ comm -23 \
|
||||
|
||||
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
|
||||
|
||||
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
|
||||
Locale files are asymmetric — legacy pre-2026-09-12 strips and uneven translator progress both leave different keys missing in different locales — so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
|
||||
|
||||
```bash
|
||||
for locale in cs de-rDE sv-rSE pt-rBR; do
|
||||
@@ -190,7 +205,7 @@ for locale in cs de-rDE sv-rSE pt-rBR; do
|
||||
done
|
||||
```
|
||||
|
||||
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
|
||||
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set: translate what needs translating, and copy the English value verbatim for the entries a locale keeps in English (see Background).
|
||||
|
||||
### 3. Get English values for missing keys
|
||||
|
||||
@@ -258,8 +273,8 @@ Flag and offer to fix:
|
||||
# hardcode "1" (or other literal digits) instead of using a placeholder.
|
||||
# Looks at default + all values-* locales, in BOTH resource trees.
|
||||
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
|
||||
commons/src/commonMain/composeResources/values/strings.xml \
|
||||
commons/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
commonsUI/src/commonMain/composeResources/values/strings.xml \
|
||||
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
awk -v file="$f" '
|
||||
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
|
||||
in_plurals && /quantity="one"/ {
|
||||
@@ -279,8 +294,8 @@ Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-
|
||||
|
||||
```bash
|
||||
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
|
||||
commons/src/commonMain/composeResources/values/strings.xml \
|
||||
commons/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
commonsUI/src/commonMain/composeResources/values/strings.xml \
|
||||
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
# Skip Arabic, Latvian and Welsh — they natively use the zero category.
|
||||
# (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.)
|
||||
case "$f" in
|
||||
@@ -313,7 +328,7 @@ itre = re.compile(r'<item quantity="([^"]+)"[^>]*>(.*?)</item>', re.S)
|
||||
# (?<!\\) is REQUIRED: \%2$d is an escaped literal, not a placeholder.
|
||||
phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])')
|
||||
sig = lambda t: sorted(m.group(0) for m in phre.finditer(t))
|
||||
for base in ['amethyst/src/main/res', 'commons/src/commonMain/composeResources']:
|
||||
for base in ['amethyst/src/main/res', 'commonsUI/src/commonMain/composeResources']:
|
||||
d = io.open(f'{base}/values/strings.xml', encoding='utf-8').read()
|
||||
dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)}
|
||||
dpl = {}
|
||||
@@ -339,7 +354,7 @@ PY
|
||||
# Empty plural items render as nothing at runtime — always a bug.
|
||||
grep -rn '<item quantity="[a-z]*"></item>' \
|
||||
amethyst/src/main/res/values*/strings.xml \
|
||||
commons/src/commonMain/composeResources/values*/strings.xml
|
||||
commonsUI/src/commonMain/composeResources/values*/strings.xml
|
||||
```
|
||||
|
||||
Three things this scan taught us, all of which it now encodes:
|
||||
@@ -458,7 +473,7 @@ When adding translated strings to locale files:
|
||||
|
||||
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
|
||||
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
|
||||
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
|
||||
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because locale files are asymmetric (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
|
||||
|
||||
```bash
|
||||
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
|
||||
@@ -528,15 +543,15 @@ When adding translated strings to locale files:
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
|
||||
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commonsUI/src/commonMain/composeResources`). A key extracted into `commonsUI/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
|
||||
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
|
||||
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
|
||||
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
|
||||
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||||
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
|
||||
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
|
||||
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
|
||||
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
|
||||
- **Skipping source-identical entries instead of copying them in** — correct before 2026-09-12, wrong now. With `import_eq_suggestions: true` the repo file is the *seed* for Crowdin's database, so a key you leave out stays untranslated in the UI forever and reappears in every future scan. Copy the English value verbatim, except for `<plurals>` (trips `MissingQuantity`) and words the locale would really translate. (Confirmed by PR #4107: 330 identical values survived the next sync with zero net changes.)
|
||||
- **Skipping per-locale diffs when only diffing cs** — different keys are missing in different locales (legacy strips plus uneven translator progress), so cs is not a reliable upper bound. Diff each target locale and union the results.
|
||||
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
|
||||
- **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.)
|
||||
- **Converting a `<string>` to `<plurals>` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`.
|
||||
|
||||
@@ -383,7 +383,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
|
||||
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
|
||||
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
|
||||
| UI components (simple) | commons/commonMain | Cards, buttons, dialogs |
|
||||
| UI components (simple) | commonsUI/commonMain | Cards, buttons, dialogs (Compose UI never goes in `commons`) |
|
||||
| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** |
|
||||
| Navigation | Platform-specific only | Activity vs Window too different |
|
||||
| Permissions | Platform-specific only | APIs incompatible |
|
||||
|
||||
@@ -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.15.1` (Maven Central)
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.2` (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.15.1"
|
||||
quartz = "1.15.2"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -41,7 +41,7 @@ kotlin {
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.15.1")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current version
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.15.1
|
||||
com.vitorpamplona.quartz:quartz:1.15.2
|
||||
```
|
||||
|
||||
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.15.1"
|
||||
quartz = "1.15.2"
|
||||
|
||||
[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.15.1")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.15.1")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
|
||||
// JNA needed for libsodium (NIP-44) on JVM
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ The layer between `LocalCache`/`Account` and the raw relay connection. Ensures c
|
||||
|
||||
## Layout
|
||||
|
||||
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`:
|
||||
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` (the `@Composable` entry points — `observeUser*`, `*FilterAssemblerSubscription`, `KeyDataSourceSubscription` — sit in the same package but in `commonsUI/src/commonMain/…`, the Compose half of the shared layer):
|
||||
|
||||
```
|
||||
relayClient/
|
||||
|
||||
@@ -45,7 +45,16 @@ jobs:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Linter (gradle)
|
||||
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
|
||||
# The three metadata compiles resolve commonMain against only the
|
||||
# deps every target shares, which is the Apple classpath — a
|
||||
# dependency that reaches JVM transitively (okio via OkHttp) but is
|
||||
# missing for iOS fails here, on Linux, instead of in test-quartz-ios.
|
||||
run: |
|
||||
./gradlew spotlessCheck \
|
||||
:quartz:verifyKmpPurity :commons:verifyKmpPurity :commonsUI:verifyKmpPurity \
|
||||
:quartz:compileCommonMainKotlinMetadata \
|
||||
:commons:compileCommonMainKotlinMetadata \
|
||||
:commonsUI:compileCommonMainKotlinMetadata
|
||||
|
||||
build-desktop:
|
||||
needs: lint
|
||||
@@ -93,7 +102,7 @@ jobs:
|
||||
|
||||
- name: Test + Build Desktop (gradle)
|
||||
run: |
|
||||
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
|
||||
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
|
||||
if [ "${{ runner.os }}" = "Linux" ]; then
|
||||
xvfb-run --auto-servernum $CMD
|
||||
else
|
||||
@@ -129,6 +138,7 @@ jobs:
|
||||
path: |
|
||||
quartz/build/reports/tests
|
||||
commons/build/reports/tests
|
||||
commonsUI/build/reports/tests
|
||||
nestsClient/build/reports/tests
|
||||
cli/build/reports/tests
|
||||
desktopApp/build/reports/tests
|
||||
@@ -307,11 +317,15 @@ jobs:
|
||||
# :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.
|
||||
# :commonsUI (the Compose half split out of :commons) gets the same
|
||||
# treatment so the shared composables keep compiling on Apple targets.
|
||||
- name: Test Commons on iOS
|
||||
run: |
|
||||
./gradlew \
|
||||
:commons:iosSimulatorArm64Test \
|
||||
:commons:compileTestKotlinIosArm64
|
||||
:commons:compileTestKotlinIosArm64 \
|
||||
:commonsUI:iosSimulatorArm64Test \
|
||||
:commonsUI:compileTestKotlinIosArm64
|
||||
|
||||
- name: Upload iOS Test Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -363,6 +377,7 @@ jobs:
|
||||
:amethyst:lintPlayBenchmark \
|
||||
:quartz:jvmTest \
|
||||
:commons:jvmTest \
|
||||
:commonsUI:jvmTest \
|
||||
:nestsClient:jvmTest \
|
||||
:amethyst:testFdroidDebugUnitTest \
|
||||
:amethyst:testPlayDebugUnitTest \
|
||||
|
||||
@@ -568,22 +568,22 @@ jobs:
|
||||
( 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)
|
||||
- name: Enforce CLI size budget (120 MB per asset)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget
|
||||
# targets < 80 MB, but :commons currently leaks Compose + Skiko as
|
||||
# transitive deps (~40 MB of unused UI jars). Budget is set to
|
||||
# 200 MB until commons is split into core + ui modules — track that
|
||||
# as a follow-up. Until then, this gate just catches pathological
|
||||
# regressions (e.g. accidental :amethyst dep pulling Android libs).
|
||||
# Measured after the :commons / :commonsUI split (1.15.2, Linux x64):
|
||||
# amy-*-jvm.tar.gz 55 MB, amy-*.tar.gz (jlink image) 80 MB, lib/ 60 MB
|
||||
# on disk. The budget sits 50% above the largest asset so a Compose /
|
||||
# Skiko / Android leak (+25-40 MB compressed) trips it, while the
|
||||
# per-OS JRE variance of the jlink image does not. The "Assert no
|
||||
# Compose UI" step above is the precise check; this is the coarse one.
|
||||
fail=0
|
||||
for f in dist/*; do
|
||||
if [[ -f "$f" ]]; then
|
||||
size=$(wc -c < "$f")
|
||||
mb=$(( size / 1048576 ))
|
||||
if (( size > 209715200 )); then
|
||||
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget"
|
||||
if (( size > 125829120 )); then
|
||||
echo "::error file=$f::asset is ${mb} MB — exceeds 120 MB amy budget"
|
||||
fail=1
|
||||
else
|
||||
echo "OK: $f — ${mb} MB"
|
||||
|
||||
@@ -31,6 +31,14 @@ jobs:
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
# Upload translations that are identical to the English source (brand
|
||||
# terms, loanwords like "Feed"/"Apps", bare formats like "v%1$s").
|
||||
# Without this they are SKIPPED on upload, so a locale that deliberately
|
||||
# keeps English never reaches Crowdin's DB and the key keeps coming back
|
||||
# as untranslated. They arrive as normal UNAPPROVED translations --
|
||||
# auto_approve_imported stays at its default false, so a translator still
|
||||
# approves them in the Crowdin UI (bulk-select in the Editor).
|
||||
import_eq_suggestions: true
|
||||
download_translations: true
|
||||
# Let the downloaded translations stay in the working tree; the single
|
||||
# create-pull-request step below opens the combined PR.
|
||||
@@ -53,7 +61,7 @@ jobs:
|
||||
# Both files in crowdin.yml are declared `type: android`, so Crowdin's Android
|
||||
# serializer escapes apostrophes on the way down: `l'URL` comes back as `l\'URL`.
|
||||
# That is correct for amethyst/src/main/res/, which aapt un-escapes at build time,
|
||||
# and WRONG for commons/.../composeResources/, where Compose resolves only \uXXXX,
|
||||
# and WRONG for commonsUI/.../composeResources/, where Compose resolves only \uXXXX,
|
||||
# \n and \t and leaves \' \" \? \@ alone -- so the backslash reaches the screen.
|
||||
#
|
||||
# Without this step every sync reopens the same regression and CI's
|
||||
@@ -68,14 +76,14 @@ jobs:
|
||||
- name: Convert Android escaping to Compose escaping in the shared catalog
|
||||
run: |
|
||||
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \
|
||||
commons/src/commonMain/composeResources
|
||||
commonsUI/src/commonMain/composeResources
|
||||
|
||||
# Assert the conversion actually satisfied the check that guards main, so a case
|
||||
# the converter cannot repair fails the sync loudly here instead of opening a red
|
||||
# PR. Known gap if this ever trips: fix_escapes.py only rewrites text inside
|
||||
# <string>/<item> elements, while the check scans the whole file -- an escape in an
|
||||
# XML comment (comments do propagate into the locale files) has to be fixed at the
|
||||
# source string in commons/.../composeResources/values/strings.xml by hand.
|
||||
# source string in commonsUI/.../composeResources/values/strings.xml by hand.
|
||||
- name: Verify the shared catalog is free of Android-only escaping
|
||||
run: .claude/hooks/compose_escaping_check.py
|
||||
|
||||
@@ -99,7 +107,7 @@ jobs:
|
||||
branch: l10n_crowdin_translations
|
||||
add-paths: |
|
||||
amethyst/src/main/res/**/strings.xml
|
||||
commons/src/commonMain/composeResources/**/strings.xml
|
||||
commonsUI/src/commonMain/composeResources/**/strings.xml
|
||||
docs/changelog/translators.json
|
||||
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
|
||||
title: 'New Crowdin Translations'
|
||||
|
||||
+14
-11
@@ -96,7 +96,7 @@ 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` |
|
||||
| **Material Symbols subset font** | `commonsUI/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
|
||||
@@ -105,8 +105,9 @@ and each has its own guide:
|
||||
> 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.
|
||||
Rust toolchain + the exact Android NDK revision pinned in
|
||||
`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs —
|
||||
they are **not** required to build Amethyst from the committed sources.
|
||||
|
||||
---
|
||||
|
||||
@@ -466,8 +467,8 @@ Homebrew removes the quarantine attribute on its own downloads.
|
||||
> 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
|
||||
> zipping (the unused `skiko`/Compose jars left the CLI image with the
|
||||
> `:commons` / `:commonsUI` split). 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.
|
||||
|
||||
@@ -533,7 +534,7 @@ reads an optional per-release changelog from
|
||||
|
||||
## Bootstrap runbook (one-time)
|
||||
|
||||
> **Status as of v1.15.1:** both Homebrew packages are now live upstream — the
|
||||
> **Status as of v1.15.2:** both Homebrew packages are now live upstream — the
|
||||
> `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy`
|
||||
> formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so
|
||||
> `bump-homebrew.yml` finally has something to bump. **Winget is still not
|
||||
@@ -587,7 +588,7 @@ The token then lives only in that maintainer's shell:
|
||||
|
||||
```bash
|
||||
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
|
||||
scripts/bump-homebrew-cask.sh v1.15.1
|
||||
scripts/bump-homebrew-cask.sh v1.15.2
|
||||
```
|
||||
|
||||
Create one at
|
||||
@@ -603,7 +604,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
|
||||
runs fine from macOS or Linux:
|
||||
|
||||
```bash
|
||||
scripts/bump-winget.sh v1.15.1
|
||||
scripts/bump-winget.sh v1.15.2
|
||||
```
|
||||
|
||||
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
|
||||
@@ -686,9 +687,11 @@ Caveats that the maintainer must weigh before submitting:
|
||||
- **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.
|
||||
- **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked
|
||||
Compose/Skiko jars onto the CLI classpath. Compose UI now lives in
|
||||
`:commonsUI`, which `:cli` does not depend on: the JVM tarball is ~55 MB
|
||||
and the jlink image tarball ~80 MB (1.15.2, Linux x64). The release
|
||||
workflow caps every amy asset at 120 MB.
|
||||
|
||||
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
|
||||
|
||||
@@ -175,9 +175,13 @@ device. PRs that introduce any of them will be sent back.
|
||||
|
||||
### KMP source-set discipline
|
||||
|
||||
- **Android-only imports don't belong in `commons/commonMain` or
|
||||
`quartz/commonMain`.** Use `expect`/`actual` for platform-specific
|
||||
bits, or move the Android-specific code to `androidMain`.
|
||||
- **Android-only imports don't belong in `commons/commonMain`,
|
||||
`commonsUI/commonMain` or `quartz/commonMain`.** Use `expect`/`actual`
|
||||
for platform-specific bits, or move the Android-specific code to
|
||||
`androidMain`.
|
||||
- **Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` don't belong
|
||||
in `commons` at all** — that module is on the CLI classpath. Put the file
|
||||
in `commonsUI` (same package) instead.
|
||||
|
||||
### Logging
|
||||
|
||||
|
||||
+13
-8
@@ -3,7 +3,7 @@
|
||||
Thanks for your interest in improving Amethyst. This document captures the
|
||||
expectations, conventions, and review rules for code, documentation, and
|
||||
translation contributions across all modules in this repository (`amethyst/`,
|
||||
`desktopApp/`, `quartz/`, `commons/`, `cli/`, `quic/`, `nestsClient/`).
|
||||
`desktopApp/`, `quartz/`, `commons/`, `commonsUI/`, `cli/`, `quic/`, `nestsClient/`).
|
||||
|
||||
By contributing, you agree to license your work under the MIT license. Any
|
||||
work contributed where you are not the original author must contain its
|
||||
@@ -157,7 +157,8 @@ Common Gradle entry points:
|
||||
Modules:
|
||||
|
||||
- `quartz/` — Nostr KMP library (protocol, crypto, models). **No UI.**
|
||||
- `commons/` — Shared Compose Multiplatform UI, icons, ViewModels, flows.
|
||||
- `commons/` — Shared headless layer: models, ViewModels, flows, relay client. **No Compose UI** (the CLI depends on it).
|
||||
- `commonsUI/` — Shared Compose Multiplatform UI, icons, theme, Compose resources, on top of `commons`.
|
||||
- `quic/` — Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport.
|
||||
- `nestsClient/` — Audio-rooms client (NIP-53) built on `:quic` and
|
||||
`:quartz`.
|
||||
@@ -175,7 +176,8 @@ of PR churn. Place new code by purpose:
|
||||
| What you're adding | Goes in |
|
||||
|---|---|
|
||||
| Nostr event types, NIPs, tags, signing, crypto, Bech32 | `quartz/commonMain/` |
|
||||
| Shared Composables, icons, ViewModels, StateFlows | `commons/commonMain/viewmodels/` or `commons/commonMain/` |
|
||||
| Shared ViewModels, StateFlows, relay subscriptions | `commons/commonMain/viewmodels/` or `commons/commonMain/` |
|
||||
| Shared Composables, icons, theme | `commonsUI/commonMain/` (same packages as `commons`) |
|
||||
| Android-only screen, navigation, system integration | `amethyst/` |
|
||||
| Desktop-only window, sidebar, menu bar, shortcut | `desktopApp/` |
|
||||
| `amy <verb>` subcommand (thin assembly only) | `cli/src/main/kotlin/.../cli/` |
|
||||
@@ -188,8 +190,9 @@ Hard rules:
|
||||
- `cli/` has **no Nostr protocol or business logic** — it's a thin assembly
|
||||
layer over `quartz` + `commons`. If your CLI command needs new behavior,
|
||||
extract it into `commons/` first.
|
||||
- ViewModels belong in `commons/commonMain/`. Only screens (the Composable
|
||||
that wires layout + navigation) stay in the platform module.
|
||||
- ViewModels belong in `commons/commonMain/`; shared composables in
|
||||
`commonsUI/commonMain/`. Only screens (the Composable that wires layout +
|
||||
navigation) stay in the platform module.
|
||||
- For platform-specific behavior in a shared file, use `expect`/`actual`.
|
||||
|
||||
## Workflow
|
||||
@@ -265,9 +268,11 @@ front:
|
||||
sequentially: `for peer in aioquic picoquic quic-go quinn; do
|
||||
quic/interop/run-matrix.sh -s $peer; done`. Plan at
|
||||
`quic/interop/plans/2026-05-06-interop-runner.md`.
|
||||
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless
|
||||
variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive
|
||||
Marmot variant prompts a human to drive the Android UI.
|
||||
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every
|
||||
relay-backed suite boots the embedded `amy serve` relay (geode) — no
|
||||
external relay binary; only the Marmot suites additionally need `cargo`
|
||||
for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to
|
||||
drive the Android UI.
|
||||
|
||||
If a change is documentation-only, UI-only, build-script-only, or otherwise
|
||||
cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip
|
||||
|
||||
@@ -328,16 +328,16 @@ repositories {
|
||||
Add the following line to your `commonMain` dependencies:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz:1.15.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz:1.15.2')
|
||||
```
|
||||
|
||||
Variations to each platform are also available:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:1.15.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:1.15.2')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.2')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.2')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.2')
|
||||
```
|
||||
|
||||
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
|
||||
|
||||
+2
-2
@@ -240,9 +240,9 @@ readable by anyone with push access here), so a maintainer runs the last step:
|
||||
```bash
|
||||
# after merging the sync PRs
|
||||
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
|
||||
scripts/bump-homebrew-cask.sh v1.15.1
|
||||
scripts/bump-homebrew-cask.sh v1.15.2
|
||||
|
||||
scripts/bump-winget.sh v1.15.1 # no token — uses your `gh` auth
|
||||
scripts/bump-winget.sh v1.15.2 # no token — uses your `gh` auth
|
||||
```
|
||||
|
||||
Both scripts re-verify the published artifact's sha256 before submitting, and
|
||||
|
||||
@@ -399,12 +399,13 @@ dependencies {
|
||||
// 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.1")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1")
|
||||
debugImplementation(libs.androidx.compose.runtime.tracing)
|
||||
debugImplementation(libs.androidx.tracing.perfetto)
|
||||
debugImplementation(libs.androidx.tracing.perfetto.binary)
|
||||
|
||||
implementation(project(":quartz"))
|
||||
implementation(project(":commons"))
|
||||
implementation(project(":commonsUI"))
|
||||
implementation(project(":nestsClient"))
|
||||
// Agent text stream previews: the raw-QUIC binding plus the QUIC
|
||||
// stack under it (for the certificate validator it requires).
|
||||
@@ -598,6 +599,15 @@ dependencies {
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
// In-process Nostr relay (geode) so unit tests that drive a real
|
||||
// NostrClient talk to an embedded relay instead of a public one. Same
|
||||
// wiring quartz uses for its jvmAndroidTest source set: the engine, its
|
||||
// testFixtures (RelayClientTest base, preload/publish helpers) and the
|
||||
// JVM SQLite driver the in-memory EventStore needs on a host JVM.
|
||||
testImplementation(project(":geode"))
|
||||
testImplementation(testFixtures(project(":geode")))
|
||||
testImplementation(libs.androidx.sqlite.bundled.jvm)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.junit.ktx)
|
||||
|
||||
@@ -241,7 +241,7 @@ class ImageUploadTesting {
|
||||
@Ignore("Returns invalid hash")
|
||||
fun testSovbit() =
|
||||
runBlocking {
|
||||
testBase(ServerName("sovbit", "https://cdn.sovbit.host", ServerType.Blossom))
|
||||
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.Blossom))
|
||||
}
|
||||
|
||||
@Test()
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -62,7 +62,8 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlaybackErrorOverlayFitTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
|
||||
+3
-2
@@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
|
||||
@@ -50,7 +50,8 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AudioPlayerBoxOverflowTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private class Bounds {
|
||||
var top = 0f
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
@@ -70,7 +70,8 @@ import org.junit.Test
|
||||
* fallback would silently start reading a dead value too.
|
||||
*/
|
||||
class ComposeImeInsetWedgeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private val keyboardHeight = 957
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
|
||||
|
||||
-112
@@ -1,112 +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.ui.screen.loggedIn.relays.eventsync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSyncTest {
|
||||
companion object {
|
||||
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
|
||||
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
|
||||
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
|
||||
.build()
|
||||
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = {
|
||||
listOf(Constants.mom, Constants.nos)
|
||||
},
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
NostrClient(socketBuilder, appScope)
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFiatjafSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = { listOf(fiatjaf) },
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
val newClient = NostrClient(socketBuilder, appScope)
|
||||
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
|
||||
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
|
||||
// Authenticates with relays.
|
||||
val auth =
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { _, authTemplate, _ ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
newClient
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -647,6 +648,41 @@ class AppModules(
|
||||
onionCache = onionLocationCache,
|
||||
)
|
||||
|
||||
// Drops pooled connections once per real Tor route change. When the user switches
|
||||
// Tor on, the direct clients' idle sockets to real hosts would otherwise sit in the
|
||||
// pool for its 5-minute keepalive after the user has asked for everything to go
|
||||
// through Tor. No request could use them either way -- OkHttp keys the pool by
|
||||
// `Address`, which includes the proxy, so a connection on a dead route is already
|
||||
// unreachable -- which is why this is hygiene and not correctness, and why it is
|
||||
// fine for it to be a little late.
|
||||
//
|
||||
// Every source here is a plain StateFlow, so subscribing costs nothing. Deliberately
|
||||
// NOT torManager.activePortOrNull: that chains to TorManager.status, whose upstream
|
||||
// is WhileSubscribed and calls service.start() when collected, so a process-lifetime
|
||||
// subscription there would hold Arti's control flow open forever -- the same hazard
|
||||
// the battery ledger above documents and sidesteps the same way.
|
||||
//
|
||||
// Also deliberately not the per-feature Tor switches (imagesViaTor, videosViaTor, ...):
|
||||
// those change which of the two existing clients a request picks, not the route either
|
||||
// one uses, so no pooled connection goes stale.
|
||||
init {
|
||||
applicationIOScope.launch {
|
||||
combine(
|
||||
torPrefs.torType,
|
||||
torPrefs.externalSocksPort,
|
||||
torService.status.map { it.socksPort },
|
||||
) { torType, externalPort, artiPort -> Triple(torType, externalPort, artiPort) }
|
||||
.distinctUntilChanged()
|
||||
// Only later moves count; the route in force at process construction is the
|
||||
// status quo, and nothing is pooled yet to evict.
|
||||
.drop(1)
|
||||
.collect {
|
||||
okHttpClients.factory.evictPooledConnections()
|
||||
okHttpClientForRelays.factory.evictPooledConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connects the INostrClient class with okHttp
|
||||
val websocketBuilder =
|
||||
OkHttpWebSocket.Builder(
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
|
||||
import kotlin.time.DurationUnit
|
||||
@@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot {
|
||||
private const val STATE_DUMP_TAG = "STATE DUMP"
|
||||
|
||||
fun debugState(context: Context) {
|
||||
// Everything below is logged at DEBUG, and every argument is built eagerly (the
|
||||
// eager Log.d overload, not the lambda one). Gate on the level that would drop
|
||||
// those lines, because the arguments are the expensive part: nine materialising
|
||||
// LargeCache.filter scans over notes/addressables/users/channels, plus three
|
||||
// passes calling Event.countMemory() — which walks every tag of every cached
|
||||
// event. MainActivity.onPause() calls this unconditionally, so without the gate
|
||||
// a release build (minLevel WARN) did all of that on every backgrounding and
|
||||
// threw the result away. Benchmark builds sit at INFO and paid it too.
|
||||
if (Log.minLevel > LogLevel.DEBUG) return
|
||||
|
||||
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
|
||||
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
|
||||
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
|
||||
|
||||
@@ -37,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
@@ -162,6 +165,16 @@ class AccountZapActions(
|
||||
?.supportsMethod(NwcMethod.PAY) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this account can settle a BOLT12 zap at all: an NWC wallet is
|
||||
* configured and the default one advertises `pay`. The sender-side half of the
|
||||
* BOLT12 route; the recipient-side half is a published kind:10058 offer.
|
||||
*/
|
||||
fun canZapViaBolt12(): Boolean =
|
||||
account.settings.nwcWallets.value
|
||||
.isNotEmpty() &&
|
||||
defaultWalletSupportsBolt12Pay()
|
||||
|
||||
/**
|
||||
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
|
||||
*
|
||||
@@ -173,6 +186,14 @@ class AccountZapActions(
|
||||
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
|
||||
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
|
||||
* external-wallet or LNURL fallback because only NWC returns the proof.
|
||||
*
|
||||
* Outcomes are split by what they say about the money:
|
||||
* - [onNotPaid]: the wallet answered with an error. The wallet does the offer →
|
||||
* invoice exchange itself, so a stale or dead offer lands here too. Whether a
|
||||
* retry is safe depends on the code — `PAYMENT_FAILED` may be a timeout with the
|
||||
* HTLC still in flight — see `Bolt12LightningFallback`.
|
||||
* - [onError]: paid but no valid receipt, or nothing conclusive. Never retry.
|
||||
* - [onTimeout]: the wallet never answered. Unknown state — never retry.
|
||||
*/
|
||||
suspend fun sendBolt12Zap(
|
||||
zappedEvent: Event?,
|
||||
@@ -183,15 +204,21 @@ class AccountZapActions(
|
||||
zapType: LnZapEvent.ZapType,
|
||||
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
|
||||
onError: (Int, String?) -> Unit,
|
||||
// (code, detail) — the wallet refused or failed the payment; no funds moved.
|
||||
onNotPaid: suspend (NwcErrorCode?, String?) -> Unit,
|
||||
onTimeout: () -> Unit,
|
||||
onProcessed: () -> Unit,
|
||||
) {
|
||||
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
|
||||
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
|
||||
if (zapType == LnZapEvent.ZapType.NONZAP) {
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats), onTimeout) { response ->
|
||||
account.scope.launch {
|
||||
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
onProcessed()
|
||||
try {
|
||||
if (response is IErrorResponseLike) onNotPaid(response.nwcErrorCode(), response.errorMessage())
|
||||
} finally {
|
||||
onProcessed()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -211,7 +238,7 @@ class AccountZapActions(
|
||||
|
||||
val payerNote = Bolt12ZapBuilder.payerNote(intent)
|
||||
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote), onTimeout) { response ->
|
||||
account.scope.launch {
|
||||
// try/finally so a failure while assembling/publishing the receipt (e.g. a
|
||||
// remote signer error) still steps progress and surfaces an error, instead
|
||||
@@ -234,7 +261,7 @@ class AccountZapActions(
|
||||
}
|
||||
}
|
||||
|
||||
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
is IErrorResponseLike -> onNotPaid(response.nwcErrorCode(), response.errorMessage())
|
||||
|
||||
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
}
|
||||
@@ -374,3 +401,11 @@ class AccountZapActions(
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/** The NIP-47 error code on a failed reply, whichever error shape the wallet used. */
|
||||
private fun Response.nwcErrorCode(): NwcErrorCode? =
|
||||
when (this) {
|
||||
is NwcErrorResponse -> error?.code
|
||||
is PayInvoiceErrorResponse -> error?.code
|
||||
else -> null
|
||||
}
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
data class VanishEventItem(
|
||||
@@ -124,7 +125,10 @@ class VanishRequestsState(
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// A cancelled check has no result. Reporting ERROR would show the relay as
|
||||
// having answered badly when it was never asked.
|
||||
if (e is CancellationException) throw e
|
||||
item.complianceResults.update {
|
||||
it + (relay to ComplianceStatus.ERROR)
|
||||
}
|
||||
|
||||
+2
-1
@@ -166,7 +166,8 @@ class NamecoinSharedPreferences(
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
* through that rail — matching the existing best-effort behaviour of the
|
||||
* actual send paths (Lightning skips pubkeys with no `lnAddress`; on-chain
|
||||
* separately warns about lnAddress-only splits that can't be paid on-chain).
|
||||
*
|
||||
* [hasLightning] is the whole Lightning rail, not just BOLT11: a recipient with
|
||||
* no `lnAddress` but a published kind:10058 BOLT12 offer counts when our own
|
||||
* NWC wallet can pay offers, because the zap send path routes them over BOLT12
|
||||
* (see `ZapPaymentHandler`). The chip stays one bolt either way — which flavour
|
||||
* gets used is decided at send time, not in the picker.
|
||||
*/
|
||||
@Immutable
|
||||
data class RailCapability(
|
||||
@@ -143,6 +149,12 @@ object RailCapabilityResolver {
|
||||
baseNote: Note,
|
||||
cashuState: CashuWalletState,
|
||||
payToEnabled: Boolean = false,
|
||||
/**
|
||||
* Whether our default NWC wallet can pay BOLT12 offers
|
||||
* (`AccountZapActions.canZapViaBolt12`). When true, a recipient's published
|
||||
* offer makes them payable on the Lightning rail even without an lnAddress.
|
||||
*/
|
||||
bolt12Payable: Boolean = false,
|
||||
): RailCapability {
|
||||
val author = baseNote.author?.pubkeyHex
|
||||
val splits = baseNote.event?.zapSplitSetup().orEmpty()
|
||||
@@ -170,7 +182,9 @@ object RailCapabilityResolver {
|
||||
val hasLightning =
|
||||
lnAddressOnlySplits.isNotEmpty() ||
|
||||
pubKeyRecipients.any { pk ->
|
||||
LocalCache.getUserIfExists(pk)?.lnAddress() != null
|
||||
val user = LocalCache.getUserIfExists(pk)
|
||||
user?.lnAddress() != null ||
|
||||
(bolt12Payable && user?.bolt12Offers()?.isNotEmpty() == true)
|
||||
}
|
||||
|
||||
// On-chain pays the pubkey directly; an event with only lnAddress
|
||||
|
||||
+5
-1
@@ -60,6 +60,7 @@ import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Fetches a resource URL on an applet's behalf — the applet has no direct network
|
||||
@@ -162,7 +163,10 @@ class NappletResourceFetcher(
|
||||
return failure(ERROR_BLOCKED, e.message)
|
||||
} catch (_: InterruptedIOException) {
|
||||
return failure(ERROR_TIMEOUT)
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// Cancellation is not an upstream failure — do not report it to the
|
||||
// napplet as one, and do not keep the request alive past it.
|
||||
if (e is CancellationException) throw e
|
||||
return failure(ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
|
||||
|
||||
/**
|
||||
* Decides whether a BOLT12 zap the wallet refused should be re-sent as a BOLT11 zap.
|
||||
*
|
||||
* Only ever consulted for a NIP-47 *error* reply. An allowlist, because not every
|
||||
* error means no money moved: NIP-47 defines `PAYMENT_FAILED` as "may be due to a
|
||||
* timeout, exhausting all routes, insufficient capacity or similar", and a wallet
|
||||
* that gave up on a payment whose HTLC is still in flight can see it settle later.
|
||||
* Retrying on that, or on the catch-all `INTERNAL` / `OTHER` / no-code replies,
|
||||
* could pay the recipient twice. Only refusals the wallet raises *before* it
|
||||
* attempts a payment qualify — the offer could not be resolved or has expired, the
|
||||
* request was rejected as malformed, or our wallet does not handle `lno` at all.
|
||||
* Those are the "recipient's configuration is stale" cases the fallback exists for.
|
||||
* Refusals about our own wallet (balance, quota, permissions) are out too: BOLT11
|
||||
* through the same wallet would fail identically and only add a second error.
|
||||
*/
|
||||
object Bolt12LightningFallback {
|
||||
/** Refusals raised before any payment attempt, about the offer or the instruction. */
|
||||
private val offerSideCodes =
|
||||
setOf(
|
||||
NwcErrorCode.EXPIRED,
|
||||
NwcErrorCode.NOT_FOUND,
|
||||
NwcErrorCode.BAD_REQUEST,
|
||||
NwcErrorCode.NOT_IMPLEMENTED,
|
||||
NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION,
|
||||
NwcErrorCode.UNSUPPORTED_NETWORK,
|
||||
)
|
||||
|
||||
/** True when a refusal with [code] (null when the wallet sent none) should be retried over BOLT11. */
|
||||
fun shouldRetry(code: NwcErrorCode?): Boolean = code in offerSideCodes
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
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.NwcErrorCode
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.mapNotNullAsync
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -84,11 +86,17 @@ class ZapPaymentHandler(
|
||||
val user: User? = null,
|
||||
)
|
||||
|
||||
/** A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we hold an NWC wallet. */
|
||||
/**
|
||||
* A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we
|
||||
* hold an NWC wallet. [lnAddress] is their BOLT11 route, kept so a refused offer can
|
||||
* fall back to a regular zap (see [payViaBolt12]); null when they publish none.
|
||||
*/
|
||||
data class Bolt12Recipient(
|
||||
val user: User,
|
||||
val offer: String,
|
||||
val weight: Double = 1.0,
|
||||
val lnAddress: String? = null,
|
||||
val relay: NormalizedRelayUrl? = null,
|
||||
)
|
||||
|
||||
suspend fun zap(
|
||||
@@ -167,16 +175,13 @@ class ZapPaymentHandler(
|
||||
// BOLT12 when our default NWC wallet advertises the nwc#2 `pay` method (needed for
|
||||
// the payer proof). Otherwise — no wallet, or a wallet without `pay` — the recipient
|
||||
// stays on lightning, so an unsupported wallet degrades gracefully instead of erroring.
|
||||
val canBolt12 =
|
||||
account.settings.nwcWallets.value
|
||||
.isNotEmpty() &&
|
||||
account.zaps.defaultWalletSupportsBolt12Pay()
|
||||
val canBolt12 = account.zaps.canZapViaBolt12()
|
||||
|
||||
val bolt12Recipients =
|
||||
unverifiedZapsToSend.mapNotNull {
|
||||
val user = it.user
|
||||
if (canBolt12 && it.bolt12Offer != null && user != null) {
|
||||
Bolt12Recipient(user, it.bolt12Offer, it.weight)
|
||||
Bolt12Recipient(user, it.bolt12Offer, it.weight, it.lnAddress, it.relay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -233,48 +238,20 @@ class ZapPaymentHandler(
|
||||
|
||||
// --- Lightning lane -----------------------------------------------------------
|
||||
if (zapsToSend.isNotEmpty()) {
|
||||
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats, totalWeight)
|
||||
|
||||
if (splitZapRequests.isNotEmpty()) {
|
||||
onProgress(0.05f)
|
||||
|
||||
val payables =
|
||||
assembleAllInvoices(
|
||||
requests = splitZapRequests,
|
||||
totalAmountMilliSats = amountMilliSats,
|
||||
message = message,
|
||||
okHttpClient = okHttpClient,
|
||||
onError = onError,
|
||||
onProgress = { onProgress(it * 0.7f + 0.05f) },
|
||||
context = context,
|
||||
totalWeight = totalWeight,
|
||||
)
|
||||
|
||||
if (payables.isNotEmpty()) {
|
||||
onProgress(0.75f)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
null -> {
|
||||
onPayViaIntent(payables.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
zapOverLightning(
|
||||
zapsToSend = zapsToSend,
|
||||
note = note,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
totalAmountMilliSats = amountMilliSats,
|
||||
totalWeight = totalWeight,
|
||||
okHttpClient = okHttpClient,
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
onPayViaIntent = onPayViaIntent,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
// --- BOLT12 lane --------------------------------------------------------------
|
||||
@@ -282,12 +259,15 @@ class ZapPaymentHandler(
|
||||
payViaBolt12(
|
||||
recipients = bolt12Recipients,
|
||||
note = note,
|
||||
pollOption = pollOption,
|
||||
totalAmountMilliSats = amountMilliSats,
|
||||
totalWeight = totalWeight,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
okHttpClient = okHttpClient,
|
||||
onError = onError,
|
||||
onProgress = { onProgress(it * 0.25f + 0.75f) },
|
||||
onPayViaIntent = onPayViaIntent,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
@@ -295,6 +275,68 @@ class ZapPaymentHandler(
|
||||
onProgress(1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* The BOLT11 lane: signs one kind 9734 per recipient, fetches each invoice from
|
||||
* the recipient's LNURL, then settles through the default payment source. Used
|
||||
* for every lnAddress recipient of a zap, and again by [payViaBolt12] for a
|
||||
* recipient whose offer the wallet refused. [onProgress] spans 0.05..1.0.
|
||||
*/
|
||||
private suspend fun zapOverLightning(
|
||||
zapsToSend: List<MyZapSplitSetup>,
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
totalAmountMilliSats: Long,
|
||||
totalWeight: Double,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onError: (String, String, User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, totalAmountMilliSats, totalWeight)
|
||||
if (splitZapRequests.isEmpty()) return
|
||||
|
||||
onProgress(0.05f)
|
||||
|
||||
val payables =
|
||||
assembleAllInvoices(
|
||||
requests = splitZapRequests,
|
||||
totalAmountMilliSats = totalAmountMilliSats,
|
||||
message = message,
|
||||
okHttpClient = okHttpClient,
|
||||
onError = onError,
|
||||
onProgress = { onProgress(it * 0.7f + 0.05f) },
|
||||
context = context,
|
||||
totalWeight = totalWeight,
|
||||
)
|
||||
if (payables.isEmpty()) return
|
||||
|
||||
onProgress(0.75f)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
null -> {
|
||||
onPayViaIntent(payables.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateZapValue(
|
||||
amountMilliSats: Long,
|
||||
weight: Double,
|
||||
@@ -463,21 +505,41 @@ class ZapPaymentHandler(
|
||||
* and (if the returned proof validates) publishes a 9736 zap — see
|
||||
* [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic
|
||||
* and settlement/errors surface later through the async NWC response.
|
||||
*
|
||||
* When the wallet refuses the offer before attempting a payment — it resolves the
|
||||
* offer itself, so a stale, expired or unsupported offer fails there — and the
|
||||
* recipient also publishes a lightning address, the same share is re-sent as a
|
||||
* regular BOLT11 zap through [zapOverLightning], silently. The BOLT12 error is
|
||||
* shown when there is no BOLT11 route or when the refusal does not qualify
|
||||
* ([Bolt12LightningFallback]: a failed payment attempt may still settle, and a
|
||||
* refusal about our own wallet would repeat on BOLT11). A paid-but-no-receipt
|
||||
* outcome and a wallet that never answers are never retried either.
|
||||
*/
|
||||
suspend fun payViaBolt12(
|
||||
recipients: List<Bolt12Recipient>,
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
totalAmountMilliSats: Long,
|
||||
totalWeight: Double,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onError: (String, String, User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
val progress = PaymentProgress(recipients.size, onProgress)
|
||||
|
||||
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
|
||||
fun reportBolt12Error(
|
||||
msgRes: Int,
|
||||
detail: String?,
|
||||
) {
|
||||
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
|
||||
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
|
||||
}
|
||||
|
||||
account.zaps.sendBolt12Zap(
|
||||
zappedEvent = note.event,
|
||||
recipientPubKey = recipient.user.pubkeyHex,
|
||||
@@ -485,9 +547,46 @@ class ZapPaymentHandler(
|
||||
amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight),
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
onError = { msgRes, detail ->
|
||||
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
|
||||
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
|
||||
onError = ::reportBolt12Error,
|
||||
onNotPaid = { code, detail ->
|
||||
val lnAddress = recipient.lnAddress
|
||||
if (lnAddress != null && Bolt12LightningFallback.shouldRetry(code)) {
|
||||
Log.i("ZapPaymentHandler") { "BOLT12 offer refused ($code: $detail); re-sending over BOLT11 to $lnAddress" }
|
||||
try {
|
||||
zapOverLightning(
|
||||
zapsToSend = listOf(MyZapSplitSetup(lnAddress, recipient.weight, recipient.relay, recipient.user)),
|
||||
note = note,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
totalAmountMilliSats = totalAmountMilliSats,
|
||||
totalWeight = totalWeight,
|
||||
okHttpClient = okHttpClient,
|
||||
onError = onError,
|
||||
// The zap's own progress finished when the BOLT12 request was
|
||||
// dispatched; the retry settles in the background like NWC does.
|
||||
onProgress = {},
|
||||
onPayViaIntent = onPayViaIntent,
|
||||
context = context,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Nothing was paid on either rail. Report it as the lightning failure it
|
||||
// is, rather than letting [sendBolt12Zap]'s catch call it "paid, no receipt".
|
||||
Log.w("ZapPaymentHandler", "BOLT11 fallback failed after a refused BOLT12 offer", e)
|
||||
onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
|
||||
}
|
||||
} else {
|
||||
// bolt12_payment_failed always formats a detail; a wallet may send neither
|
||||
// message nor a recognised code.
|
||||
reportBolt12Error(R.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
// No response callback will fire, so account for the settlement step here.
|
||||
reportBolt12Error(R.string.bolt12_payment_failed, nwcTimeoutMessage(context))
|
||||
progress.step()
|
||||
},
|
||||
onProcessed = { progress.step() },
|
||||
)
|
||||
|
||||
+3
@@ -201,6 +201,9 @@ class EventNotificationConsumer(
|
||||
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
|
||||
.getOrNull()
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
private suspend fun dispatchForAccount(
|
||||
event: Event,
|
||||
account: Account,
|
||||
|
||||
+3
@@ -112,6 +112,9 @@ class NotificationDispatcher(
|
||||
// recipient account.
|
||||
// `internal` (was `private`) so the notification-kinds contract test
|
||||
// can pin the push-side kind set against the in-app feed's kind set.
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
internal val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
|
||||
+8
@@ -320,6 +320,14 @@ class NotificationRelayService : Service() {
|
||||
}
|
||||
|
||||
launch {
|
||||
// This flow used to over-report: it is fed by socket callbacks, and until
|
||||
// the OkHttp adapters answered a relay's CLOSE frame a relay-initiated close
|
||||
// produced none (no onClosed, no onFailure, and a silent cancel()
|
||||
// afterwards), so after the feeds tore down in the background it carried
|
||||
// hundreds of already-dropped relays for minutes. The pool now clears it
|
||||
// itself whenever it lets a relay go, and every transport reports its
|
||||
// session end exactly once (see WebSocket), so what it emits is the 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
|
||||
|
||||
+3
@@ -81,6 +81,9 @@ object CodeNotification {
|
||||
event: GitPullRequestUpdateEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be rendered.
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
|
||||
+63
-24
@@ -34,6 +34,7 @@ import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class OkHttpWebSocket(
|
||||
val url: NormalizedRelayUrl,
|
||||
@@ -41,7 +42,17 @@ class OkHttpWebSocket(
|
||||
val out: WebSocketListener,
|
||||
) : WebSocket {
|
||||
private var usingOkHttp: OkHttpClient? = null
|
||||
private var socket: okhttp3.WebSocket? = null
|
||||
|
||||
@Volatile private var socket: okhttp3.WebSocket? = null
|
||||
|
||||
/**
|
||||
* Set once, by whichever of `onClosed`, `onFailure` or [disconnect] ends the session first.
|
||||
* One adapter is one session (the relay client builds a fresh one per dial, and OkHttp binds
|
||||
* exactly one socket to the listener), so a callback only has to ask whether the session
|
||||
* already ended. See quartz's `BasicOkHttpWebSocket` for the full reasoning; the two adapters
|
||||
* differ only in how [needsReconnect] is decided.
|
||||
*/
|
||||
private val ended = AtomicBoolean(false)
|
||||
|
||||
fun buildRequest() = Request.Builder().url(url.url).build()
|
||||
|
||||
@@ -68,8 +79,10 @@ class OkHttpWebSocket(
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
usingOkHttp = httpClient(url)
|
||||
socket = usingOkHttp?.newWebSocket(buildRequest(), OkHttpWebsocketListener(out))
|
||||
if (socket != null || ended.get()) return
|
||||
val client = httpClient(url)
|
||||
usingOkHttp = client
|
||||
socket = client.newWebSocket(buildRequest(), OkHttpWebsocketListener(out))
|
||||
}
|
||||
|
||||
inner class OkHttpWebsocketListener(
|
||||
@@ -91,35 +104,60 @@ class OkHttpWebSocket(
|
||||
}
|
||||
}
|
||||
|
||||
/** Claims the session's single terminal report. False if it already ended. */
|
||||
private fun endSession(): Boolean {
|
||||
if (!ended.compareAndSet(false, true)) return false
|
||||
socket = null
|
||||
incomingMessages.close()
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOpen(
|
||||
webSocket: okhttp3.WebSocket,
|
||||
response: Response,
|
||||
) = out.onOpen(
|
||||
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
|
||||
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
|
||||
)
|
||||
) {
|
||||
if (ended.get()) return
|
||||
out.onOpen(
|
||||
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
|
||||
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: okhttp3.WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
// Asynchronously send the received message to the channel.
|
||||
// `trySendBlocking` is used here for simplicity within the callback,
|
||||
// but it's important to understand potential thread blocking if the buffer is full.
|
||||
if (ended.get()) return
|
||||
// Never blocks (unlimited channel): the OkHttp reader thread must
|
||||
// stay free to keep draining the socket.
|
||||
incomingMessages.trySendBlocking(text)
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: okhttp3.WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
// The relay sent a CLOSE frame. OkHttp fires onClosed only once BOTH peers have sent
|
||||
// one, and sending ours is the application's job (WebSocketListener KDoc; its own
|
||||
// WebSocketEcho recipe does exactly this). Unanswered, the socket sat half-closed:
|
||||
// no onClosed, no onFailure, send() still accepted and discarded, a later cancel()
|
||||
// silent too -- so the relay client believed it was connected until the 120s ping
|
||||
// path failed up to two intervals later.
|
||||
//
|
||||
// Always 1000 rather than echoing `code`: close() validates the code it writes and
|
||||
// throws on the reserved ones (1005, 1006, 1015), and a relay may send anything.
|
||||
webSocket.close(1000, null)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: okhttp3.WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
// Close the channel on failure, and propagate the error.
|
||||
incomingMessages.close()
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
|
||||
socket = null
|
||||
if (!endSession()) return
|
||||
out.onClosed(code, reason)
|
||||
}
|
||||
|
||||
@@ -128,12 +166,7 @@ class OkHttpWebSocket(
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
// Close the channel on failure, and propagate the error.
|
||||
incomingMessages.close()
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
|
||||
socket = null
|
||||
if (!endSession()) return
|
||||
out.onFailure(t, response?.code, response?.message)
|
||||
}
|
||||
}
|
||||
@@ -153,9 +186,15 @@ class OkHttpWebSocket(
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
// uses cancel to kill the SEND stack that might be waiting
|
||||
socket?.cancel()
|
||||
// Claim the session ourselves and cancel (which also kills a SEND stack that might be
|
||||
// waiting): OkHttp's cancel() raises no callback when no reader is left to fail, and when
|
||||
// it does the failure arrives later on its own thread. The relay client needs the answer
|
||||
// now, and must not hear from this socket again.
|
||||
val closing = socket ?: return
|
||||
if (!ended.compareAndSet(false, true)) return
|
||||
socket = null
|
||||
closing.cancel()
|
||||
out.onClosed(1000, "client disconnect")
|
||||
}
|
||||
|
||||
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ fun RenderTopButtonsPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
RenderTopButtons(
|
||||
mediaData = MediaItemData("http://test.mp4"),
|
||||
mediaData = MediaItemData("https://test.mp4"),
|
||||
hasMultipleQualities = false,
|
||||
qualityButton = {},
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
|
||||
+2
-1
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.abbreviateBolt12Offer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
@@ -191,7 +192,7 @@ fun Bolt12OfferEntry(
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Text(
|
||||
text = "${offer.take(14)}…${offer.takeLast(6)}",
|
||||
text = abbreviateBolt12Offer(offer),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ val DEFAULT_MEDIA_SERVERS: List<ServerName> =
|
||||
ServerName("Azzamo", "https://blossom.azzamo.media", ServerType.Blossom),
|
||||
ServerName("YakiHonne", "https://blossom.yakihonne.com/", ServerType.Blossom),
|
||||
ServerName("Primal", "https://blossom.primal.net/", ServerType.Blossom),
|
||||
ServerName("Sovbit", "https://cdn.sovbit.host", ServerType.Blossom),
|
||||
ServerName("Sovbit", "https://files.sovbit.host", ServerType.Blossom),
|
||||
ServerName("Nostr.Download", "https://nostr.download", ServerType.Blossom),
|
||||
ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom),
|
||||
ServerName("NostrMedia (Paid)", "https://nostrmedia.com", ServerType.Blossom),
|
||||
|
||||
@@ -65,6 +65,7 @@ import org.webrtc.RtpSender
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val TAG = "CallSession"
|
||||
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
|
||||
@@ -429,6 +430,7 @@ class CallSession(
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -47,10 +47,13 @@ import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.gif
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font10SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size6dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
@@ -72,6 +75,15 @@ fun GifVideoView(
|
||||
// remember() to avoid the recompute would cost more (slot read + N equality checks)
|
||||
// than the work it saves; that's why this stays as a plain expression.
|
||||
val ratio = dimensions?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(videoUri)
|
||||
|
||||
// Mirrors [mediaSizingModifier]: Crop gets fillMaxSize() and a known ratio gets
|
||||
// aspectRatio(), both of which bound the height. Everything else is a bare
|
||||
// fillMaxWidth() that wraps its content, and only THAT case needs a loading state
|
||||
// with intrinsic height to keep the note from collapsing to nothing. Keying the
|
||||
// fallback on `ratio` alone would put raw URL text inside every Crop card cell --
|
||||
// MyAsyncImage passes dimensions/blurhash/thumbhash all null, so a gif in a card
|
||||
// slot hits this on first load, before MediaAspectRatioCache knows its size.
|
||||
val heightIsBounded = contentScale == ContentScale.Crop || ratio != null
|
||||
val autoPlay = accountViewModel.settings.autoPlayVideos()
|
||||
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier
|
||||
val context = LocalContext.current
|
||||
@@ -108,13 +120,29 @@ fun GifVideoView(
|
||||
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading -> {
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
contentDescription,
|
||||
contentScale,
|
||||
Modifier.fillMaxSize(),
|
||||
thumbhash = thumbhash,
|
||||
)
|
||||
// When the height is unbounded (see [heightIsBounded]) this branch MUST
|
||||
// emit something with an intrinsic height, or the box wraps nothing and the
|
||||
// whole note collapses to zero -- no picture, no URL, no spinner, just a gap
|
||||
// in the feed until the load finishes. DisplayBlurHash renders NOTHING when
|
||||
// both hashes are absent (placeholderModel returns null), which is exactly
|
||||
// what a no-imeta post hits. Mirrors UrlImageView's ladder.
|
||||
if (blurhash != null || thumbhash != null) {
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
contentDescription,
|
||||
contentScale,
|
||||
Modifier.fillMaxSize(),
|
||||
thumbhash = thumbhash,
|
||||
)
|
||||
} else if (heightIsBounded) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
LoadingAnimation(Size40dp, Size6dp)
|
||||
}
|
||||
} else {
|
||||
WaitAndDisplay {
|
||||
DisplayUrlWithLoadingSymbol(videoUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
|
||||
@@ -40,6 +40,7 @@ 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 androidx.core.graphics.withTranslation
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MathParser
|
||||
import ru.noties.jlatexmath.JLatexMathDrawable
|
||||
|
||||
@@ -130,12 +131,11 @@ fun LatexEquation(
|
||||
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)
|
||||
native.withTranslation(y = drawTopPx) {
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
drawable.draw(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trailing.isNotEmpty()) {
|
||||
|
||||
@@ -110,6 +110,10 @@ fun RenderLoaded(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav? = null,
|
||||
) {
|
||||
// Bound to a local: `UrlInfoItem` lives in another module, so the null check below cannot
|
||||
// smart-cast the property itself.
|
||||
val playableMedia = state.previewInfo.playableMediaUrl
|
||||
|
||||
when {
|
||||
state.previewInfo.mimeType.startsWith("image") -> {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
@@ -122,10 +126,14 @@ fun RenderLoaded(
|
||||
}
|
||||
}
|
||||
|
||||
state.previewInfo.mimeType.startsWith("video") -> {
|
||||
// Audio rides the video pipeline; see RichTextParser.videoExt. The fetched Content-Type is
|
||||
// forwarded because it is the only type signal for an extension-less URL -- an HLS
|
||||
// playlist served as `audio/x-mpegurl` off `/stream?id=1` is otherwise handed to the
|
||||
// progressive source and fails.
|
||||
state.previewInfo.mimeType.startsWith("video") || state.previewInfo.mimeType.startsWith("audio") -> {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlVideo(url, uri = callbackUri),
|
||||
content = MediaUrlVideo(url, uri = callbackUri, mimeType = state.previewInfo.mimeType),
|
||||
roundedCorner = true,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -144,6 +152,29 @@ fun RenderLoaded(
|
||||
}
|
||||
}
|
||||
|
||||
// An HTML page that declares the media file it is a player for: nostr.build publishes one
|
||||
// such page per upload and names the real file in `og:video` or `og:audio`. Play that
|
||||
// file, with the page's `og:image` as poster/cover art, instead of a card that only links
|
||||
// out. `playableMediaUrl` is null unless the page's declared type held up, so a page whose
|
||||
// `og:video` is really an embed (YouTube's, say) still falls through to the card below.
|
||||
playableMedia != null -> {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content =
|
||||
MediaUrlVideo(
|
||||
url = playableMedia,
|
||||
description = state.previewInfo.title.ifBlank { null },
|
||||
uri = callbackUri,
|
||||
artworkUri = state.previewInfo.imageUrlFullPath.ifBlank { null },
|
||||
mimeType = state.previewInfo.playableMediaType,
|
||||
),
|
||||
roundedCorner = true,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
UrlPreviewCard(
|
||||
url,
|
||||
|
||||
@@ -117,12 +117,16 @@ fun UrlPreviewCard(
|
||||
},
|
||||
),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = previewInfo.imageUrlFullPath,
|
||||
contentDescription = previewInfo.title,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = previewCardImageModifier,
|
||||
)
|
||||
// A Loaded preview no longer implies an image: a player page that ships no cover art is
|
||||
// kept (it has media to play), and painting its empty string left a blank 180dp box.
|
||||
if (previewInfo.imageUrlFullPath.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = previewInfo.imageUrlFullPath,
|
||||
contentDescription = previewInfo.title,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = previewCardImageModifier,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
|
||||
+3
-3
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.ui.feeds
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.vitorpamplona.amethyst.commons.feeds.AdditiveComplexFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.service.BundledInsert
|
||||
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveComplexFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Stable
|
||||
|
||||
@@ -28,8 +28,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -31,7 +31,7 @@ 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.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
@@ -42,7 +43,6 @@ 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.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -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.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.feeds
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
|
||||
|
||||
@Composable
|
||||
fun WatchLifecycleAndUpdateModel(model: InvalidatableContent) {
|
||||
|
||||
@@ -29,8 +29,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
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.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -61,8 +61,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.isCommunityDefinition
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo
|
||||
import com.vitorpamplona.amethyst.commons.model.textNoteModifications
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.approve
|
||||
@@ -78,8 +80,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.isCommunityDefinition
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo
|
||||
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
|
||||
|
||||
@@ -216,6 +216,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
@@ -225,6 +226,7 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -1480,10 +1482,15 @@ fun zapClick(
|
||||
|
||||
choices.size == 1 -> {
|
||||
// One-tap fast path is Lightning-only. If the recipient can't
|
||||
// receive Lightning (no lud16/lud06), firing a zap here would just
|
||||
// fail — open the picker instead so the rail-aware chip can route to
|
||||
// cashu / on-chain / reload.
|
||||
val caps = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState)
|
||||
// receive Lightning (no lud16/lud06, and no BOLT12 offer our wallet
|
||||
// can pay), firing a zap here would just fail — open the picker
|
||||
// instead so the rail-aware chip can route to cashu / on-chain / reload.
|
||||
val caps =
|
||||
RailCapabilityResolver.peek(
|
||||
baseNote,
|
||||
accountViewModel.account.cashuWalletState,
|
||||
bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(),
|
||||
)
|
||||
if (caps.hasLightning) {
|
||||
onZapStarts()
|
||||
accountViewModel.zap(
|
||||
@@ -2124,16 +2131,26 @@ fun observeZapRailCapability(
|
||||
): RailCapability {
|
||||
val cashuState = accountViewModel.account.cashuWalletState
|
||||
val author = baseNote.author
|
||||
// These four are deliberately read only to drive the recompute below — do NOT
|
||||
// delete them as "unused". Each observe* call ALSO subscribes the relay fetch
|
||||
// (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is
|
||||
// open), and each value is a remember() key so railCapability recomputes when
|
||||
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
|
||||
// just say *when* to re-run it.
|
||||
// Every value below up to `showOnchainWallet` is deliberately read only to drive
|
||||
// the recompute — do NOT delete them as "unused". Each observe* call ALSO
|
||||
// subscribes the relay fetch (so a not-yet-seen kind:0 / kind:10019 / kind:10058
|
||||
// gets pulled in while the popup is open), and each value is a remember() key so
|
||||
// railCapability recomputes when it arrives. RailCapabilityResolver.peek re-reads
|
||||
// everything itself; these just say *when* to re-run it.
|
||||
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
|
||||
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
|
||||
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
|
||||
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
|
||||
// BOLT12 route inputs, same contract: the recipient's kind:10058 offer list
|
||||
// (rides in UserMetadataForKeyKinds beside kind:0) and our default NWC wallet,
|
||||
// whose `pay` support decides whether that offer makes them Lightning-payable.
|
||||
val bolt12OfferList = author?.let { observeNoteEvent<Bolt12OfferListEvent>(it.bolt12OfferListNote, accountViewModel).value }
|
||||
val nip47State = accountViewModel.account.nip47SignerState
|
||||
val defaultWalletUri by nip47State.defaultWalletUri.collectAsStateWithLifecycle()
|
||||
// The wallet's kind:13194 info (its `pay` support) is a plain cache read inside
|
||||
// canZapViaBolt12(); this counter is what recomputes when it lands after opening.
|
||||
val walletInfoUpdates by remember(nip47State) { nip47State.infoCache?.updates ?: MutableStateFlow(0) }
|
||||
.collectAsStateWithLifecycle()
|
||||
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
|
||||
// rail from the zap chips too, matching the wallet screen, profile chips, and
|
||||
// Send Payment screen.
|
||||
@@ -2180,11 +2197,20 @@ fun observeZapRailCapability(
|
||||
cashuEntries,
|
||||
recipientInfo,
|
||||
nutzapInfo,
|
||||
bolt12OfferList,
|
||||
defaultWalletUri,
|
||||
walletInfoUpdates,
|
||||
showPayToChip,
|
||||
recipientPayTo,
|
||||
payToApps,
|
||||
) {
|
||||
val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip)
|
||||
val rc =
|
||||
RailCapabilityResolver.peek(
|
||||
baseNote,
|
||||
cashuState,
|
||||
showPayToChip,
|
||||
bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(),
|
||||
)
|
||||
if (onchainEnabled) {
|
||||
rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats)
|
||||
} else {
|
||||
|
||||
+2
-2
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.location
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.ColorMatrix
|
||||
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
|
||||
@@ -36,6 +35,7 @@ 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.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -159,7 +159,7 @@ fun LocationPreviewMap(
|
||||
remember(pinColor, pinEmoji) {
|
||||
if (pinColor != null && pinEmoji != null) {
|
||||
val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density)
|
||||
BitmapDrawable(context.resources, bitmap)
|
||||
bitmap.toDrawable(context.resources)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
+2
-2
@@ -228,7 +228,7 @@ private fun MyLoadUrlPreviewDirect(
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxHeight().aspectRatio(1f),
|
||||
)
|
||||
} else if (state.previewInfo.mimeType.startsWith("video")) {
|
||||
} else if (state.previewInfo.mimeType.startsWith("video") || state.previewInfo.mimeType.startsWith("audio")) {
|
||||
VideoView(
|
||||
state.previewInfo.url,
|
||||
mimeType = state.previewInfo.mimeType,
|
||||
@@ -297,7 +297,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (state.previewInfo.mimeType.startsWith("video")) {
|
||||
} else if (state.previewInfo.mimeType.startsWith("video") || state.previewInfo.mimeType.startsWith("audio")) {
|
||||
VideoView(
|
||||
state.previewInfo.url,
|
||||
mimeType = state.previewInfo.mimeType,
|
||||
|
||||
@@ -46,7 +46,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge
|
||||
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessGameViewer
|
||||
import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessGameViewer
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.chess_accept
|
||||
import com.vitorpamplona.amethyst.commons.resources.chess_decline
|
||||
|
||||
@@ -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.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo
|
||||
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.commons.ui.note.replyingDirectlyTo
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
|
||||
@@ -34,8 +34,8 @@ 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.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
|
||||
|
||||
@@ -25,10 +25,10 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
@@ -118,6 +118,5 @@ open class UserFeedViewModel(
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
bundler.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
|
||||
+2
-3
@@ -42,6 +42,7 @@ 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.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Dao
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
@@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuild
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
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
|
||||
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCacheAsync
|
||||
@@ -1196,7 +1196,7 @@ class AccountViewModel(
|
||||
.isNotEmpty()
|
||||
|
||||
/** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */
|
||||
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay()
|
||||
fun canPayBolt12ViaNwc(): Boolean = account.zaps.canZapViaBolt12()
|
||||
|
||||
/**
|
||||
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
|
||||
@@ -2647,7 +2647,6 @@ class AccountViewModel(
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
.clear()
|
||||
feedStates.destroy()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun loadMentions(
|
||||
|
||||
+1
-1
@@ -21,10 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ 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.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
|
||||
+1
-1
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
|
||||
+1
-1
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
|
||||
class BookmarkPrivateFeedFilter(
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
|
||||
class BookmarkPublicFeedFilter(
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ private fun Picture(
|
||||
onValueChange = { bookmarkGroupInfoViewModel.picture.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "http://mygroup.com/logo.jpg",
|
||||
text = "https://mygroup.com/logo.jpg",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
|
||||
class OldBookmarkPrivateFeedFilter(
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
|
||||
class OldBookmarkPublicFeedFilter(
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent
|
||||
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
|
||||
/**
|
||||
|
||||
-1
@@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
stopObserving()
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/** One decrypted observer telemetry frame rendered on the Observer tab. */
|
||||
|
||||
-1
@@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
liveJob?.cancel()
|
||||
liveJob = null
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
|
||||
+2
-2
@@ -47,6 +47,8 @@ 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.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
@@ -56,8 +58,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_collection_count
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_subtitle
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_export_event
|
||||
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.ui.feeds.RefresheableBox
|
||||
|
||||
+2
-2
@@ -51,6 +51,8 @@ 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.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange
|
||||
@@ -63,8 +65,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_day_subtitle
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_day_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_day
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_day
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
|
||||
+11
-3
@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,11 +59,15 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new
|
||||
// SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling.
|
||||
private val MonthShortFormatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("MMM", Locale.getDefault())
|
||||
// Cached per locale rather than in a single val that captures the locale once: the month
|
||||
// names have to follow a language the user changes while the app is running.
|
||||
private val monthShortFormatters = ConcurrentHashMap<Locale, DateTimeFormatter>()
|
||||
|
||||
private fun monthShortFormatter(locale: Locale): DateTimeFormatter = monthShortFormatters.getOrPut(locale) { DateTimeFormatter.ofPattern("MMM", locale) }
|
||||
|
||||
@Composable
|
||||
fun CalendarEventListCard(
|
||||
@@ -214,7 +219,10 @@ private fun CalendarDateBadge(startSeconds: Long?) {
|
||||
Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
}
|
||||
val day = localDate.dayOfMonth.toString()
|
||||
val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() }
|
||||
// LocalLocale rather than Locale.getDefault(): the latter is not observable, so a
|
||||
// locale change while the app runs would leave the month name in the old language.
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val month = remember(localDate, locale) { monthShortFormatter(locale).format(localDate).uppercase() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.size(width = 52.dp, height = 60.dp),
|
||||
|
||||
+2
-2
@@ -38,6 +38,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
|
||||
@@ -46,8 +48,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_subtitle
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_section_past
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_section_upcoming
|
||||
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.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
|
||||
|
||||
+2
-2
@@ -53,6 +53,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MONTH_GRID_MAX_LANES
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
|
||||
@@ -62,8 +64,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_day_a11y_selected_s
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_day_a11y_today_suffix
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_month
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_month
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
+2
-2
@@ -51,6 +51,8 @@ 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.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
@@ -60,8 +62,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_week_subtitle
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_week_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_week
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_week
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
|
||||
-1
@@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
liveScanJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun toggle(address: Address) {
|
||||
|
||||
+1
-1
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
|
||||
+1
-1
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
|
||||
+2
-2
@@ -36,9 +36,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedError
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.MarmotGroupFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.feeds.MarmotGroupFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user