mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00379719c8 | ||
|
|
0c59d0ef2e | ||
|
|
3e9b629b8d | ||
|
|
032546f70b | ||
|
|
e2bbe41991 | ||
|
|
25e61542ba | ||
|
|
624e38ce34 | ||
|
|
b243184026 | ||
|
|
41113ca259 | ||
|
|
2b5ce62c6e | ||
|
|
5dcaa0bf5d | ||
|
|
2afe9b9221 | ||
|
|
1590fca9b7 | ||
|
|
afeb70b15b | ||
|
|
68a9f77337 | ||
|
|
153fd89f8d | ||
|
|
f9b11ef68b | ||
|
|
c8262d440b | ||
|
|
686975e322 | ||
|
|
5943dfe3d7 | ||
|
|
8e6cb1f7ac | ||
|
|
0a4a0e60ff | ||
|
|
1725425658 | ||
|
|
047eb197a4 | ||
|
|
795394c454 | ||
|
|
a2b0fd9405 | ||
|
|
38bbfa8c74 | ||
|
|
94a229e983 | ||
|
|
81b9ba853a | ||
|
|
9193502748 | ||
|
|
e524d38dc5 | ||
|
|
bfdeaa1ace | ||
|
|
9e380cf9dc | ||
|
|
0067db3608 | ||
|
|
08529c3a74 | ||
|
|
5f53568204 | ||
|
|
c33256a08e | ||
|
|
64e0ce4b3e | ||
|
|
71f72a0910 | ||
|
|
6030d22aa9 | ||
|
|
d108ba0cc8 | ||
|
|
5142c88629 | ||
|
|
732baf8a8a | ||
|
|
ae0b70728e | ||
|
|
ea28ea2f76 | ||
|
|
8c6a38a7bf | ||
|
|
49fb3a7a00 | ||
|
|
78fef44e2f | ||
|
|
add7bfe3ca | ||
|
|
b8135e76c4 | ||
|
|
18c576a068 | ||
|
|
5b846d64d6 | ||
|
|
4fa619d2a2 | ||
|
|
fe8580ca56 | ||
|
|
1ea820e699 | ||
|
|
9e7ffe6854 | ||
|
|
501c9a5b5a | ||
|
|
285a51e98f | ||
|
|
8fa4a72a9a | ||
|
|
97b13b7c47 | ||
|
|
42698ed59c | ||
|
|
943e137d4e | ||
|
|
e076b1eaee | ||
|
|
2ab934b2d2 | ||
|
|
d4ac5149f7 | ||
|
|
ae2e971dd9 | ||
|
|
88ae3d0f5a | ||
|
|
1093b3ce8a | ||
|
|
3887b033e0 | ||
|
|
713b7d41f6 | ||
|
|
6a4ddfa974 | ||
|
|
1f4d2bf9b5 | ||
|
|
5e8fdedb83 | ||
|
|
54252b0fb9 | ||
|
|
3fefb62f1a | ||
|
|
6e3af61e6a | ||
|
|
cfa6f72760 | ||
|
|
6d34d3d9f9 | ||
|
|
a87222c51f | ||
|
|
32f0c462bb | ||
|
|
d63e14bb36 | ||
|
|
f6a49761f5 | ||
|
|
a4b83b86e7 | ||
|
|
08a3bab605 | ||
|
|
4a5278ee55 | ||
|
|
7aa345ba28 | ||
|
|
284671aa0f | ||
|
|
a894315335 | ||
|
|
7995f042ab | ||
|
|
d22e2c1fc7 | ||
|
|
3b69fae6f4 | ||
|
|
cb53a863ce | ||
|
|
0118c74163 | ||
|
|
a5da7e49ba | ||
|
|
6dc631e85d | ||
|
|
0629b23b1a | ||
|
|
c0a38bc0b7 | ||
|
|
aa2e598e28 | ||
|
|
cb49caae69 | ||
|
|
c03e7cfa5e | ||
|
|
1a812c3df5 | ||
|
|
8c87745b21 | ||
|
|
7d01d3b155 | ||
|
|
17f0ee7fbe | ||
|
|
d504f41870 | ||
|
|
212008b037 | ||
|
|
17189aba56 | ||
|
|
3ae53d5d19 | ||
|
|
92f3fd49b0 | ||
|
|
84c6dba81b | ||
|
|
2616bbfea8 | ||
|
|
f0aa12982a | ||
|
|
3c4ab62729 | ||
|
|
892aca2b12 | ||
|
|
1924da60f9 | ||
|
|
abc1bc8a82 | ||
|
|
f337e2da50 | ||
|
|
303b84ec30 | ||
|
|
10cdbffcfd | ||
|
|
ec71cd7084 |
+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'
|
||||
|
||||
+11
-9
@@ -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
|
||||
@@ -466,8 +466,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 +533,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 +587,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 +603,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 +686,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
|
||||
|
||||
+32
-1
@@ -3,7 +3,7 @@
|
||||
**App:** Amethyst (Android Nostr client)<br>
|
||||
**Publisher:** Vitor Pamplona<br>
|
||||
**Contact:** amethyst@vitorpamplona.com<br>
|
||||
**Last updated:** 2026-05-24
|
||||
**Last updated:** 2026-09-12
|
||||
|
||||
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
|
||||
|
||||
@@ -20,6 +20,7 @@ Using the app causes the following data to leave your phone:
|
||||
- **Nostr events** you publish, sent to the relays you have configured.
|
||||
- **Subscriptions** (filters describing what you want to read), sent to those relays.
|
||||
- **Media uploads** (images, audio, video), sent to the media server you select.
|
||||
- **Workout summaries**, when you choose to publish one — see [Health and fitness data](#health-and-fitness-data-health-connect) below.
|
||||
- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app.
|
||||
- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy).
|
||||
|
||||
@@ -29,6 +30,36 @@ The developer does not run any server that aggregates or stores this data.
|
||||
|
||||
Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling.
|
||||
|
||||
### Health and fitness data (Health Connect)
|
||||
|
||||
Amethyst's **Workouts** section lets you publish a summary of a finished workout to the Nostr relays you choose (a NIP-101e kind 1301 event), so the people who follow you can see it. To save you typing the numbers in by hand, Amethyst can read the workout your watch or fitness app already saved to **Android Health Connect** and pre-fill the post.
|
||||
|
||||
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open the New Workout composer — never on first launch.
|
||||
|
||||
**What Amethyst reads, and what each type is for:**
|
||||
|
||||
| Health Connect data type | Permission | What it is used for |
|
||||
| --- | --- | --- |
|
||||
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start time and duration — the title, date and duration of the post. |
|
||||
| Distance | `READ_DISTANCE` | The distance of the run, ride, walk or swim. |
|
||||
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | The energy the workout burned. |
|
||||
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback energy figure for sources that only record total energy. |
|
||||
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate over the workout — how hard the effort was. |
|
||||
| Steps | `READ_STEPS` | The step count of a run, walk or hike. |
|
||||
| ElevationGained | `READ_ELEVATION_GAINED` | How much you climbed. |
|
||||
|
||||
Health Connect groups a few data types under one permission: `READ_EXERCISE` also covers CyclingPedalingCadence and `READ_STEPS` also covers StepsCadence. Amethyst does not read, store, or publish cadence — those types come attached to the permissions above and are never requested separately.
|
||||
|
||||
**Limits on this access:**
|
||||
|
||||
- **Read-only.** Amethyst never writes to Health Connect.
|
||||
- **Foreground only.** Reads happen only while the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
|
||||
- **Last 7 days only.** Only sessions that finished in the previous 7 days are offered. Amethyst does not request `READ_HEALTH_DATA_HISTORY`.
|
||||
- **No location.** Amethyst does not request `READ_EXERCISE_ROUTE`, so it never receives the GPS track of a workout.
|
||||
- **Nothing is uploaded automatically.** Health data stays on your device until you pick a suggestion, review the pre-filled post, and publish it yourself. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
|
||||
- **No other use.** Health data is never used for advertising, analytics, profiling, or sale, and is never shared with third parties. It is not used to determine your eligibility for insurance, credit, or employment, and is not transferred to any such party.
|
||||
- **Revocable.** Turn the feature off under Settings → Compose → "Suggest workouts to share", or revoke the permissions in Health Connect at any time. Amethyst keeps the workout suggestions it has already shown only in memory; revoking access stops all reads immediately.
|
||||
|
||||
### What relays can see
|
||||
|
||||
A relay you connect to sees:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
@@ -188,11 +188,6 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Health Connect privacy-policy rationale (required by Google when reading health data) -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -446,15 +441,28 @@
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
|
||||
the permission request fails silently (no dialog appears). The system launches it,
|
||||
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
|
||||
MainActivity. Android 13 and lower use the ACTION_SHOW_PERMISSIONS_RATIONALE
|
||||
intent-filter declared on MainActivity above. -->
|
||||
<!-- Health Connect permissions rationale. Both entry points land on the same screen,
|
||||
which explains what Amethyst reads from Health Connect and why; it needs no account,
|
||||
so it works even when launched cold from Health Connect itself.
|
||||
Android 13 and lower open it with ACTION_SHOW_PERMISSIONS_RATIONALE; without a
|
||||
target for it the permission request fails silently (no dialog appears). -->
|
||||
<activity
|
||||
android:name=".ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/health_connect_rationale_activity_label"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Android 14+ route to the same rationale screen. The system launches it, guarded by
|
||||
START_VIEW_PERMISSION_USAGE, from the Health Connect data-management screens. -->
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".ui.MainActivity"
|
||||
android:targetActivity=".ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
|
||||
@@ -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) },
|
||||
|
||||
+43
-24
@@ -36,6 +36,8 @@ import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.roundToInt
|
||||
@@ -54,6 +56,9 @@ class HealthConnectManager(
|
||||
) {
|
||||
private val client: HealthConnectClient by lazy { HealthConnectClient.getOrCreate(context) }
|
||||
|
||||
/** Writer package -> display label. See [resolveSourceName]. */
|
||||
private val sourceNames = mutableMapOf<String, String>()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HealthConnectManager"
|
||||
|
||||
@@ -132,25 +137,31 @@ class HealthConnectManager(
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return try {
|
||||
val response =
|
||||
client.readRecords(
|
||||
ReadRecordsRequest(
|
||||
recordType = ExerciseSessionRecord::class,
|
||||
timeRangeFilter = TimeRangeFilter.between(since, now),
|
||||
),
|
||||
)
|
||||
Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
|
||||
val mapped = response.records.mapNotNull { mapSession(it) }
|
||||
// Fold split-up sessions of the same activity (a long run broken around
|
||||
// breaks) into one suggestion so the composer offers the whole effort.
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
|
||||
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
|
||||
merged
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(TAG, "Failed to read workouts from Health Connect", e)
|
||||
emptyList()
|
||||
// The callers are composables launching into rememberCoroutineScope(), i.e.
|
||||
// Dispatchers.Main. Health Connect's own calls suspend, but the PackageManager
|
||||
// lookup in resolveSourceName is a blocking binder call, so the whole read
|
||||
// moves off the UI thread rather than relying on each step to behave.
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response =
|
||||
client.readRecords(
|
||||
ReadRecordsRequest(
|
||||
recordType = ExerciseSessionRecord::class,
|
||||
timeRangeFilter = TimeRangeFilter.between(since, now),
|
||||
),
|
||||
)
|
||||
Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
|
||||
val mapped = response.records.mapNotNull { mapSession(it) }
|
||||
// Fold split-up sessions of the same activity (a long run broken around
|
||||
// breaks) into one suggestion so the composer offers the whole effort.
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
|
||||
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
|
||||
merged
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(TAG, "Failed to read workouts from Health Connect", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,11 +219,19 @@ class HealthConnectManager(
|
||||
*/
|
||||
private fun resolveSourceName(packageName: String): String {
|
||||
if (packageName.isBlank()) return DEFAULT_SOURCE
|
||||
runCatching {
|
||||
val pm = context.packageManager
|
||||
return pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
|
||||
}
|
||||
return KNOWN_SOURCES[packageName] ?: packageName
|
||||
// Memoized: getApplicationInfo is a blocking binder call, and a week of sessions
|
||||
// almost always comes from the same one or two writer apps, so an uncached lookup
|
||||
// pays for the same round trip once per session.
|
||||
sourceNames[packageName]?.let { return it }
|
||||
|
||||
val resolved =
|
||||
runCatching {
|
||||
val pm = context.packageManager
|
||||
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
|
||||
}.getOrNull() ?: KNOWN_SOURCES[packageName] ?: packageName
|
||||
|
||||
sourceNames[packageName] = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
/** Aggregates the optional metrics over the session window. Null if aggregation fails. */
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-16
@@ -119,7 +119,6 @@ class MarmotFileUploader(
|
||||
// imprecisely.
|
||||
val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE
|
||||
val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename)
|
||||
val v2Cipher = cipher
|
||||
|
||||
item.orchestrator.uploadEncrypted(
|
||||
uri = media.uri,
|
||||
@@ -142,21 +141,19 @@ class MarmotFileUploader(
|
||||
// compression and metadata stripping — because that is what the
|
||||
// key was derived from.
|
||||
val reference =
|
||||
v2Cipher?.let {
|
||||
EncryptedMediaReferenceV2(
|
||||
locators =
|
||||
listOf(
|
||||
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
|
||||
),
|
||||
ciphertextSha256 = it.ciphertextSha256,
|
||||
plaintextSha256 = it.plaintextSha256,
|
||||
nonce = it.nonce,
|
||||
mediaType = it.mediaType,
|
||||
filename = filename,
|
||||
dim = serverResult.fileHeader.dim?.toString(),
|
||||
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
}
|
||||
EncryptedMediaReferenceV2(
|
||||
locators =
|
||||
listOf(
|
||||
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
|
||||
),
|
||||
ciphertextSha256 = cipher.ciphertextSha256,
|
||||
plaintextSha256 = cipher.plaintextSha256,
|
||||
nonce = cipher.nonce,
|
||||
mediaType = cipher.mediaType,
|
||||
filename = filename,
|
||||
dim = serverResult.fileHeader.dim?.toString(),
|
||||
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
results.add(
|
||||
Mip04UploadResult(
|
||||
url = serverResult.url,
|
||||
|
||||
+2
-2
@@ -38,13 +38,13 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip04
|
||||
import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip17
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user